start-command 0.30.4 → 0.31.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 +12 -0
- package/package.json +1 -1
- package/src/bin/cli.js +1 -4
- package/src/lib/args-parser.js +12 -2
- package/src/lib/docker-cleanup.js +17 -0
- package/src/lib/docker-network-options.js +44 -0
- package/src/lib/isolation.js +41 -4
- package/src/lib/status-formatter.js +85 -13
- package/src/lib/usage.js +2 -0
- package/test/docker-network-integration.js +120 -0
- package/test/docker-runtime-options.js +75 -1
- package/test/regression-150.js +178 -0
- package/test/session-name-status.js +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.31.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b14f11c: Add `--network` and repeatable `--network-alias` options for Docker-isolated commands.
|
|
8
|
+
|
|
9
|
+
## 0.30.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- d0656c2: Stop `--status` from fabricating a detached session exit code out of the command's own output: the terminal exit code is now read from the anchored three-line footer `start` writes (separator / `Finished:` / `Exit Code:`) in the tail of the log only, and Docker's own `.State.ExitCode` takes precedence over the log text.
|
|
14
|
+
|
|
3
15
|
## 0.30.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -581,10 +581,7 @@ async function runWithIsolation(
|
|
|
581
581
|
alwaysCleanupContainer: options.alwaysCleanupContainer,
|
|
582
582
|
keepContainer: options.keepContainer,
|
|
583
583
|
keepContainerOnFail: options.keepContainerOnFail,
|
|
584
|
-
|
|
585
|
-
mounts: options.mounts,
|
|
586
|
-
env: options.env,
|
|
587
|
-
privileged: options.privileged,
|
|
584
|
+
...buildDockerRuntimeMetadata(options),
|
|
588
585
|
shell: options.shell,
|
|
589
586
|
logPath: logFilePath,
|
|
590
587
|
});
|
package/src/lib/args-parser.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* --mount <mount-spec> Docker --mount spec (repeatable, docker only)
|
|
16
16
|
* --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
17
17
|
* --privileged Run docker container in privileged mode (docker only)
|
|
18
|
+
* --network <name> Connect docker container to a named network (docker only)
|
|
19
|
+
* --network-alias <alias> Add a network-scoped alias (repeatable, docker only)
|
|
18
20
|
* --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
19
21
|
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
|
|
20
22
|
* --keep-user Keep isolated user after command completes (don't delete)
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
*/
|
|
38
40
|
|
|
39
41
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
42
|
+
const dockerNetworkOptions = require('./docker-network-options');
|
|
40
43
|
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
41
44
|
|
|
42
45
|
// Debug mode from environment
|
|
@@ -178,6 +181,8 @@ function parseArgs(args) {
|
|
|
178
181
|
mounts: [], // Docker --mount specs, applied to docker levels
|
|
179
182
|
env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
|
|
180
183
|
privileged: false, // Run docker container in privileged mode
|
|
184
|
+
network: null, // Docker network name
|
|
185
|
+
networkAliases: [], // Docker network-scoped aliases
|
|
181
186
|
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
182
187
|
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
183
188
|
user: false, // Create isolated user
|
|
@@ -396,6 +401,11 @@ function parseOption(args, index, options) {
|
|
|
396
401
|
return 1;
|
|
397
402
|
}
|
|
398
403
|
|
|
404
|
+
const networkOption = dockerNetworkOptions.parse(args, index, options);
|
|
405
|
+
if (networkOption) {
|
|
406
|
+
return networkOption;
|
|
407
|
+
}
|
|
408
|
+
|
|
399
409
|
// --endpoint (for ssh) - supports sequence for stacked isolation
|
|
400
410
|
if (arg === '--endpoint') {
|
|
401
411
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
@@ -645,8 +655,7 @@ function parseOption(args, index, options) {
|
|
|
645
655
|
}
|
|
646
656
|
|
|
647
657
|
/**
|
|
648
|
-
* Throw if docker runtime options
|
|
649
|
-
* are present but the isolation configuration does not include docker.
|
|
658
|
+
* Throw if docker runtime options are present without docker isolation.
|
|
650
659
|
* @param {object} options - Parsed options
|
|
651
660
|
* @throws {Error} If a docker-only option is set without docker isolation
|
|
652
661
|
*/
|
|
@@ -671,6 +680,7 @@ function validateDockerRuntimeOptionsRequireDocker(options) {
|
|
|
671
680
|
'--privileged option is only valid when isolation stack includes docker'
|
|
672
681
|
);
|
|
673
682
|
}
|
|
683
|
+
dockerNetworkOptions.validateRequireDocker(options);
|
|
674
684
|
}
|
|
675
685
|
|
|
676
686
|
function validateDockerCleanupOptions(options, hasDocker) {
|
|
@@ -109,6 +109,22 @@ function readDockerContainerOomKilled(containerName) {
|
|
|
109
109
|
return null;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function readDockerContainerStatus(containerName) {
|
|
113
|
+
const result = spawnSync(
|
|
114
|
+
getDockerCommand(),
|
|
115
|
+
['inspect', '-f', '{{.State.Status}}', containerName],
|
|
116
|
+
getDockerSpawnOptions({
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
env: process.env,
|
|
119
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
if (result.error || result.status !== 0) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return String(result.stdout || '').trim() || null;
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
function removeDockerContainer(containerName, logPath = null) {
|
|
113
129
|
const result = spawnSync(
|
|
114
130
|
getDockerCommand(),
|
|
@@ -237,6 +253,7 @@ module.exports = {
|
|
|
237
253
|
getDockerContainerCleanupInstructions,
|
|
238
254
|
appendDockerContainerCleanupPolicyMessage,
|
|
239
255
|
readDockerContainerOomKilled,
|
|
256
|
+
readDockerContainerStatus,
|
|
240
257
|
removeDockerContainer,
|
|
241
258
|
buildDetachedDockerCompletionScript,
|
|
242
259
|
startDetachedDockerCompletionWatcher,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Parse and validate Docker network wrapper options. */
|
|
2
|
+
|
|
3
|
+
function parseDockerNetworkOption(args, index, options) {
|
|
4
|
+
const arg = args[index];
|
|
5
|
+
if (arg === '--network' || arg === '--network-alias') {
|
|
6
|
+
if (index + 1 >= args.length || args[index + 1].startsWith('-')) {
|
|
7
|
+
const value = arg === '--network' ? 'network name' : 'alias';
|
|
8
|
+
throw new Error(`Option ${arg} requires a ${value} argument`);
|
|
9
|
+
}
|
|
10
|
+
if (arg === '--network') {
|
|
11
|
+
options.network = args[index + 1];
|
|
12
|
+
} else {
|
|
13
|
+
options.networkAliases.push(args[index + 1]);
|
|
14
|
+
}
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
if (arg.startsWith('--network=')) {
|
|
18
|
+
options.network = arg.slice('--network='.length);
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
if (arg.startsWith('--network-alias=')) {
|
|
22
|
+
options.networkAliases.push(arg.slice('--network-alias='.length));
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateDockerNetworkOptionsRequireDocker(options) {
|
|
29
|
+
if (options.network) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'--network option is only valid when isolation stack includes docker'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (options.networkAliases?.length > 0) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'--network-alias option is only valid when isolation stack includes docker'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
parse: parseDockerNetworkOption,
|
|
43
|
+
validateRequireDocker: validateDockerNetworkOptionsRequireDocker,
|
|
44
|
+
};
|
package/src/lib/isolation.js
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
getDockerContainerCleanupInstructions,
|
|
29
29
|
appendDockerContainerCleanupPolicyMessage,
|
|
30
30
|
readDockerContainerOomKilled,
|
|
31
|
+
readDockerContainerStatus,
|
|
31
32
|
removeDockerContainer,
|
|
32
33
|
startDetachedDockerCompletionWatcher,
|
|
33
34
|
spawnAttachedDocker,
|
|
@@ -499,9 +500,9 @@ const {
|
|
|
499
500
|
/**
|
|
500
501
|
* Build the docker run runtime argument list contributed by configurable
|
|
501
502
|
* container options: privileged mode, environment variables, volumes/bind
|
|
502
|
-
* mounts,
|
|
503
|
+
* mounts, --mount specs, and network configuration. Returned in a stable order so they can be spliced
|
|
503
504
|
* into the `docker run` argv before the image name.
|
|
504
|
-
* @param {object} options - Options (privileged, env, volumes, mounts)
|
|
505
|
+
* @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases)
|
|
505
506
|
* @returns {string[]} Docker CLI arguments
|
|
506
507
|
*/
|
|
507
508
|
function buildDockerRuntimeArgs(options = {}) {
|
|
@@ -518,6 +519,12 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
518
519
|
for (const mount of options.mounts || []) {
|
|
519
520
|
args.push('--mount', mount);
|
|
520
521
|
}
|
|
522
|
+
if (options.network) {
|
|
523
|
+
args.push('--network', options.network);
|
|
524
|
+
}
|
|
525
|
+
for (const alias of options.networkAliases || []) {
|
|
526
|
+
args.push('--network-alias', alias);
|
|
527
|
+
}
|
|
521
528
|
return args;
|
|
522
529
|
}
|
|
523
530
|
|
|
@@ -542,6 +549,14 @@ function buildDockerRuntimeStatusLines(options = {}) {
|
|
|
542
549
|
if (options.privileged) {
|
|
543
550
|
lines.push(`[Isolation] Privileged: true`);
|
|
544
551
|
}
|
|
552
|
+
if (options.network) {
|
|
553
|
+
lines.push(`[Isolation] Network: ${options.network}`);
|
|
554
|
+
}
|
|
555
|
+
if (options.networkAliases && options.networkAliases.length > 0) {
|
|
556
|
+
lines.push(
|
|
557
|
+
`[Isolation] Network aliases: ${options.networkAliases.join(', ')}`
|
|
558
|
+
);
|
|
559
|
+
}
|
|
545
560
|
return lines;
|
|
546
561
|
}
|
|
547
562
|
|
|
@@ -549,7 +564,7 @@ function buildDockerRuntimeStatusLines(options = {}) {
|
|
|
549
564
|
* Build the execution-record metadata for docker runtime options, normalizing
|
|
550
565
|
* empty collections and a falsy privileged flag to `null`.
|
|
551
566
|
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
552
|
-
* @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}}
|
|
567
|
+
* @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networkAliases: ?string[]}}
|
|
553
568
|
*/
|
|
554
569
|
function buildDockerRuntimeMetadata(options = {}) {
|
|
555
570
|
return {
|
|
@@ -558,6 +573,11 @@ function buildDockerRuntimeMetadata(options = {}) {
|
|
|
558
573
|
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
559
574
|
env: options.env && options.env.length > 0 ? options.env : null,
|
|
560
575
|
privileged: options.privileged || null,
|
|
576
|
+
network: options.network || null,
|
|
577
|
+
networkAliases:
|
|
578
|
+
options.networkAliases && options.networkAliases.length > 0
|
|
579
|
+
? options.networkAliases
|
|
580
|
+
: null,
|
|
561
581
|
};
|
|
562
582
|
}
|
|
563
583
|
|
|
@@ -597,6 +617,8 @@ function runInDocker(command, options = {}) {
|
|
|
597
617
|
}
|
|
598
618
|
|
|
599
619
|
const containerName = options.session || generateSessionName('docker');
|
|
620
|
+
const containerExistedBeforeLaunch =
|
|
621
|
+
readDockerContainerStatus(containerName) !== null;
|
|
600
622
|
const cleanupPolicy = getDockerContainerCleanupPolicy(options);
|
|
601
623
|
if (!dockerImageExists(options.image)) {
|
|
602
624
|
// Pass logPath so the image-preparation phase (docker pull) is recorded in
|
|
@@ -671,6 +693,12 @@ function runInDocker(command, options = {}) {
|
|
|
671
693
|
dockerResult.stderr.trim() ||
|
|
672
694
|
dockerResult.stdout.trim() ||
|
|
673
695
|
`docker exited with code ${dockerResult.status}`;
|
|
696
|
+
if (
|
|
697
|
+
!containerExistedBeforeLaunch &&
|
|
698
|
+
readDockerContainerStatus(containerName) === 'created'
|
|
699
|
+
) {
|
|
700
|
+
removeDockerContainer(containerName, options.logPath);
|
|
701
|
+
}
|
|
674
702
|
throw new Error(dockerError);
|
|
675
703
|
}
|
|
676
704
|
|
|
@@ -760,8 +788,17 @@ function runInDocker(command, options = {}) {
|
|
|
760
788
|
}
|
|
761
789
|
|
|
762
790
|
const oomKilled = readDockerContainerOomKilled(containerName);
|
|
791
|
+
const launchFailed =
|
|
792
|
+
!containerExistedBeforeLaunch &&
|
|
793
|
+
readDockerContainerStatus(containerName) === 'created';
|
|
763
794
|
|
|
764
|
-
if (
|
|
795
|
+
if (launchFailed) {
|
|
796
|
+
if (removeDockerContainer(containerName, options.logPath)) {
|
|
797
|
+
message += `\nContainer removed after launch failure.`;
|
|
798
|
+
} else {
|
|
799
|
+
message += `\nWarning: failed to remove container after launch failure.`;
|
|
800
|
+
}
|
|
801
|
+
} else if (
|
|
765
802
|
shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
|
|
766
803
|
) {
|
|
767
804
|
if (removeDockerContainer(containerName, options.logPath)) {
|
|
@@ -175,20 +175,88 @@ function isDetachedSessionAlive(record) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Number of trailing bytes scanned for the terminal log footer. The footer is
|
|
180
|
+
* always appended at the very end of the log, so there is no reason to read
|
|
181
|
+
* (potentially megabytes of) command output on every `--status` call.
|
|
182
|
+
*/
|
|
183
|
+
const LOG_TAIL_BYTES = 16 * 1024;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The three-line terminal footer that `start` itself writes (see
|
|
187
|
+
* `createLogFooter()` and `createShellLogFooterSnippet()`):
|
|
188
|
+
*
|
|
189
|
+
* ==================================================
|
|
190
|
+
* Finished: 2026-07-30 23:36:20.295
|
|
191
|
+
* Exit Code: 0
|
|
192
|
+
*
|
|
193
|
+
* The whole block is anchored at line starts so that a bare `Exit Code: N`
|
|
194
|
+
* substring emitted by the wrapped command (inside a JSON payload, a quoted
|
|
195
|
+
* log excerpt, an `rg` dump, ...) can no longer be mistaken for the footer
|
|
196
|
+
* `start` appends itself (issue #150).
|
|
197
|
+
*/
|
|
198
|
+
const LOG_FOOTER_PATTERN =
|
|
199
|
+
/^={10,}[ \t]*\r?\n^Finished:[^\r\n]*\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read the last `bytes` bytes of a file as UTF-8 text.
|
|
203
|
+
*
|
|
204
|
+
* A partial first line is dropped: the slice can start in the middle of a line,
|
|
205
|
+
* and that fragment must not be treated as the beginning of a line by the
|
|
206
|
+
* anchored footer pattern.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} logPath - Path to the log file
|
|
209
|
+
* @param {number} [bytes] - Maximum number of trailing bytes to read
|
|
210
|
+
* @returns {string|null} Tail content, or null when the file cannot be read
|
|
211
|
+
*/
|
|
212
|
+
function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
|
|
213
|
+
let fd;
|
|
214
|
+
try {
|
|
215
|
+
fd = fs.openSync(logPath, 'r');
|
|
216
|
+
const size = fs.fstatSync(fd).size;
|
|
217
|
+
const length = Math.min(size, bytes);
|
|
218
|
+
const buffer = Buffer.alloc(length);
|
|
219
|
+
fs.readSync(fd, buffer, 0, length, size - length);
|
|
220
|
+
const tail = buffer.toString('utf8');
|
|
221
|
+
if (size <= bytes) {
|
|
222
|
+
return tail;
|
|
223
|
+
}
|
|
224
|
+
const firstNewline = tail.indexOf('\n');
|
|
225
|
+
return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
} finally {
|
|
229
|
+
if (fd !== undefined) {
|
|
230
|
+
try {
|
|
231
|
+
fs.closeSync(fd);
|
|
232
|
+
} catch {
|
|
233
|
+
// ignore
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read the terminal exit code from the anchored footer at the end of a log.
|
|
241
|
+
* Returns null when the log has no footer yet (command still running, or the
|
|
242
|
+
* host-side watcher has not appended it yet) — never a number parsed out of the
|
|
243
|
+
* command's own output.
|
|
244
|
+
* @param {string} logPath - Path to the log file
|
|
245
|
+
* @returns {number|null}
|
|
246
|
+
*/
|
|
178
247
|
function readExitCodeFromLog(logPath) {
|
|
179
248
|
if (!logPath) {
|
|
180
249
|
return null;
|
|
181
250
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const matches = [...content.matchAll(/Exit Code:\s*(-?\d+)/g)];
|
|
185
|
-
if (matches.length === 0) {
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
return parseInt(matches[matches.length - 1][1], 10);
|
|
189
|
-
} catch {
|
|
251
|
+
const tail = readLogTail(logPath);
|
|
252
|
+
if (tail === null) {
|
|
190
253
|
return null;
|
|
191
254
|
}
|
|
255
|
+
const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
|
|
256
|
+
if (matches.length === 0) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return parseInt(matches[matches.length - 1][1], 10);
|
|
192
260
|
}
|
|
193
261
|
|
|
194
262
|
/**
|
|
@@ -280,15 +348,17 @@ function enrichDetachedStatus(record) {
|
|
|
280
348
|
// Otherwise keep the recorded/footer exit code - the command has finished.
|
|
281
349
|
} else if (!alive && enriched.status === 'executing') {
|
|
282
350
|
// Session ended but record says executing - correct it. Resolve a real exit
|
|
283
|
-
// code: prefer the
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
351
|
+
// code: prefer the backend's own record (e.g. `docker inspect
|
|
352
|
+
// .State.ExitCode`, which is authoritative and cannot be spoofed by command
|
|
353
|
+
// output — issue #150), then the anchored log footer, then `137` when the
|
|
354
|
+
// only evidence left is the OOM observation, and only fall back to the `-1`
|
|
355
|
+
// sentinel as a last resort when no real code can be obtained (issues #136,
|
|
356
|
+
// #151).
|
|
287
357
|
enriched.status = 'executed';
|
|
288
358
|
if (enriched.exitCode === null || enriched.exitCode === undefined) {
|
|
289
359
|
enriched.exitCode =
|
|
290
|
-
footerExit ??
|
|
291
360
|
backendExitCode(dockerState) ??
|
|
361
|
+
footerExit ??
|
|
292
362
|
(oomKilled === true ? 137 : -1);
|
|
293
363
|
}
|
|
294
364
|
if (!enriched.endTime) {
|
|
@@ -638,6 +708,8 @@ module.exports = {
|
|
|
638
708
|
listExecutions,
|
|
639
709
|
isDetachedSessionAlive,
|
|
640
710
|
enrichDetachedStatus,
|
|
711
|
+
readExitCodeFromLog,
|
|
712
|
+
readLogTail,
|
|
641
713
|
attachCurrentTime,
|
|
642
714
|
attachProcessIds,
|
|
643
715
|
};
|
package/src/lib/usage.js
CHANGED
|
@@ -14,6 +14,8 @@ Options:
|
|
|
14
14
|
--mount <spec> Docker --mount spec (repeatable, docker only)
|
|
15
15
|
--env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
16
16
|
--privileged Run docker container in privileged mode (docker only)
|
|
17
|
+
--network <name> Connect docker container to a named network (docker only)
|
|
18
|
+
--network-alias <alias> Add network-scoped alias (repeatable, docker only)
|
|
17
19
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
18
20
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
19
21
|
--keep-user Keep isolated user after command completes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** Real-daemon integration coverage for Docker network isolation (issue #154). */
|
|
3
|
+
|
|
4
|
+
const { after, before, describe, it } = require('node:test');
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const { randomUUID } = require('crypto');
|
|
8
|
+
const { runInDocker } = require('../src/lib/isolation');
|
|
9
|
+
|
|
10
|
+
const suffix = randomUUID().slice(0, 8);
|
|
11
|
+
const network = `start-network-${suffix}`;
|
|
12
|
+
const sidecar = `start-sidecar-${suffix}`;
|
|
13
|
+
const connected = `start-connected-${suffix}`;
|
|
14
|
+
const control = `start-control-${suffix}`;
|
|
15
|
+
const missing = `start-missing-${suffix}`;
|
|
16
|
+
const missingAttached = `start-missing-attached-${suffix}`;
|
|
17
|
+
let dockerAvailable = false;
|
|
18
|
+
|
|
19
|
+
function docker(args) {
|
|
20
|
+
return spawnSync('docker', args, { encoding: 'utf8' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Docker named network integration', { timeout: 60000 }, () => {
|
|
24
|
+
before(() => {
|
|
25
|
+
dockerAvailable =
|
|
26
|
+
process.platform === 'linux' && docker(['info']).status === 0;
|
|
27
|
+
if (!dockerAvailable) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const created = docker(['network', 'create', '--internal', network]);
|
|
32
|
+
assert.strictEqual(created.status, 0, created.stderr);
|
|
33
|
+
const started = docker([
|
|
34
|
+
'run',
|
|
35
|
+
'-d',
|
|
36
|
+
'--name',
|
|
37
|
+
sidecar,
|
|
38
|
+
'--network',
|
|
39
|
+
network,
|
|
40
|
+
'--network-alias',
|
|
41
|
+
'formal-ai',
|
|
42
|
+
'alpine:3.23',
|
|
43
|
+
'sleep',
|
|
44
|
+
'60',
|
|
45
|
+
]);
|
|
46
|
+
assert.strictEqual(started.status, 0, started.stderr);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
after(() => {
|
|
50
|
+
if (!dockerAvailable) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
docker(['rm', '-f', sidecar, connected, control, missing, missingAttached]);
|
|
54
|
+
docker(['network', 'rm', network]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('resolves an alias only from containers joined to the internal network', async () => {
|
|
58
|
+
if (!dockerAvailable) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const joined = await runInDocker('ping -c 1 formal-ai', {
|
|
63
|
+
image: 'alpine:3.23',
|
|
64
|
+
session: connected,
|
|
65
|
+
network,
|
|
66
|
+
networkAliases: ['task'],
|
|
67
|
+
detached: true,
|
|
68
|
+
shell: 'sh',
|
|
69
|
+
keepContainer: true,
|
|
70
|
+
});
|
|
71
|
+
assert.strictEqual(joined.success, true, joined.message);
|
|
72
|
+
const joinedExit = docker(['wait', connected]);
|
|
73
|
+
assert.strictEqual(joinedExit.status, 0, joinedExit.stderr);
|
|
74
|
+
assert.strictEqual(joinedExit.stdout.trim(), '0');
|
|
75
|
+
|
|
76
|
+
const unconnected = await runInDocker('ping -c 1 formal-ai', {
|
|
77
|
+
image: 'alpine:3.23',
|
|
78
|
+
session: control,
|
|
79
|
+
shell: 'sh',
|
|
80
|
+
alwaysCleanupContainer: true,
|
|
81
|
+
});
|
|
82
|
+
assert.strictEqual(unconnected.success, false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('fails for a missing network without orphaning a container', async () => {
|
|
86
|
+
if (!dockerAvailable) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await runInDocker('echo should-not-run', {
|
|
91
|
+
image: 'alpine:3.23',
|
|
92
|
+
session: missing,
|
|
93
|
+
network: `${network}-absent`,
|
|
94
|
+
detached: true,
|
|
95
|
+
shell: 'sh',
|
|
96
|
+
});
|
|
97
|
+
assert.strictEqual(result.success, false);
|
|
98
|
+
assert.match(result.message, /network .* not found/i);
|
|
99
|
+
assert.notStrictEqual(docker(['inspect', missing]).status, 0);
|
|
100
|
+
|
|
101
|
+
const attachedResult = await runInDocker('echo should-not-run', {
|
|
102
|
+
image: 'alpine:3.23',
|
|
103
|
+
session: missingAttached,
|
|
104
|
+
network: `${network}-absent`,
|
|
105
|
+
shell: 'sh',
|
|
106
|
+
});
|
|
107
|
+
assert.strictEqual(attachedResult.success, false);
|
|
108
|
+
assert.notStrictEqual(docker(['inspect', missingAttached]).status, 0);
|
|
109
|
+
|
|
110
|
+
const conflict = await runInDocker('echo should-not-run', {
|
|
111
|
+
image: 'alpine:3.23',
|
|
112
|
+
session: sidecar,
|
|
113
|
+
network: `${network}-absent`,
|
|
114
|
+
detached: true,
|
|
115
|
+
shell: 'sh',
|
|
116
|
+
});
|
|
117
|
+
assert.strictEqual(conflict.success, false);
|
|
118
|
+
assert.strictEqual(docker(['inspect', sidecar]).status, 0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Tests for Docker runtime options: --volume/-v, --mount, --env/-e,
|
|
3
|
+
* Tests for Docker runtime options: --volume/-v, --mount, --env/-e,
|
|
4
|
+
* --privileged, --network, and --network-alias
|
|
4
5
|
*
|
|
5
6
|
* Reproduces issue #132: callers need to configure bind mounts, volumes,
|
|
6
7
|
* environment variables, and privileged mode for the docker isolation backend
|
|
@@ -90,12 +91,58 @@ describe('Docker runtime options parsing', () => {
|
|
|
90
91
|
assert.strictEqual(result.wrapperOptions.privileged, true);
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
it('should parse --network value and equals forms without consuming child args', () => {
|
|
95
|
+
const spaced = parseArgs([
|
|
96
|
+
'-i',
|
|
97
|
+
'docker',
|
|
98
|
+
'--network',
|
|
99
|
+
'hive-formal-ai',
|
|
100
|
+
'--',
|
|
101
|
+
'--network',
|
|
102
|
+
'child-network',
|
|
103
|
+
]);
|
|
104
|
+
const equals = parseArgs([
|
|
105
|
+
'-i',
|
|
106
|
+
'docker',
|
|
107
|
+
'--network=hive-formal-ai',
|
|
108
|
+
'--',
|
|
109
|
+
'echo',
|
|
110
|
+
'ok',
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
assert.strictEqual(spaced.wrapperOptions.network, 'hive-formal-ai');
|
|
114
|
+
assert.deepStrictEqual(spaced.rawCommand, ['--network', 'child-network']);
|
|
115
|
+
assert.strictEqual(equals.wrapperOptions.network, 'hive-formal-ai');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should parse repeatable --network-alias value and equals forms', () => {
|
|
119
|
+
const result = parseArgs([
|
|
120
|
+
'-i',
|
|
121
|
+
'docker',
|
|
122
|
+
'--network',
|
|
123
|
+
'hive-formal-ai',
|
|
124
|
+
'--network-alias',
|
|
125
|
+
'formal-ai',
|
|
126
|
+
'--network-alias=checker',
|
|
127
|
+
'--',
|
|
128
|
+
'echo',
|
|
129
|
+
'ok',
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
assert.deepStrictEqual(result.wrapperOptions.networkAliases, [
|
|
133
|
+
'formal-ai',
|
|
134
|
+
'checker',
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
93
138
|
it('should default runtime options to empty/false', () => {
|
|
94
139
|
const result = parseArgs(['-i', 'docker', '--', 'ls']);
|
|
95
140
|
assert.deepStrictEqual(result.wrapperOptions.volumes, []);
|
|
96
141
|
assert.deepStrictEqual(result.wrapperOptions.mounts, []);
|
|
97
142
|
assert.deepStrictEqual(result.wrapperOptions.env, []);
|
|
98
143
|
assert.strictEqual(result.wrapperOptions.privileged, false);
|
|
144
|
+
assert.strictEqual(result.wrapperOptions.network, null);
|
|
145
|
+
assert.deepStrictEqual(result.wrapperOptions.networkAliases, []);
|
|
99
146
|
});
|
|
100
147
|
|
|
101
148
|
it('should throw when --volume requires an argument', () => {
|
|
@@ -136,6 +183,15 @@ describe('Docker runtime options validation', () => {
|
|
|
136
183
|
}, /--privileged option is only valid when isolation stack includes docker/);
|
|
137
184
|
});
|
|
138
185
|
|
|
186
|
+
it('should reject network options without docker', () => {
|
|
187
|
+
assert.throws(() => {
|
|
188
|
+
parseArgs(['-i', 'tmux', '--network', 'private', '--', 'ls']);
|
|
189
|
+
}, /--network option is only valid when isolation stack includes docker/);
|
|
190
|
+
assert.throws(() => {
|
|
191
|
+
parseArgs(['--network-alias', 'service', '--', 'ls']);
|
|
192
|
+
}, /--network-alias option is only valid when isolation stack includes docker/);
|
|
193
|
+
});
|
|
194
|
+
|
|
139
195
|
it('should accept runtime options when stack includes docker', () => {
|
|
140
196
|
const result = parseArgs([
|
|
141
197
|
'-i',
|
|
@@ -171,6 +227,8 @@ describe('buildDockerRuntimeArgs', () => {
|
|
|
171
227
|
env: ['FOO=bar', 'GH_TOKEN=secret'],
|
|
172
228
|
volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
|
|
173
229
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
230
|
+
network: 'hive-formal-ai',
|
|
231
|
+
networkAliases: ['formal-ai', 'checker'],
|
|
174
232
|
});
|
|
175
233
|
assert.deepStrictEqual(args, [
|
|
176
234
|
'--privileged',
|
|
@@ -184,6 +242,12 @@ describe('buildDockerRuntimeArgs', () => {
|
|
|
184
242
|
'/h/b:/c/b:ro',
|
|
185
243
|
'--mount',
|
|
186
244
|
'type=bind,src=/h,dst=/c',
|
|
245
|
+
'--network',
|
|
246
|
+
'hive-formal-ai',
|
|
247
|
+
'--network-alias',
|
|
248
|
+
'formal-ai',
|
|
249
|
+
'--network-alias',
|
|
250
|
+
'checker',
|
|
187
251
|
]);
|
|
188
252
|
});
|
|
189
253
|
});
|
|
@@ -199,12 +263,16 @@ describe('buildDockerRuntimeStatusLines', () => {
|
|
|
199
263
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
200
264
|
env: ['FOO=bar'],
|
|
201
265
|
privileged: true,
|
|
266
|
+
network: 'hive-formal-ai',
|
|
267
|
+
networkAliases: ['formal-ai', 'checker'],
|
|
202
268
|
});
|
|
203
269
|
assert.deepStrictEqual(lines, [
|
|
204
270
|
'[Isolation] Volumes: /h:/c:ro',
|
|
205
271
|
'[Isolation] Mounts: type=bind,src=/h,dst=/c',
|
|
206
272
|
'[Isolation] Env: FOO=bar',
|
|
207
273
|
'[Isolation] Privileged: true',
|
|
274
|
+
'[Isolation] Network: hive-formal-ai',
|
|
275
|
+
'[Isolation] Network aliases: formal-ai, checker',
|
|
208
276
|
]);
|
|
209
277
|
});
|
|
210
278
|
|
|
@@ -223,6 +291,8 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
223
291
|
mounts: null,
|
|
224
292
|
env: null,
|
|
225
293
|
privileged: null,
|
|
294
|
+
network: null,
|
|
295
|
+
networkAliases: null,
|
|
226
296
|
});
|
|
227
297
|
});
|
|
228
298
|
|
|
@@ -233,12 +303,16 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
233
303
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
234
304
|
env: ['FOO=bar'],
|
|
235
305
|
privileged: true,
|
|
306
|
+
network: 'hive-formal-ai',
|
|
307
|
+
networkAliases: ['formal-ai', 'checker'],
|
|
236
308
|
}),
|
|
237
309
|
{
|
|
238
310
|
volumes: ['/h:/c'],
|
|
239
311
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
240
312
|
env: ['FOO=bar'],
|
|
241
313
|
privileged: true,
|
|
314
|
+
network: 'hive-formal-ai',
|
|
315
|
+
networkAliases: ['formal-ai', 'checker'],
|
|
242
316
|
}
|
|
243
317
|
);
|
|
244
318
|
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for issue #150:
|
|
4
|
+
* "`$ --status` fabricates a detached session exit code from the command's own
|
|
5
|
+
* output (unanchored `Exit Code:` scan over the whole log)"
|
|
6
|
+
*
|
|
7
|
+
* The exit code of a detached session used to be derived from an *unanchored*
|
|
8
|
+
* `Exit Code: N` scan over the *whole* session log. Any text the wrapped
|
|
9
|
+
* command printed that merely contained the substring `Exit Code: N` was
|
|
10
|
+
* indistinguishable from the terminal footer `start` appends itself, so
|
|
11
|
+
* `$ --status` could report an exit code the command never produced.
|
|
12
|
+
*
|
|
13
|
+
* The fix anchors the scan on the three-line footer block `start` writes
|
|
14
|
+
* (separator / `Finished:` / `Exit Code:`) and only reads the tail of the log,
|
|
15
|
+
* where the footer always is.
|
|
16
|
+
*
|
|
17
|
+
* Reference: https://github.com/link-foundation/start/issues/150
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
const { ExecutionRecord } = require('../src/lib/execution-store');
|
|
26
|
+
const {
|
|
27
|
+
readExitCodeFromLog,
|
|
28
|
+
readLogTail,
|
|
29
|
+
enrichDetachedStatus,
|
|
30
|
+
} = require('../src/lib/status-formatter');
|
|
31
|
+
|
|
32
|
+
const SEPARATOR = '='.repeat(50);
|
|
33
|
+
|
|
34
|
+
function realFooter(exitCode) {
|
|
35
|
+
return `\n${SEPARATOR}\nFinished: 2026-07-30 23:36:20.295\nExit Code: ${exitCode}\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('anchored log footer parsing (issue #150)', () => {
|
|
39
|
+
let tempDir;
|
|
40
|
+
let logPath;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-150-'));
|
|
44
|
+
logPath = path.join(tempDir, 'session.log');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('ignores an `Exit Code:` substring inside the command output', () => {
|
|
52
|
+
// Exactly the payload from the incident: an older session log dumped by
|
|
53
|
+
// `rg -n` into the JSON output of the running command.
|
|
54
|
+
fs.writeFileSync(
|
|
55
|
+
logPath,
|
|
56
|
+
'{"type":"item.completed","item":{"aggregated_output":' +
|
|
57
|
+
'"40-==================================================\\n41-Finished: 2026-07-28 20:04:52.316\\n42-Exit Code: 1\\n",' +
|
|
58
|
+
'"exit_code":0,"status":"completed"}}\n'
|
|
59
|
+
);
|
|
60
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ignores a bare `Exit Code:` line without the footer block around it', () => {
|
|
64
|
+
fs.writeFileSync(logPath, 'Exit Code: 1\nstill running\n');
|
|
65
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ignores an `Exit Code:` substring in the middle of a line', () => {
|
|
69
|
+
fs.writeFileSync(
|
|
70
|
+
logPath,
|
|
71
|
+
`${SEPARATOR}\nFinished: now\nlog: Exit Code: 3\n`
|
|
72
|
+
);
|
|
73
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('reads the real footer that `start` appends', () => {
|
|
77
|
+
fs.writeFileSync(logPath, `hello\n${realFooter(0)}`);
|
|
78
|
+
expect(readExitCodeFromLog(logPath)).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reads the real footer even when the output forged one earlier', () => {
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
logPath,
|
|
84
|
+
`${SEPARATOR}\nFinished: fake\nExit Code: 1\n${realFooter(0)}`
|
|
85
|
+
);
|
|
86
|
+
// The last anchored footer wins - here the genuine one, appended last.
|
|
87
|
+
expect(readExitCodeFromLog(logPath)).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('reads negative and multi-digit codes, and tolerates CRLF logs', () => {
|
|
91
|
+
fs.writeFileSync(
|
|
92
|
+
logPath,
|
|
93
|
+
`${SEPARATOR}\r\nFinished: t\r\nExit Code: 137\r\n`
|
|
94
|
+
);
|
|
95
|
+
expect(readExitCodeFromLog(logPath)).toBe(137);
|
|
96
|
+
fs.writeFileSync(logPath, realFooter(-1));
|
|
97
|
+
expect(readExitCodeFromLog(logPath)).toBe(-1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns null for a log without a footer and for a missing file', () => {
|
|
101
|
+
fs.writeFileSync(logPath, 'still running, no footer yet\n');
|
|
102
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
103
|
+
expect(readExitCodeFromLog(path.join(tempDir, 'missing.log'))).toBeNull();
|
|
104
|
+
expect(readExitCodeFromLog(null)).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('finds the footer at the end of a large log without reading it all', () => {
|
|
108
|
+
fs.writeFileSync(
|
|
109
|
+
logPath,
|
|
110
|
+
`${'x'.repeat(2 * 1024 * 1024)}\n${realFooter(42)}`
|
|
111
|
+
);
|
|
112
|
+
expect(readExitCodeFromLog(logPath)).toBe(42);
|
|
113
|
+
// Only the tail is read, so an `Exit Code:` far from the end is invisible.
|
|
114
|
+
const tail = readLogTail(logPath);
|
|
115
|
+
expect(tail.length).toBeLessThan(32 * 1024);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('drops the partial first line of the tail so it cannot be a line start', () => {
|
|
119
|
+
const filler = 'y'.repeat(32 * 1024);
|
|
120
|
+
fs.writeFileSync(
|
|
121
|
+
logPath,
|
|
122
|
+
`${filler}${SEPARATOR}\nFinished: t\nExit Code: 7\n`
|
|
123
|
+
);
|
|
124
|
+
// The separator is a line *continuation* (the line starts with the filler),
|
|
125
|
+
// so it is not a footer even though the tail slice may begin mid-line.
|
|
126
|
+
expect(readExitCodeFromLog(logPath)).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('detached status is not derailed by forged output (issue #150)', () => {
|
|
131
|
+
let tempDir;
|
|
132
|
+
let logPath;
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-150-enrich-'));
|
|
136
|
+
logPath = path.join(tempDir, 'session.log');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
function makeRecord() {
|
|
144
|
+
return new ExecutionRecord({
|
|
145
|
+
command: 'solve.mjs',
|
|
146
|
+
logPath,
|
|
147
|
+
options: {
|
|
148
|
+
// A container name that cannot be inspected: liveness is unknown, the
|
|
149
|
+
// exact window in which the incident happened.
|
|
150
|
+
sessionName: `issue150-absent-${process.pid}-${Date.now()}`,
|
|
151
|
+
isolated: 'docker',
|
|
152
|
+
isolationMode: 'detached',
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it('keeps an executing record executing when only the output claims an exit code', () => {
|
|
158
|
+
fs.writeFileSync(
|
|
159
|
+
logPath,
|
|
160
|
+
'{"aggregated_output":"41-Finished: x\\n42-Exit Code: 1\\n","exit_code":0}\n'
|
|
161
|
+
);
|
|
162
|
+
const enriched = enrichDetachedStatus(makeRecord());
|
|
163
|
+
expect(enriched.status).toBe('executing');
|
|
164
|
+
expect(enriched.exitCode).toBeNull();
|
|
165
|
+
expect(enriched.endTime).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('marks the record executed once the genuine footer is appended', () => {
|
|
169
|
+
fs.writeFileSync(
|
|
170
|
+
logPath,
|
|
171
|
+
`{"aggregated_output":"42-Exit Code: 1\\n"}\n${realFooter(0)}`
|
|
172
|
+
);
|
|
173
|
+
const enriched = enrichDetachedStatus(makeRecord());
|
|
174
|
+
expect(enriched.status).toBe('executed');
|
|
175
|
+
expect(enriched.exitCode).toBe(0);
|
|
176
|
+
expect(enriched.endTime).not.toBeNull();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -846,7 +846,12 @@ describe('Issue #151: OOMKilled is an observation, not a verdict', () => {
|
|
|
846
846
|
});
|
|
847
847
|
|
|
848
848
|
it('prefers the log footer exit code over the OOM fallback when the container is gone', () => {
|
|
849
|
-
|
|
849
|
+
// The anchored footer block `start` itself writes (issue #150).
|
|
850
|
+
fs.writeFileSync(
|
|
851
|
+
logPath,
|
|
852
|
+
`${'='.repeat(50)}\nFinished: now\nExit Code: 0\n`,
|
|
853
|
+
'utf8'
|
|
854
|
+
);
|
|
850
855
|
const record = saveDockerRecord({ oomKilled: true });
|
|
851
856
|
|
|
852
857
|
withFakeDockerMissingContainer(() => {
|