start-command 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/bin/cli.js +8 -0
- package/src/lib/args-parser.js +108 -8
- package/src/lib/command-builder.js +17 -0
- package/src/lib/isolation.js +72 -1
- package/src/lib/status-formatter.js +105 -15
- package/src/lib/usage.js +7 -1
- package/test/args-parser-isolation-alias.js +41 -0
- package/test/docker-runtime-options.js +245 -0
- package/test/session-name-status.js +243 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.29.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fix detached docker `--status`/`--list` reporting a terminal status (`executed`) with the `-1` sentinel while the container is still running (or not visible yet on a slow Docker-in-Docker host). `isDetachedSessionAlive()` now treats a failed `docker inspect` as "unknown" (returns `null`) instead of "stopped", so a session whose container has not appeared yet stays `executing` rather than being marked finished. When a container has genuinely stopped, `enrichDetachedStatus()` resolves the real exit code from the `Exit Code:` log footer and then `docker inspect .State.ExitCode`, only falling back to `-1` when no real code can be obtained.
|
|
8
|
+
|
|
9
|
+
Fix `--status` for detached executions resurrecting a completed (killed) record. `enrichDetachedStatus()` no longer flips an already-`executed` record back to `executing` (and nulls its exit code) just because `screen -ls`/`tmux`/`docker` still lists a same-named session — a lingering shell can outlive a SIGKILLed command (e.g. OOM, exit 137). The recorded exit code and the `Exit Code:` log footer that `start` itself writes are now treated as authoritative; the record only flips to `executing` when there is no recorded exit code and no terminal footer in the log.
|
|
10
|
+
|
|
11
|
+
## 0.29.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- Add Docker isolation runtime controls: `--volume`/`-v`, `--mount`, `--env`/`-e`, and `--privileged`. These are threaded into the underlying `docker run` invocation and recorded in `--status`/`--list` metadata, allowing callers to mount tool credentials, pass environment variables, and run Docker-in-Docker images without wrapping `docker run` themselves.
|
|
16
|
+
|
|
17
|
+
Add `--isolation` as an alias for `--isolated` and fail fast on unknown wrapper options.
|
|
18
|
+
|
|
3
19
|
## 0.28.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -22,6 +22,8 @@ const {
|
|
|
22
22
|
appendLogFile,
|
|
23
23
|
createLogPath,
|
|
24
24
|
getDefaultDockerImage,
|
|
25
|
+
buildDockerRuntimeStatusLines,
|
|
26
|
+
buildDockerRuntimeMetadata,
|
|
25
27
|
} = require('../lib/isolation');
|
|
26
28
|
const {
|
|
27
29
|
createIsolatedUser,
|
|
@@ -470,6 +472,7 @@ async function runWithIsolation(
|
|
|
470
472
|
if (effectiveImage) {
|
|
471
473
|
extraLines.push(`[Isolation] Image: ${effectiveImage}`);
|
|
472
474
|
}
|
|
475
|
+
extraLines.push(...buildDockerRuntimeStatusLines(options));
|
|
473
476
|
if (options.endpoint) {
|
|
474
477
|
extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
|
|
475
478
|
}
|
|
@@ -496,6 +499,7 @@ async function runWithIsolation(
|
|
|
496
499
|
isolationMode: mode,
|
|
497
500
|
sessionName,
|
|
498
501
|
image: effectiveImage,
|
|
502
|
+
...buildDockerRuntimeMetadata(options),
|
|
499
503
|
endpoint: options.endpoint,
|
|
500
504
|
user: options.user,
|
|
501
505
|
keepAlive: options.keepAlive,
|
|
@@ -570,6 +574,10 @@ async function runWithIsolation(
|
|
|
570
574
|
user: createdUser,
|
|
571
575
|
keepAlive: options.keepAlive,
|
|
572
576
|
autoRemoveDockerContainer: options.autoRemoveDockerContainer,
|
|
577
|
+
volumes: options.volumes,
|
|
578
|
+
mounts: options.mounts,
|
|
579
|
+
env: options.env,
|
|
580
|
+
privileged: options.privileged,
|
|
573
581
|
shell: options.shell,
|
|
574
582
|
logPath: logFilePath,
|
|
575
583
|
});
|
package/src/lib/args-parser.js
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* 2. $ [wrapper-options] command [command-options]
|
|
7
7
|
*
|
|
8
8
|
* Wrapper Options:
|
|
9
|
-
* --isolated, -i <backend>
|
|
9
|
+
* --isolated, --isolation, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
|
|
10
10
|
* --attached, -a Run in attached mode (foreground)
|
|
11
11
|
* --detached, -d Run in detached mode (background)
|
|
12
12
|
* --session, -s <name> Session name for isolation
|
|
13
13
|
* --image <image> Docker image (optional, defaults to OS-matched image)
|
|
14
|
+
* --volume, -v <host:container[:mode]> Docker bind mount/volume (repeatable, docker only)
|
|
15
|
+
* --mount <mount-spec> Docker --mount spec (repeatable, docker only)
|
|
16
|
+
* --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
17
|
+
* --privileged Run docker container in privileged mode (docker only)
|
|
14
18
|
* --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
15
19
|
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
|
|
16
20
|
* --keep-user Keep isolated user after command completes (don't delete)
|
|
@@ -167,6 +171,10 @@ function parseArgs(args) {
|
|
|
167
171
|
sessionId: null, // Session ID (UUID) for tracking - auto-generated if not provided
|
|
168
172
|
image: null, // Docker image (current level)
|
|
169
173
|
imageStack: null, // Docker images for each level (with nulls for non-docker levels)
|
|
174
|
+
volumes: [], // Docker bind mounts/volumes (-v/--volume), applied to docker levels
|
|
175
|
+
mounts: [], // Docker --mount specs, applied to docker levels
|
|
176
|
+
env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
|
|
177
|
+
privileged: false, // Run docker container in privileged mode
|
|
170
178
|
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
171
179
|
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
172
180
|
user: false, // Create isolated user
|
|
@@ -206,9 +214,7 @@ function parseArgs(args) {
|
|
|
206
214
|
if (arg.startsWith('-')) {
|
|
207
215
|
const consumed = parseOption(args, i, wrapperOptions);
|
|
208
216
|
if (consumed === 0) {
|
|
209
|
-
|
|
210
|
-
commandArgs = args.slice(i);
|
|
211
|
-
break;
|
|
217
|
+
throw new Error(`Unknown wrapper option: ${arg}`);
|
|
212
218
|
}
|
|
213
219
|
i += consumed;
|
|
214
220
|
} else {
|
|
@@ -239,6 +245,9 @@ function parseWrapperArgs(args, options) {
|
|
|
239
245
|
while (i < args.length) {
|
|
240
246
|
const consumed = parseOption(args, i, options);
|
|
241
247
|
if (consumed === 0) {
|
|
248
|
+
if (args[i].startsWith('-')) {
|
|
249
|
+
throw new Error(`Unknown wrapper option: ${args[i]}`);
|
|
250
|
+
}
|
|
242
251
|
if (DEBUG) {
|
|
243
252
|
console.warn(`Unknown wrapper option: ${args[i]}`);
|
|
244
253
|
}
|
|
@@ -259,8 +268,8 @@ function parseWrapperArgs(args, options) {
|
|
|
259
268
|
function parseOption(args, index, options) {
|
|
260
269
|
const arg = args[index];
|
|
261
270
|
|
|
262
|
-
// --isolated or -i
|
|
263
|
-
if (arg === '--isolated' || arg === '-i') {
|
|
271
|
+
// --isolated, --isolation, or -i
|
|
272
|
+
if (arg === '--isolated' || arg === '--isolation' || arg === '-i') {
|
|
264
273
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
265
274
|
const value = args[index + 1];
|
|
266
275
|
parseIsolatedValue(value, options);
|
|
@@ -272,8 +281,8 @@ function parseOption(args, index, options) {
|
|
|
272
281
|
}
|
|
273
282
|
}
|
|
274
283
|
|
|
275
|
-
// --isolated=<value>
|
|
276
|
-
if (arg.startsWith('--isolated=')) {
|
|
284
|
+
// --isolated=<value> or --isolation=<value>
|
|
285
|
+
if (arg.startsWith('--isolated=') || arg.startsWith('--isolation=')) {
|
|
277
286
|
const value = arg.split('=')[1];
|
|
278
287
|
parseIsolatedValue(value, options);
|
|
279
288
|
return 1;
|
|
@@ -325,6 +334,62 @@ function parseOption(args, index, options) {
|
|
|
325
334
|
return 1;
|
|
326
335
|
}
|
|
327
336
|
|
|
337
|
+
// --volume or -v (for docker) - repeatable bind mount / volume
|
|
338
|
+
if (arg === '--volume' || arg === '-v') {
|
|
339
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
340
|
+
options.volumes.push(args[index + 1]);
|
|
341
|
+
return 2;
|
|
342
|
+
} else {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`Option ${arg} requires a volume argument (host:container[:mode])`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --volume=<value> or -v=<value>
|
|
350
|
+
if (arg.startsWith('--volume=') || arg.startsWith('-v=')) {
|
|
351
|
+
options.volumes.push(arg.slice(arg.indexOf('=') + 1));
|
|
352
|
+
return 1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --mount (for docker) - repeatable mount spec
|
|
356
|
+
if (arg === '--mount') {
|
|
357
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
358
|
+
options.mounts.push(args[index + 1]);
|
|
359
|
+
return 2;
|
|
360
|
+
} else {
|
|
361
|
+
throw new Error(`Option ${arg} requires a mount spec argument`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --mount=<value>
|
|
366
|
+
if (arg.startsWith('--mount=')) {
|
|
367
|
+
options.mounts.push(arg.slice('--mount='.length));
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --env or -e (for docker) - repeatable environment variable
|
|
372
|
+
if (arg === '--env' || arg === '-e') {
|
|
373
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
374
|
+
options.env.push(args[index + 1]);
|
|
375
|
+
return 2;
|
|
376
|
+
} else {
|
|
377
|
+
throw new Error(`Option ${arg} requires a KEY=VALUE argument`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --env=<value> or -e=<value>
|
|
382
|
+
if (arg.startsWith('--env=') || arg.startsWith('-e=')) {
|
|
383
|
+
options.env.push(arg.slice(arg.indexOf('=') + 1));
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --privileged (for docker)
|
|
388
|
+
if (arg === '--privileged') {
|
|
389
|
+
options.privileged = true;
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
328
393
|
// --endpoint (for ssh) - supports sequence for stacked isolation
|
|
329
394
|
if (arg === '--endpoint') {
|
|
330
395
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
@@ -555,6 +620,35 @@ function parseOption(args, index, options) {
|
|
|
555
620
|
return 0;
|
|
556
621
|
}
|
|
557
622
|
|
|
623
|
+
/**
|
|
624
|
+
* Throw if docker runtime options (--volume, --mount, --env, --privileged)
|
|
625
|
+
* are present but the isolation configuration does not include docker.
|
|
626
|
+
* @param {object} options - Parsed options
|
|
627
|
+
* @throws {Error} If a docker-only option is set without docker isolation
|
|
628
|
+
*/
|
|
629
|
+
function validateDockerRuntimeOptionsRequireDocker(options) {
|
|
630
|
+
if (options.volumes && options.volumes.length > 0) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
'--volume option is only valid when isolation stack includes docker'
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
if (options.mounts && options.mounts.length > 0) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
'--mount option is only valid when isolation stack includes docker'
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
if (options.env && options.env.length > 0) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
'--env option is only valid when isolation stack includes docker'
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
if (options.privileged) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
'--privileged option is only valid when isolation stack includes docker'
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
558
652
|
/**
|
|
559
653
|
* Validate parsed options
|
|
560
654
|
* @param {object} options - Parsed options
|
|
@@ -677,6 +771,11 @@ function validateOptions(options) {
|
|
|
677
771
|
);
|
|
678
772
|
}
|
|
679
773
|
|
|
774
|
+
// Docker runtime options (--volume, --mount, --env, --privileged) require docker
|
|
775
|
+
if (!stack.includes('docker')) {
|
|
776
|
+
validateDockerRuntimeOptionsRequireDocker(options);
|
|
777
|
+
}
|
|
778
|
+
|
|
680
779
|
// User isolation is not supported with Docker as first level
|
|
681
780
|
if (options.user && currentBackend === 'docker') {
|
|
682
781
|
throw new Error(
|
|
@@ -701,6 +800,7 @@ function validateOptions(options) {
|
|
|
701
800
|
'--endpoint option is only valid when isolation stack includes ssh'
|
|
702
801
|
);
|
|
703
802
|
}
|
|
803
|
+
validateDockerRuntimeOptionsRequireDocker(options);
|
|
704
804
|
}
|
|
705
805
|
|
|
706
806
|
// Session name is only valid with isolation
|
|
@@ -54,6 +54,23 @@ function buildNextLevelCommand(options, command) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Docker runtime options are flat (not per-level); forward them only when a
|
|
58
|
+
// remaining level still uses docker so the nested $ invocation can apply them.
|
|
59
|
+
if (remainingStack.includes('docker')) {
|
|
60
|
+
for (const volume of options.volumes || []) {
|
|
61
|
+
parts.push(`--volume "${volume}"`);
|
|
62
|
+
}
|
|
63
|
+
for (const mount of options.mounts || []) {
|
|
64
|
+
parts.push(`--mount "${mount}"`);
|
|
65
|
+
}
|
|
66
|
+
for (const envVar of options.env || []) {
|
|
67
|
+
parts.push(`--env "${envVar}"`);
|
|
68
|
+
}
|
|
69
|
+
if (options.privileged) {
|
|
70
|
+
parts.push('--privileged');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
// Pass through global flags
|
|
58
75
|
if (options.detached) {
|
|
59
76
|
parts.push('--detached');
|
package/src/lib/isolation.js
CHANGED
|
@@ -486,10 +486,75 @@ const {
|
|
|
486
486
|
canRunLinuxDockerImages,
|
|
487
487
|
} = require('./docker-utils');
|
|
488
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Build the docker run runtime argument list contributed by configurable
|
|
491
|
+
* container options: privileged mode, environment variables, volumes/bind
|
|
492
|
+
* mounts, and --mount specs. Returned in a stable order so they can be spliced
|
|
493
|
+
* into the `docker run` argv before the image name.
|
|
494
|
+
* @param {object} options - Options (privileged, env, volumes, mounts)
|
|
495
|
+
* @returns {string[]} Docker CLI arguments
|
|
496
|
+
*/
|
|
497
|
+
function buildDockerRuntimeArgs(options = {}) {
|
|
498
|
+
const args = [];
|
|
499
|
+
if (options.privileged) {
|
|
500
|
+
args.push('--privileged');
|
|
501
|
+
}
|
|
502
|
+
for (const envVar of options.env || []) {
|
|
503
|
+
args.push('-e', envVar);
|
|
504
|
+
}
|
|
505
|
+
for (const volume of options.volumes || []) {
|
|
506
|
+
args.push('-v', volume);
|
|
507
|
+
}
|
|
508
|
+
for (const mount of options.mounts || []) {
|
|
509
|
+
args.push('--mount', mount);
|
|
510
|
+
}
|
|
511
|
+
return args;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Build the human-readable `[Isolation]` status lines for docker runtime
|
|
516
|
+
* options (volumes, mounts, env, privileged). Empty collections and a falsy
|
|
517
|
+
* privileged flag contribute no lines.
|
|
518
|
+
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
519
|
+
* @returns {string[]} Status lines for the start block / log header
|
|
520
|
+
*/
|
|
521
|
+
function buildDockerRuntimeStatusLines(options = {}) {
|
|
522
|
+
const lines = [];
|
|
523
|
+
if (options.volumes && options.volumes.length > 0) {
|
|
524
|
+
lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
|
|
525
|
+
}
|
|
526
|
+
if (options.mounts && options.mounts.length > 0) {
|
|
527
|
+
lines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`);
|
|
528
|
+
}
|
|
529
|
+
if (options.env && options.env.length > 0) {
|
|
530
|
+
lines.push(`[Isolation] Env: ${options.env.join(', ')}`);
|
|
531
|
+
}
|
|
532
|
+
if (options.privileged) {
|
|
533
|
+
lines.push(`[Isolation] Privileged: true`);
|
|
534
|
+
}
|
|
535
|
+
return lines;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Build the execution-record metadata for docker runtime options, normalizing
|
|
540
|
+
* empty collections and a falsy privileged flag to `null`.
|
|
541
|
+
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
542
|
+
* @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}}
|
|
543
|
+
*/
|
|
544
|
+
function buildDockerRuntimeMetadata(options = {}) {
|
|
545
|
+
return {
|
|
546
|
+
volumes:
|
|
547
|
+
options.volumes && options.volumes.length > 0 ? options.volumes : null,
|
|
548
|
+
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
549
|
+
env: options.env && options.env.length > 0 ? options.env : null,
|
|
550
|
+
privileged: options.privileged || null,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
489
554
|
/**
|
|
490
555
|
* Run command in Docker container
|
|
491
556
|
* @param {string} command - Command to execute
|
|
492
|
-
* @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer)
|
|
557
|
+
* @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer, volumes, mounts, env, privileged)
|
|
493
558
|
* @returns {Promise<{success: boolean, containerName: string, message: string}>}
|
|
494
559
|
*/
|
|
495
560
|
function runInDocker(command, options = {}) {
|
|
@@ -558,6 +623,8 @@ function runInDocker(command, options = {}) {
|
|
|
558
623
|
dockerArgs.push('--user', options.user);
|
|
559
624
|
}
|
|
560
625
|
|
|
626
|
+
dockerArgs.push(...buildDockerRuntimeArgs(options));
|
|
627
|
+
|
|
561
628
|
const effectiveCommand = options.keepAlive
|
|
562
629
|
? `${command}; exec ${shellToUse}`
|
|
563
630
|
: command;
|
|
@@ -640,6 +707,7 @@ function runInDocker(command, options = {}) {
|
|
|
640
707
|
if (options.user) {
|
|
641
708
|
dockerArgs.push('--user', options.user);
|
|
642
709
|
}
|
|
710
|
+
dockerArgs.push(...buildDockerRuntimeArgs(options));
|
|
643
711
|
if (DEBUG) {
|
|
644
712
|
console.log(`[DEBUG] shell: ${shellToUse}`);
|
|
645
713
|
}
|
|
@@ -784,6 +852,9 @@ module.exports = {
|
|
|
784
852
|
runInScreen,
|
|
785
853
|
runInTmux,
|
|
786
854
|
runInDocker,
|
|
855
|
+
buildDockerRuntimeArgs,
|
|
856
|
+
buildDockerRuntimeStatusLines,
|
|
857
|
+
buildDockerRuntimeMetadata,
|
|
787
858
|
runInSsh,
|
|
788
859
|
runIsolated,
|
|
789
860
|
runAsIsolatedUser,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - Text: Human-readable text format
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
const { execSync } = require('child_process');
|
|
10
|
+
const { execSync, spawnSync } = require('child_process');
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const {
|
|
13
13
|
escapeForLinksNotation,
|
|
@@ -15,6 +15,58 @@ const {
|
|
|
15
15
|
} = require('./output-blocks');
|
|
16
16
|
const { collectProcessIds } = require('./execution-control');
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Inspect the live state of a detached docker container by name.
|
|
20
|
+
*
|
|
21
|
+
* Distinguishes three outcomes that matter for status reporting:
|
|
22
|
+
* - the container exists and is running,
|
|
23
|
+
* - the container exists but has stopped (with a real exit code), and
|
|
24
|
+
* - the container cannot be inspected at all.
|
|
25
|
+
*
|
|
26
|
+
* The last case is crucial on slow Docker-in-Docker hosts (issue #136): right
|
|
27
|
+
* after `docker run -d` returns, `docker inspect <name>` can transiently fail
|
|
28
|
+
* because the container is not visible yet. A failed inspect must NOT be read
|
|
29
|
+
* as "stopped"; it means "unknown", so callers can keep the session running
|
|
30
|
+
* instead of fabricating a terminal `-1` result.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} sessionName - Container name
|
|
33
|
+
* @returns {{running: boolean, exitCode: number|null}|null} State, or null when
|
|
34
|
+
* the container cannot be inspected (not found yet, removed, or docker error)
|
|
35
|
+
*/
|
|
36
|
+
function inspectDockerState(sessionName) {
|
|
37
|
+
const result = spawnSync(
|
|
38
|
+
'docker',
|
|
39
|
+
['inspect', '-f', '{{.State.Running}} {{.State.ExitCode}}', sessionName],
|
|
40
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
41
|
+
);
|
|
42
|
+
if (result.error || result.status !== 0 || !result.stdout) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const [runningRaw, exitRaw] = result.stdout.trim().split(/\s+/);
|
|
46
|
+
const exitCode = Number.parseInt(exitRaw, 10);
|
|
47
|
+
return {
|
|
48
|
+
running: runningRaw === 'true',
|
|
49
|
+
exitCode: Number.isFinite(exitCode) ? exitCode : null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Best-effort terminal exit code reported by the isolation backend itself
|
|
55
|
+
* (currently docker via `docker inspect .State.ExitCode`). Returns null when
|
|
56
|
+
* the backend cannot provide a real code, so callers never surface the `-1`
|
|
57
|
+
* sentinel for a session whose real exit code is simply not available yet.
|
|
58
|
+
* @param {Object} record - Execution record
|
|
59
|
+
* @returns {number|null}
|
|
60
|
+
*/
|
|
61
|
+
function readBackendExitCode(record) {
|
|
62
|
+
const opts = record.options || {};
|
|
63
|
+
if (opts.isolated !== 'docker' || !opts.sessionName) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const state = inspectDockerState(opts.sessionName);
|
|
67
|
+
return state && !state.running ? state.exitCode : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
18
70
|
/**
|
|
19
71
|
* Check if a detached isolation session is still running
|
|
20
72
|
* @param {Object} record - Execution record
|
|
@@ -46,11 +98,11 @@ function isDetachedSessionAlive(record) {
|
|
|
46
98
|
return true;
|
|
47
99
|
}
|
|
48
100
|
case 'docker': {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
);
|
|
53
|
-
return
|
|
101
|
+
// A failed inspect means the container is not visible yet (still being
|
|
102
|
+
// created on a slow DinD host) or already removed — not "stopped".
|
|
103
|
+
// Return null (unknown) so the session is not falsely marked finished.
|
|
104
|
+
const state = inspectDockerState(sessionName);
|
|
105
|
+
return state === null ? null : state.running;
|
|
54
106
|
}
|
|
55
107
|
case 'ssh': {
|
|
56
108
|
// For SSH, check if the PID is still running on remote would require
|
|
@@ -100,24 +152,62 @@ function readExitCodeFromLog(logPath) {
|
|
|
100
152
|
*/
|
|
101
153
|
function enrichDetachedStatus(record) {
|
|
102
154
|
const alive = isDetachedSessionAlive(record);
|
|
155
|
+
const footerExit = readExitCodeFromLog(record.logPath);
|
|
156
|
+
|
|
157
|
+
// Create a shallow copy to avoid mutating the original
|
|
158
|
+
const cloneRecord = () => {
|
|
159
|
+
const enriched = Object.create(Object.getPrototypeOf(record));
|
|
160
|
+
Object.assign(enriched, record);
|
|
161
|
+
return enriched;
|
|
162
|
+
};
|
|
163
|
+
|
|
103
164
|
if (alive === null) {
|
|
165
|
+
// Liveness is unknown: the backend could not be probed (e.g. a detached
|
|
166
|
+
// docker container that is not visible yet on a slow Docker-in-Docker host,
|
|
167
|
+
// or one that has already been removed). Honor a terminal `Exit Code:`
|
|
168
|
+
// footer if the command wrote one; otherwise leave the record untouched
|
|
169
|
+
// (still executing) rather than fabricating a `-1` terminal result that
|
|
170
|
+
// orchestrators misread as a finished/failed run (issue #136).
|
|
171
|
+
const isDetached =
|
|
172
|
+
record.options && record.options.isolationMode === 'detached';
|
|
173
|
+
if (isDetached && record.status === 'executing' && footerExit !== null) {
|
|
174
|
+
const enriched = cloneRecord();
|
|
175
|
+
enriched.status = 'executed';
|
|
176
|
+
enriched.exitCode = footerExit;
|
|
177
|
+
if (!enriched.endTime) {
|
|
178
|
+
enriched.endTime = new Date().toISOString();
|
|
179
|
+
}
|
|
180
|
+
return enriched;
|
|
181
|
+
}
|
|
104
182
|
return record;
|
|
105
183
|
}
|
|
106
184
|
|
|
107
|
-
|
|
108
|
-
const enriched = Object.create(Object.getPrototypeOf(record));
|
|
109
|
-
Object.assign(enriched, record);
|
|
185
|
+
const enriched = cloneRecord();
|
|
110
186
|
|
|
111
187
|
if (alive && enriched.status === 'executed') {
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
188
|
+
// A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
|
|
189
|
+
// is still running: a lingering shell can outlive a killed command (e.g. the
|
|
190
|
+
// OOM killer sends SIGKILL, exit 137, but the login shell stays up for a
|
|
191
|
+
// window after `start` already wrote the terminal footer). The footer/recorded
|
|
192
|
+
// exit code is authoritative. Only flip back to 'executing' when there is NO
|
|
193
|
+
// recorded terminal exit code AND no `Exit Code:` footer in the log.
|
|
194
|
+
const hasRecordedExit =
|
|
195
|
+
enriched.exitCode !== null && enriched.exitCode !== undefined;
|
|
196
|
+
if (!hasRecordedExit && footerExit === null) {
|
|
197
|
+
// Session still running and no terminal record - correct it
|
|
198
|
+
enriched.status = 'executing';
|
|
199
|
+
enriched.exitCode = null;
|
|
200
|
+
enriched.endTime = null;
|
|
201
|
+
}
|
|
202
|
+
// Otherwise keep the recorded/footer exit code - the command has finished.
|
|
116
203
|
} else if (!alive && enriched.status === 'executing') {
|
|
117
|
-
// Session ended but record says executing - correct it
|
|
204
|
+
// Session ended but record says executing - correct it. Resolve a real exit
|
|
205
|
+
// code: prefer the log footer, then the backend's own record (e.g.
|
|
206
|
+
// `docker inspect .State.ExitCode`), and only fall back to the `-1` sentinel
|
|
207
|
+
// as a last resort when no real code can be obtained (issue #136).
|
|
118
208
|
enriched.status = 'executed';
|
|
119
209
|
if (enriched.exitCode === null || enriched.exitCode === undefined) {
|
|
120
|
-
enriched.exitCode =
|
|
210
|
+
enriched.exitCode = footerExit ?? readBackendExitCode(enriched) ?? -1;
|
|
121
211
|
}
|
|
122
212
|
if (!enriched.endTime) {
|
|
123
213
|
enriched.endTime = new Date().toISOString();
|
package/src/lib/usage.js
CHANGED
|
@@ -3,13 +3,17 @@ function printUsage() {
|
|
|
3
3
|
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id>
|
|
4
4
|
|
|
5
5
|
Options:
|
|
6
|
-
--isolated, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
|
|
6
|
+
--isolated, --isolation, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
|
|
7
7
|
--attached, -a Run in attached mode (foreground)
|
|
8
8
|
--detached, -d Run in detached mode (background)
|
|
9
9
|
--session, -s <name> Session name for isolation
|
|
10
10
|
--session-id <uuid> Session UUID for tracking (auto-generated if not provided)
|
|
11
11
|
--session-name <uuid> Alias for --session-id
|
|
12
12
|
--image <image> Docker image (optional, defaults to OS-matched image)
|
|
13
|
+
--volume, -v <spec> Docker bind mount/volume host:container[:mode] (repeatable, docker only)
|
|
14
|
+
--mount <spec> Docker --mount spec (repeatable, docker only)
|
|
15
|
+
--env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
16
|
+
--privileged Run docker container in privileged mode (docker only)
|
|
13
17
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
14
18
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
15
19
|
--keep-user Keep isolated user after command completes
|
|
@@ -33,6 +37,8 @@ Examples:
|
|
|
33
37
|
$ -i screen -d bun start
|
|
34
38
|
$ --isolated docker -- echo "hi" # uses OS-matched default image
|
|
35
39
|
$ --isolated docker --image ghcr.io/link-foundation/box-js:latest -- bun --version
|
|
40
|
+
$ -i docker -v ~/.config/gh:/root/.config/gh -e TOKEN=abc -- gh repo list
|
|
41
|
+
$ -i docker --image konard/hive-mind-dind:latest --privileged -- solve ...
|
|
36
42
|
$ --isolated ssh --endpoint user@remote.server -- ls -la
|
|
37
43
|
$ --isolated-user -- npm test # Create isolated user
|
|
38
44
|
$ -u myuser -- npm start # Custom username
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for --isolation alias handling and unknown wrapper options.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { describe, it } = require('node:test');
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
9
|
+
|
|
10
|
+
describe('parseArgs --isolation alias', () => {
|
|
11
|
+
it('should parse --isolation alias with value', () => {
|
|
12
|
+
const result = parseArgs(['--isolation', 'docker', '--', 'echo', 'hi']);
|
|
13
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'docker');
|
|
14
|
+
assert.strictEqual(result.command, 'echo hi');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should parse --isolation=value alias format', () => {
|
|
18
|
+
const result = parseArgs(['--isolation=screen', '--', 'ls', '-la']);
|
|
19
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should parse --isolation alias before command without separator', () => {
|
|
23
|
+
const result = parseArgs(['--isolation', 'docker', 'echo', 'hi']);
|
|
24
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'docker');
|
|
25
|
+
assert.strictEqual(result.command, 'echo hi');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('parseArgs unknown wrapper options', () => {
|
|
30
|
+
it('should throw for unknown wrapper option before separator', () => {
|
|
31
|
+
assert.throws(() => {
|
|
32
|
+
parseArgs(['--unknown-wrapper', 'value', '--', 'echo', 'hi']);
|
|
33
|
+
}, /Unknown wrapper option: --unknown-wrapper/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should throw for unknown wrapper option without separator', () => {
|
|
37
|
+
assert.throws(() => {
|
|
38
|
+
parseArgs(['--unknown-wrapper', 'value', 'echo', 'hi']);
|
|
39
|
+
}, /Unknown wrapper option: --unknown-wrapper/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Docker runtime options: --volume/-v, --mount, --env/-e, --privileged
|
|
4
|
+
*
|
|
5
|
+
* Reproduces issue #132: callers need to configure bind mounts, volumes,
|
|
6
|
+
* environment variables, and privileged mode for the docker isolation backend
|
|
7
|
+
* so they can mount tool credentials and run Docker-in-Docker images without
|
|
8
|
+
* wrapping `docker run` themselves.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { describe, it } = require('node:test');
|
|
12
|
+
const assert = require('assert');
|
|
13
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
14
|
+
const {
|
|
15
|
+
buildDockerRuntimeArgs,
|
|
16
|
+
buildDockerRuntimeStatusLines,
|
|
17
|
+
buildDockerRuntimeMetadata,
|
|
18
|
+
} = require('../src/lib/isolation');
|
|
19
|
+
|
|
20
|
+
describe('Docker runtime options parsing', () => {
|
|
21
|
+
it('should parse repeatable --volume and -v', () => {
|
|
22
|
+
const result = parseArgs([
|
|
23
|
+
'--isolated',
|
|
24
|
+
'docker',
|
|
25
|
+
'--volume',
|
|
26
|
+
'/host/a:/container/a',
|
|
27
|
+
'-v',
|
|
28
|
+
'/host/b:/container/b:ro',
|
|
29
|
+
'--',
|
|
30
|
+
'ls',
|
|
31
|
+
]);
|
|
32
|
+
assert.deepStrictEqual(result.wrapperOptions.volumes, [
|
|
33
|
+
'/host/a:/container/a',
|
|
34
|
+
'/host/b:/container/b:ro',
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should parse --volume=value and -v=value', () => {
|
|
39
|
+
const result = parseArgs([
|
|
40
|
+
'-i',
|
|
41
|
+
'docker',
|
|
42
|
+
'--volume=/host/a:/c/a',
|
|
43
|
+
'-v=/host/b:/c/b',
|
|
44
|
+
'--',
|
|
45
|
+
'ls',
|
|
46
|
+
]);
|
|
47
|
+
assert.deepStrictEqual(result.wrapperOptions.volumes, [
|
|
48
|
+
'/host/a:/c/a',
|
|
49
|
+
'/host/b:/c/b',
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should parse repeatable --mount', () => {
|
|
54
|
+
const result = parseArgs([
|
|
55
|
+
'-i',
|
|
56
|
+
'docker',
|
|
57
|
+
'--mount',
|
|
58
|
+
'type=bind,src=/h,dst=/c',
|
|
59
|
+
'--mount=type=volume,src=vol,dst=/data',
|
|
60
|
+
'--',
|
|
61
|
+
'ls',
|
|
62
|
+
]);
|
|
63
|
+
assert.deepStrictEqual(result.wrapperOptions.mounts, [
|
|
64
|
+
'type=bind,src=/h,dst=/c',
|
|
65
|
+
'type=volume,src=vol,dst=/data',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should parse repeatable --env and -e', () => {
|
|
70
|
+
const result = parseArgs([
|
|
71
|
+
'-i',
|
|
72
|
+
'docker',
|
|
73
|
+
'--env',
|
|
74
|
+
'FOO=bar',
|
|
75
|
+
'-e',
|
|
76
|
+
'GH_TOKEN=secret',
|
|
77
|
+
'--env=BAZ=qux',
|
|
78
|
+
'--',
|
|
79
|
+
'env',
|
|
80
|
+
]);
|
|
81
|
+
assert.deepStrictEqual(result.wrapperOptions.env, [
|
|
82
|
+
'FOO=bar',
|
|
83
|
+
'GH_TOKEN=secret',
|
|
84
|
+
'BAZ=qux',
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should parse --privileged', () => {
|
|
89
|
+
const result = parseArgs(['-i', 'docker', '--privileged', '--', 'ls']);
|
|
90
|
+
assert.strictEqual(result.wrapperOptions.privileged, true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should default runtime options to empty/false', () => {
|
|
94
|
+
const result = parseArgs(['-i', 'docker', '--', 'ls']);
|
|
95
|
+
assert.deepStrictEqual(result.wrapperOptions.volumes, []);
|
|
96
|
+
assert.deepStrictEqual(result.wrapperOptions.mounts, []);
|
|
97
|
+
assert.deepStrictEqual(result.wrapperOptions.env, []);
|
|
98
|
+
assert.strictEqual(result.wrapperOptions.privileged, false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should throw when --volume requires an argument', () => {
|
|
102
|
+
assert.throws(() => {
|
|
103
|
+
parseArgs(['-i', 'docker', '--volume', '--', 'ls']);
|
|
104
|
+
}, /requires a volume argument/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should throw when --env requires an argument', () => {
|
|
108
|
+
assert.throws(() => {
|
|
109
|
+
parseArgs(['-i', 'docker', '--env', '--', 'ls']);
|
|
110
|
+
}, /requires a KEY=VALUE argument/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Docker runtime options validation', () => {
|
|
115
|
+
it('should reject --volume with non-docker backend', () => {
|
|
116
|
+
assert.throws(() => {
|
|
117
|
+
parseArgs(['-i', 'tmux', '-v', '/a:/b', '--', 'ls']);
|
|
118
|
+
}, /--volume option is only valid when isolation stack includes docker/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should reject --mount without isolation', () => {
|
|
122
|
+
assert.throws(() => {
|
|
123
|
+
parseArgs(['--mount', 'type=bind,src=/a,dst=/b', '--', 'ls']);
|
|
124
|
+
}, /--mount option is only valid when isolation stack includes docker/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should reject --env with non-docker backend', () => {
|
|
128
|
+
assert.throws(() => {
|
|
129
|
+
parseArgs(['-i', 'ssh', '--endpoint', 'u@h', '-e', 'A=1', '--', 'ls']);
|
|
130
|
+
}, /--env option is only valid when isolation stack includes docker/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should reject --privileged without docker', () => {
|
|
134
|
+
assert.throws(() => {
|
|
135
|
+
parseArgs(['--privileged', '--', 'ls']);
|
|
136
|
+
}, /--privileged option is only valid when isolation stack includes docker/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should accept runtime options when stack includes docker', () => {
|
|
140
|
+
const result = parseArgs([
|
|
141
|
+
'-i',
|
|
142
|
+
'screen docker',
|
|
143
|
+
'-v',
|
|
144
|
+
'/a:/b',
|
|
145
|
+
'-e',
|
|
146
|
+
'A=1',
|
|
147
|
+
'--privileged',
|
|
148
|
+
'--',
|
|
149
|
+
'ls',
|
|
150
|
+
]);
|
|
151
|
+
assert.deepStrictEqual(result.wrapperOptions.volumes, ['/a:/b']);
|
|
152
|
+
assert.deepStrictEqual(result.wrapperOptions.env, ['A=1']);
|
|
153
|
+
assert.strictEqual(result.wrapperOptions.privileged, true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('buildDockerRuntimeArgs', () => {
|
|
158
|
+
it('should build empty args by default', () => {
|
|
159
|
+
assert.deepStrictEqual(buildDockerRuntimeArgs({}), []);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should add --privileged first', () => {
|
|
163
|
+
assert.deepStrictEqual(buildDockerRuntimeArgs({ privileged: true }), [
|
|
164
|
+
'--privileged',
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should expand env, volumes, and mounts in order', () => {
|
|
169
|
+
const args = buildDockerRuntimeArgs({
|
|
170
|
+
privileged: true,
|
|
171
|
+
env: ['FOO=bar', 'GH_TOKEN=secret'],
|
|
172
|
+
volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
|
|
173
|
+
mounts: ['type=bind,src=/h,dst=/c'],
|
|
174
|
+
});
|
|
175
|
+
assert.deepStrictEqual(args, [
|
|
176
|
+
'--privileged',
|
|
177
|
+
'-e',
|
|
178
|
+
'FOO=bar',
|
|
179
|
+
'-e',
|
|
180
|
+
'GH_TOKEN=secret',
|
|
181
|
+
'-v',
|
|
182
|
+
'/h/a:/c/a',
|
|
183
|
+
'-v',
|
|
184
|
+
'/h/b:/c/b:ro',
|
|
185
|
+
'--mount',
|
|
186
|
+
'type=bind,src=/h,dst=/c',
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('buildDockerRuntimeStatusLines', () => {
|
|
192
|
+
it('should return no lines for empty options', () => {
|
|
193
|
+
assert.deepStrictEqual(buildDockerRuntimeStatusLines({}), []);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should format volumes, mounts, env, and privileged lines', () => {
|
|
197
|
+
const lines = buildDockerRuntimeStatusLines({
|
|
198
|
+
volumes: ['/h:/c:ro'],
|
|
199
|
+
mounts: ['type=bind,src=/h,dst=/c'],
|
|
200
|
+
env: ['FOO=bar'],
|
|
201
|
+
privileged: true,
|
|
202
|
+
});
|
|
203
|
+
assert.deepStrictEqual(lines, [
|
|
204
|
+
'[Isolation] Volumes: /h:/c:ro',
|
|
205
|
+
'[Isolation] Mounts: type=bind,src=/h,dst=/c',
|
|
206
|
+
'[Isolation] Env: FOO=bar',
|
|
207
|
+
'[Isolation] Privileged: true',
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should join multiple volumes with a comma', () => {
|
|
212
|
+
assert.deepStrictEqual(
|
|
213
|
+
buildDockerRuntimeStatusLines({ volumes: ['/a:/a', '/b:/b'] }),
|
|
214
|
+
['[Isolation] Volumes: /a:/a, /b:/b']
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('buildDockerRuntimeMetadata', () => {
|
|
220
|
+
it('should null out empty collections and falsy privileged', () => {
|
|
221
|
+
assert.deepStrictEqual(buildDockerRuntimeMetadata({}), {
|
|
222
|
+
volumes: null,
|
|
223
|
+
mounts: null,
|
|
224
|
+
env: null,
|
|
225
|
+
privileged: null,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should pass through populated collections and privileged', () => {
|
|
230
|
+
assert.deepStrictEqual(
|
|
231
|
+
buildDockerRuntimeMetadata({
|
|
232
|
+
volumes: ['/h:/c'],
|
|
233
|
+
mounts: ['type=bind,src=/h,dst=/c'],
|
|
234
|
+
env: ['FOO=bar'],
|
|
235
|
+
privileged: true,
|
|
236
|
+
}),
|
|
237
|
+
{
|
|
238
|
+
volumes: ['/h:/c'],
|
|
239
|
+
mounts: ['type=bind,src=/h,dst=/c'],
|
|
240
|
+
env: ['FOO=bar'],
|
|
241
|
+
privileged: true,
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -306,6 +306,249 @@ describe('Issue #101: Detached status enrichment', () => {
|
|
|
306
306
|
expect(enriched.endTime).not.toBeNull();
|
|
307
307
|
}
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
// Issue #134: a lingering screen session must NOT resurrect a completed
|
|
311
|
+
// (killed, exit 137) record back to 'executing' / null exit code.
|
|
312
|
+
describe('Issue #134: completed record with a lingering live session', () => {
|
|
313
|
+
const screenAvailable = (() => {
|
|
314
|
+
const probe = spawnSync('screen', ['-v'], { encoding: 'utf8' });
|
|
315
|
+
return probe.status === 0 || /Screen version/.test(probe.stdout || '');
|
|
316
|
+
})();
|
|
317
|
+
|
|
318
|
+
let sessionName;
|
|
319
|
+
let logPath;
|
|
320
|
+
|
|
321
|
+
beforeEach(() => {
|
|
322
|
+
if (!screenAvailable) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
sessionName = `enrich-134-${process.pid}-${Date.now()}`;
|
|
326
|
+
logPath = path.join(TEST_APP_FOLDER, `${sessionName}.log`);
|
|
327
|
+
if (!fs.existsSync(TEST_APP_FOLDER)) {
|
|
328
|
+
fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
|
|
329
|
+
}
|
|
330
|
+
// Footer exactly as `start` writes it for a SIGKILLed command.
|
|
331
|
+
fs.writeFileSync(
|
|
332
|
+
logPath,
|
|
333
|
+
`Killed\n\n${'='.repeat(50)}\nFinished: 2026-06-14 19:10:49.822\nExit Code: 137\n`
|
|
334
|
+
);
|
|
335
|
+
// A shell that outlives the (already-finished) command.
|
|
336
|
+
spawnSync('screen', ['-dmS', sessionName, 'sh', '-c', 'sleep 30'], {
|
|
337
|
+
encoding: 'utf8',
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
afterEach(() => {
|
|
342
|
+
if (!screenAvailable) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
spawnSync('screen', ['-S', sessionName, '-X', 'quit'], {
|
|
346
|
+
stdio: 'ignore',
|
|
347
|
+
});
|
|
348
|
+
if (logPath && fs.existsSync(logPath)) {
|
|
349
|
+
fs.unlinkSync(logPath);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('keeps the recorded exit code when the session is still listed', () => {
|
|
354
|
+
if (!screenAvailable) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const record = new ExecutionRecord({
|
|
358
|
+
command: 'sleep 60',
|
|
359
|
+
logPath,
|
|
360
|
+
options: {
|
|
361
|
+
sessionName,
|
|
362
|
+
isolated: 'screen',
|
|
363
|
+
isolationMode: 'detached',
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
record.complete(137);
|
|
367
|
+
|
|
368
|
+
// Sanity: the session must actually be alive for this test to be meaningful.
|
|
369
|
+
expect(isDetachedSessionAlive(record)).toBe(true);
|
|
370
|
+
|
|
371
|
+
const enriched = enrichDetachedStatus(record);
|
|
372
|
+
expect(enriched.status).toBe('executed');
|
|
373
|
+
expect(enriched.exitCode).toBe(137);
|
|
374
|
+
expect(enriched.endTime).not.toBeNull();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('honors the log footer exit code even without a recorded exit code', () => {
|
|
378
|
+
if (!screenAvailable) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const record = new ExecutionRecord({
|
|
382
|
+
command: 'sleep 60',
|
|
383
|
+
logPath,
|
|
384
|
+
options: {
|
|
385
|
+
sessionName,
|
|
386
|
+
isolated: 'screen',
|
|
387
|
+
isolationMode: 'detached',
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
// Record was never reconciled: status 'executed' but exitCode still null.
|
|
391
|
+
record.status = 'executed';
|
|
392
|
+
record.exitCode = null;
|
|
393
|
+
record.endTime = null;
|
|
394
|
+
|
|
395
|
+
expect(isDetachedSessionAlive(record)).toBe(true);
|
|
396
|
+
|
|
397
|
+
const enriched = enrichDetachedStatus(record);
|
|
398
|
+
// Footer says 137, so it must stay finished, not flip to executing.
|
|
399
|
+
expect(enriched.status).toBe('executed');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('flips to executing only when there is no terminal record at all', () => {
|
|
403
|
+
if (!screenAvailable) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Log with NO Exit Code footer and no recorded exit code.
|
|
407
|
+
fs.writeFileSync(logPath, 'still running, no footer yet\n');
|
|
408
|
+
const record = new ExecutionRecord({
|
|
409
|
+
command: 'sleep 60',
|
|
410
|
+
logPath,
|
|
411
|
+
options: {
|
|
412
|
+
sessionName,
|
|
413
|
+
isolated: 'screen',
|
|
414
|
+
isolationMode: 'detached',
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
record.status = 'executed';
|
|
418
|
+
record.exitCode = null;
|
|
419
|
+
record.endTime = null;
|
|
420
|
+
|
|
421
|
+
expect(isDetachedSessionAlive(record)).toBe(true);
|
|
422
|
+
|
|
423
|
+
const enriched = enrichDetachedStatus(record);
|
|
424
|
+
expect(enriched.status).toBe('executing');
|
|
425
|
+
expect(enriched.exitCode).toBeNull();
|
|
426
|
+
expect(enriched.endTime).toBeNull();
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Issue #136: a detached docker session must not be reported with a terminal
|
|
433
|
+
// status (`executed`) and the `-1` sentinel while its container is still
|
|
434
|
+
// running (or not visible yet on a slow Docker-in-Docker host).
|
|
435
|
+
describe('Issue #136: detached docker session liveness', () => {
|
|
436
|
+
// Use the repo's own probe: `docker` may be installed yet unable to run Linux
|
|
437
|
+
// images (e.g. Windows runners in Windows-containers mode, where `alpine`
|
|
438
|
+
// never starts). In that case `docker inspect` fails and liveness is `null`
|
|
439
|
+
// (unknown) rather than `false`, which would break the stopped-container
|
|
440
|
+
// assertions below.
|
|
441
|
+
const { canRunLinuxDockerImages } = require('../src/lib/isolation');
|
|
442
|
+
const dockerAvailable = canRunLinuxDockerImages();
|
|
443
|
+
|
|
444
|
+
// Whether the container actually exists (was created) per `docker inspect`.
|
|
445
|
+
function dockerContainerExists(name) {
|
|
446
|
+
const probe = spawnSync('docker', ['inspect', name], { stdio: 'ignore' });
|
|
447
|
+
return probe.status === 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function makeDockerRecord(sessionName, extra = {}) {
|
|
451
|
+
return new ExecutionRecord({
|
|
452
|
+
command: 'sleep 120; echo done',
|
|
453
|
+
options: {
|
|
454
|
+
sessionName,
|
|
455
|
+
isolated: 'docker',
|
|
456
|
+
isolationMode: 'detached',
|
|
457
|
+
},
|
|
458
|
+
...extra,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function dockerRm(name) {
|
|
463
|
+
spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
it('reports unknown (null) — not false — when the container is not visible yet', () => {
|
|
467
|
+
if (!dockerAvailable) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const record = makeDockerRecord('issue136-container-does-not-exist-yet');
|
|
471
|
+
expect(isDetachedSessionAlive(record)).toBeNull();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('keeps the record executing while the container is not visible yet', () => {
|
|
475
|
+
if (!dockerAvailable) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const record = makeDockerRecord('issue136-container-not-visible');
|
|
479
|
+
// Defaults to executing with a null exit code.
|
|
480
|
+
const enriched = enrichDetachedStatus(record);
|
|
481
|
+
expect(enriched.status).toBe('executing');
|
|
482
|
+
expect(enriched.exitCode).toBeNull();
|
|
483
|
+
expect(enriched.endTime).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('keeps a running container executing', () => {
|
|
487
|
+
if (!dockerAvailable) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const name = `issue136-running-${process.pid}`;
|
|
491
|
+
dockerRm(name);
|
|
492
|
+
const started = spawnSync('docker', [
|
|
493
|
+
'run',
|
|
494
|
+
'-d',
|
|
495
|
+
'--name',
|
|
496
|
+
name,
|
|
497
|
+
'alpine',
|
|
498
|
+
'sh',
|
|
499
|
+
'-c',
|
|
500
|
+
'sleep 30',
|
|
501
|
+
]);
|
|
502
|
+
if (started.status !== 0) {
|
|
503
|
+
dockerRm(name);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
const record = makeDockerRecord(name);
|
|
508
|
+
expect(isDetachedSessionAlive(record)).toBe(true);
|
|
509
|
+
const enriched = enrichDetachedStatus(record);
|
|
510
|
+
expect(enriched.status).toBe('executing');
|
|
511
|
+
expect(enriched.exitCode).toBeNull();
|
|
512
|
+
} finally {
|
|
513
|
+
dockerRm(name);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('resolves a stopped container to its real exit code, never the -1 sentinel', () => {
|
|
518
|
+
if (!dockerAvailable) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const name = `issue136-stopped-${process.pid}`;
|
|
522
|
+
dockerRm(name);
|
|
523
|
+
const ran = spawnSync('docker', [
|
|
524
|
+
'run',
|
|
525
|
+
'--name',
|
|
526
|
+
name,
|
|
527
|
+
'alpine',
|
|
528
|
+
'sh',
|
|
529
|
+
'-c',
|
|
530
|
+
'exit 1',
|
|
531
|
+
]);
|
|
532
|
+
// `docker run` exits with the container's code (1 here); treat spawn errors
|
|
533
|
+
// (no daemon) or a container that never materialized (e.g. the Linux image
|
|
534
|
+
// could not be pulled) as a skip — there is nothing stopped to inspect.
|
|
535
|
+
if (ran.error || !dockerContainerExists(name)) {
|
|
536
|
+
dockerRm(name);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
// No log footer: force exit-code resolution through `docker inspect`.
|
|
541
|
+
const record = makeDockerRecord(name, {
|
|
542
|
+
logPath: '/nonexistent-issue136.log',
|
|
543
|
+
});
|
|
544
|
+
expect(isDetachedSessionAlive(record)).toBe(false);
|
|
545
|
+
const enriched = enrichDetachedStatus(record);
|
|
546
|
+
expect(enriched.status).toBe('executed');
|
|
547
|
+
expect(enriched.exitCode).toBe(1);
|
|
548
|
+
expect(enriched.endTime).not.toBeNull();
|
|
549
|
+
} finally {
|
|
550
|
+
dockerRm(name);
|
|
551
|
+
}
|
|
309
552
|
});
|
|
310
553
|
});
|
|
311
554
|
|