start-command 0.24.8 → 0.24.9
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 +30 -0
- package/package.json +1 -1
- package/src/lib/isolation.js +14 -239
- package/src/lib/screen-isolation.js +309 -0
- package/test/isolation.test.js +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.24.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 48515a1: fix: capture output from quick-completing commands in screen isolation (issue #96)
|
|
8
|
+
|
|
9
|
+
When running a short-lived command like `agent --version` through screen isolation:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
$ --isolated screen -- agent --version
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
the version output was silently lost — the command exited cleanly (exit code 0)
|
|
16
|
+
but no output was displayed.
|
|
17
|
+
|
|
18
|
+
**Root cause:** GNU Screen's internal log buffer flushes every 10 seconds by default
|
|
19
|
+
(`log_flush = 10`). For commands that complete faster than this, the buffer may not
|
|
20
|
+
be flushed to the log file before the screen session terminates.
|
|
21
|
+
|
|
22
|
+
**Fix:** A temporary screenrc file with `logfile flush 0` is passed to screen via
|
|
23
|
+
the `-c` option. This forces screen to flush the log buffer after every write,
|
|
24
|
+
eliminating the 10-second flush delay for quick-completing commands.
|
|
25
|
+
|
|
26
|
+
A retry mechanism is also added for the tee fallback path (older screen < 4.5.1)
|
|
27
|
+
to handle the TOCTOU race where the log file appears empty when first read
|
|
28
|
+
immediately after session completion.
|
|
29
|
+
|
|
30
|
+
Both JavaScript (`isolation.js`) and Rust (`isolation.rs`) implementations are fixed
|
|
31
|
+
with equivalent test coverage added.
|
|
32
|
+
|
|
3
33
|
## 0.24.8
|
|
4
34
|
|
|
5
35
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/isolation.js
CHANGED
|
@@ -1,94 +1,20 @@
|
|
|
1
1
|
/** Isolation Runners for start-command (screen, tmux, docker, ssh) */
|
|
2
2
|
|
|
3
3
|
const { execSync, spawn, spawnSync } = require('child_process');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const os = require('os');
|
|
6
4
|
const path = require('path');
|
|
7
5
|
const { generateSessionName } = require('./args-parser');
|
|
8
6
|
const outputBlocks = require('./output-blocks');
|
|
9
7
|
|
|
10
|
-
const setTimeout = globalThis.setTimeout;
|
|
11
|
-
|
|
12
8
|
// Debug mode from environment
|
|
13
9
|
const DEBUG =
|
|
14
10
|
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
|
|
23
|
-
*/
|
|
24
|
-
function getScreenVersion() {
|
|
25
|
-
if (screenVersionChecked) {
|
|
26
|
-
return cachedScreenVersion;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
screenVersionChecked = true;
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const output = execSync('screen --version', {
|
|
33
|
-
encoding: 'utf8',
|
|
34
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
-
});
|
|
36
|
-
// Match patterns like "4.09.01", "4.00.03", "4.5.1"
|
|
37
|
-
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
38
|
-
if (match) {
|
|
39
|
-
cachedScreenVersion = {
|
|
40
|
-
major: parseInt(match[1], 10),
|
|
41
|
-
minor: parseInt(match[2], 10),
|
|
42
|
-
patch: parseInt(match[3], 10),
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
if (DEBUG) {
|
|
46
|
-
console.log(
|
|
47
|
-
`[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return cachedScreenVersion;
|
|
52
|
-
}
|
|
53
|
-
} catch {
|
|
54
|
-
if (DEBUG) {
|
|
55
|
-
console.log('[DEBUG] Could not detect screen version');
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Check if screen supports the -Logfile option
|
|
64
|
-
* The -Logfile option was introduced in GNU Screen 4.5.1
|
|
65
|
-
* @returns {boolean} True if -Logfile is supported
|
|
66
|
-
*/
|
|
67
|
-
function supportsLogfileOption() {
|
|
68
|
-
const version = getScreenVersion();
|
|
69
|
-
if (!version) {
|
|
70
|
-
// If we can't detect version, assume older version and use fallback
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// -Logfile was added in 4.5.1
|
|
75
|
-
// Compare: version >= 4.5.1
|
|
76
|
-
if (version.major > 4) {
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
if (version.major < 4) {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
// major === 4
|
|
83
|
-
if (version.minor > 5) {
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
if (version.minor < 5) {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
// minor === 5
|
|
90
|
-
return version.patch >= 1;
|
|
91
|
-
}
|
|
12
|
+
const {
|
|
13
|
+
getScreenVersion,
|
|
14
|
+
supportsLogfileOption,
|
|
15
|
+
runScreenWithLogCapture: _runScreenWithLogCapture,
|
|
16
|
+
resetScreenVersionCache,
|
|
17
|
+
} = require('./screen-isolation');
|
|
92
18
|
|
|
93
19
|
/**
|
|
94
20
|
* Check if a command is available on the system
|
|
@@ -245,159 +171,14 @@ function wrapCommandWithUser(command, user) {
|
|
|
245
171
|
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
|
|
246
172
|
*/
|
|
247
173
|
function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
let screenArgs;
|
|
257
|
-
// Wrap command with user switch if specified
|
|
258
|
-
let effectiveCommand = wrapCommandWithUser(command, user);
|
|
259
|
-
|
|
260
|
-
if (useNativeLogging) {
|
|
261
|
-
// Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
|
|
262
|
-
// screen -dmS <session> -L -Logfile <logfile> <shell> -c '<command>'
|
|
263
|
-
const logArgs = ['-dmS', sessionName, '-L', '-Logfile', logFile];
|
|
264
|
-
screenArgs = isInteractiveShellCommand(command)
|
|
265
|
-
? [...logArgs, ...command.trim().split(/\s+/)]
|
|
266
|
-
: [...logArgs, shell, shellArg, effectiveCommand];
|
|
267
|
-
|
|
268
|
-
if (DEBUG) {
|
|
269
|
-
console.log(
|
|
270
|
-
`[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
} else {
|
|
274
|
-
// Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
|
|
275
|
-
// The parentheses ensure proper grouping of the command and its stderr
|
|
276
|
-
const isBareShell = isInteractiveShellCommand(command);
|
|
277
|
-
if (!isBareShell) {
|
|
278
|
-
effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
|
|
279
|
-
}
|
|
280
|
-
screenArgs = isBareShell
|
|
281
|
-
? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
|
|
282
|
-
: ['-dmS', sessionName, shell, shellArg, effectiveCommand];
|
|
283
|
-
|
|
284
|
-
if (DEBUG) {
|
|
285
|
-
console.log(
|
|
286
|
-
`[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
|
|
292
|
-
const result = spawnSync('screen', screenArgs, {
|
|
293
|
-
stdio: 'inherit',
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
if (result.error) {
|
|
297
|
-
throw result.error;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Poll for session completion
|
|
301
|
-
const checkInterval = 100; // ms
|
|
302
|
-
const maxWait = 300000; // 5 minutes max
|
|
303
|
-
let waited = 0;
|
|
304
|
-
|
|
305
|
-
const checkCompletion = () => {
|
|
306
|
-
try {
|
|
307
|
-
// Check if session still exists
|
|
308
|
-
const sessions = execSync('screen -ls', {
|
|
309
|
-
encoding: 'utf8',
|
|
310
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
if (!sessions.includes(sessionName)) {
|
|
314
|
-
// Session ended, read output
|
|
315
|
-
let output = '';
|
|
316
|
-
try {
|
|
317
|
-
output = fs.readFileSync(logFile, 'utf8');
|
|
318
|
-
// Display the output with surrounding empty lines for consistency
|
|
319
|
-
if (output.trim()) {
|
|
320
|
-
process.stdout.write(output);
|
|
321
|
-
// Add trailing newline if output doesn't end with one
|
|
322
|
-
if (!output.endsWith('\n')) {
|
|
323
|
-
process.stdout.write('\n');
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
} catch {
|
|
327
|
-
// Log file might not exist if command was very quick
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Clean up log file
|
|
331
|
-
try {
|
|
332
|
-
fs.unlinkSync(logFile);
|
|
333
|
-
} catch {
|
|
334
|
-
// Ignore cleanup errors
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
resolve({
|
|
338
|
-
success: true,
|
|
339
|
-
sessionName,
|
|
340
|
-
message: `Screen session "${sessionName}" exited with code 0`,
|
|
341
|
-
exitCode: 0,
|
|
342
|
-
output,
|
|
343
|
-
});
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
waited += checkInterval;
|
|
348
|
-
if (waited >= maxWait) {
|
|
349
|
-
resolve({
|
|
350
|
-
success: false,
|
|
351
|
-
sessionName,
|
|
352
|
-
message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
|
|
353
|
-
exitCode: 1,
|
|
354
|
-
});
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
setTimeout(checkCompletion, checkInterval);
|
|
359
|
-
} catch {
|
|
360
|
-
// screen -ls failed, session probably ended
|
|
361
|
-
let output = '';
|
|
362
|
-
try {
|
|
363
|
-
output = fs.readFileSync(logFile, 'utf8');
|
|
364
|
-
if (output.trim()) {
|
|
365
|
-
process.stdout.write(output);
|
|
366
|
-
// Add trailing newline if output doesn't end with one
|
|
367
|
-
if (!output.endsWith('\n')) {
|
|
368
|
-
process.stdout.write('\n');
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
} catch {
|
|
372
|
-
// Ignore
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
try {
|
|
376
|
-
fs.unlinkSync(logFile);
|
|
377
|
-
} catch {
|
|
378
|
-
// Ignore
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
resolve({
|
|
382
|
-
success: true,
|
|
383
|
-
sessionName,
|
|
384
|
-
message: `Screen session "${sessionName}" exited with code 0`,
|
|
385
|
-
exitCode: 0,
|
|
386
|
-
output,
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
// Start checking after a brief delay
|
|
392
|
-
setTimeout(checkCompletion, checkInterval);
|
|
393
|
-
} catch (err) {
|
|
394
|
-
resolve({
|
|
395
|
-
success: false,
|
|
396
|
-
sessionName,
|
|
397
|
-
message: `Failed to run in screen: ${err.message}`,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
});
|
|
174
|
+
return _runScreenWithLogCapture(
|
|
175
|
+
command,
|
|
176
|
+
sessionName,
|
|
177
|
+
shellInfo,
|
|
178
|
+
user,
|
|
179
|
+
wrapCommandWithUser,
|
|
180
|
+
isInteractiveShellCommand
|
|
181
|
+
);
|
|
401
182
|
}
|
|
402
183
|
|
|
403
184
|
/**
|
|
@@ -934,12 +715,6 @@ function runIsolated(backend, command, options = {}) {
|
|
|
934
715
|
}
|
|
935
716
|
}
|
|
936
717
|
|
|
937
|
-
/** Reset screen version cache (useful for testing) */
|
|
938
|
-
function resetScreenVersionCache() {
|
|
939
|
-
cachedScreenVersion = null;
|
|
940
|
-
screenVersionChecked = false;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
718
|
const {
|
|
944
719
|
getTimestamp,
|
|
945
720
|
generateLogFilename,
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/** Screen-specific isolation helpers extracted from isolation.js */
|
|
2
|
+
|
|
3
|
+
const { execSync, spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const setTimeout = globalThis.setTimeout;
|
|
9
|
+
|
|
10
|
+
// Debug mode from environment
|
|
11
|
+
const DEBUG =
|
|
12
|
+
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
13
|
+
|
|
14
|
+
// Cache for screen version detection
|
|
15
|
+
let cachedScreenVersion = null;
|
|
16
|
+
let screenVersionChecked = false;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the installed screen version
|
|
20
|
+
* @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
|
|
21
|
+
*/
|
|
22
|
+
function getScreenVersion() {
|
|
23
|
+
if (screenVersionChecked) {
|
|
24
|
+
return cachedScreenVersion;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
screenVersionChecked = true;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const output = execSync('screen --version', {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
// Match patterns like "4.09.01", "4.00.03", "4.5.1"
|
|
35
|
+
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
36
|
+
if (match) {
|
|
37
|
+
cachedScreenVersion = {
|
|
38
|
+
major: parseInt(match[1], 10),
|
|
39
|
+
minor: parseInt(match[2], 10),
|
|
40
|
+
patch: parseInt(match[3], 10),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (DEBUG) {
|
|
44
|
+
console.log(
|
|
45
|
+
`[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return cachedScreenVersion;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
if (DEBUG) {
|
|
53
|
+
console.log('[DEBUG] Could not detect screen version');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if screen supports the -Logfile option
|
|
62
|
+
* The -Logfile option was introduced in GNU Screen 4.5.1
|
|
63
|
+
* @returns {boolean} True if -Logfile is supported
|
|
64
|
+
*/
|
|
65
|
+
function supportsLogfileOption() {
|
|
66
|
+
const version = getScreenVersion();
|
|
67
|
+
if (!version) {
|
|
68
|
+
// If we can't detect version, assume older version and use fallback
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// -Logfile was added in 4.5.1
|
|
73
|
+
// Compare: version >= 4.5.1
|
|
74
|
+
if (version.major > 4) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (version.major < 4) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// major === 4
|
|
81
|
+
if (version.minor > 5) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (version.minor < 5) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// minor === 5
|
|
88
|
+
return version.patch >= 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run command in GNU Screen using detached mode with log capture.
|
|
93
|
+
* Supports screen >= 4.5.1 (native -Logfile) and older versions (tee fallback).
|
|
94
|
+
* @param {string} command - Command to execute
|
|
95
|
+
* @param {string} sessionName - Session name
|
|
96
|
+
* @param {object} shellInfo - Shell info from getShell()
|
|
97
|
+
* @param {string|null} user - Username to run command as (optional)
|
|
98
|
+
* @param {Function} wrapCommandWithUser - Function to wrap command with user
|
|
99
|
+
* @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
|
|
100
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
|
|
101
|
+
*/
|
|
102
|
+
function runScreenWithLogCapture(
|
|
103
|
+
command,
|
|
104
|
+
sessionName,
|
|
105
|
+
shellInfo,
|
|
106
|
+
user = null,
|
|
107
|
+
wrapCommandWithUser,
|
|
108
|
+
isInteractiveShellCommand
|
|
109
|
+
) {
|
|
110
|
+
const { shell, shellArg } = shellInfo;
|
|
111
|
+
const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
|
|
112
|
+
|
|
113
|
+
// Check if screen supports -Logfile option (added in 4.5.1)
|
|
114
|
+
const useNativeLogging = supportsLogfileOption();
|
|
115
|
+
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
try {
|
|
118
|
+
let screenArgs;
|
|
119
|
+
// Wrap command with user switch if specified
|
|
120
|
+
let effectiveCommand = wrapCommandWithUser(command, user);
|
|
121
|
+
|
|
122
|
+
// Temporary screenrc file for native logging path (issue #96)
|
|
123
|
+
// Setting logfile flush 0 forces screen to flush its log buffer after every write,
|
|
124
|
+
// preventing output loss for quick-completing commands like `agent --version`.
|
|
125
|
+
// Without this, screen buffers log writes and flushes every 10 seconds by default.
|
|
126
|
+
let screenrcFile = null;
|
|
127
|
+
|
|
128
|
+
if (useNativeLogging) {
|
|
129
|
+
// Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
|
|
130
|
+
// Use a temporary screenrc with `logfile flush 0` to force immediate log flushing
|
|
131
|
+
// (issue #96: quick commands like `agent --version` lose output without this)
|
|
132
|
+
screenrcFile = path.join(os.tmpdir(), `screenrc-${sessionName}`);
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(screenrcFile, 'logfile flush 0\n');
|
|
135
|
+
} catch {
|
|
136
|
+
// If we can't create the screenrc, proceed without it (best effort)
|
|
137
|
+
screenrcFile = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// screen -dmS <session> -c <screenrc> -L -Logfile <logfile> <shell> -c '<command>'
|
|
141
|
+
const logArgs = screenrcFile
|
|
142
|
+
? ['-dmS', sessionName, '-c', screenrcFile, '-L', '-Logfile', logFile]
|
|
143
|
+
: ['-dmS', sessionName, '-L', '-Logfile', logFile];
|
|
144
|
+
screenArgs = isInteractiveShellCommand(command)
|
|
145
|
+
? [...logArgs, ...command.trim().split(/\s+/)]
|
|
146
|
+
: [...logArgs, shell, shellArg, effectiveCommand];
|
|
147
|
+
|
|
148
|
+
if (DEBUG) {
|
|
149
|
+
console.log(
|
|
150
|
+
`[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
|
|
155
|
+
// The parentheses ensure proper grouping of the command and its stderr
|
|
156
|
+
const isBareShell = isInteractiveShellCommand(command);
|
|
157
|
+
if (!isBareShell) {
|
|
158
|
+
effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
|
|
159
|
+
}
|
|
160
|
+
screenArgs = isBareShell
|
|
161
|
+
? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
|
|
162
|
+
: ['-dmS', sessionName, shell, shellArg, effectiveCommand];
|
|
163
|
+
|
|
164
|
+
if (DEBUG) {
|
|
165
|
+
console.log(
|
|
166
|
+
`[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
|
|
172
|
+
const result = spawnSync('screen', screenArgs, {
|
|
173
|
+
stdio: 'inherit',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (result.error) {
|
|
177
|
+
throw result.error;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Helper to read log file output and write to stdout
|
|
181
|
+
// Includes a short retry for the tee fallback path to handle the TOCTOU race
|
|
182
|
+
// condition where the session appears gone but the log file isn't fully written yet
|
|
183
|
+
// (issue #96)
|
|
184
|
+
const readAndDisplayOutput = (retryCount = 0) => {
|
|
185
|
+
let output = '';
|
|
186
|
+
try {
|
|
187
|
+
output = fs.readFileSync(logFile, 'utf8');
|
|
188
|
+
} catch {
|
|
189
|
+
// Log file might not exist if command produced no output
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If output is empty and we haven't retried yet, wait briefly and retry once.
|
|
193
|
+
// This handles the race where tee's write hasn't been flushed to disk yet
|
|
194
|
+
// when the screen session appears done in `screen -ls` (issue #96).
|
|
195
|
+
if (!output.trim() && retryCount === 0) {
|
|
196
|
+
return new Promise((resolveRetry) => {
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
resolveRetry(readAndDisplayOutput(1));
|
|
199
|
+
}, 50);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Display the output
|
|
204
|
+
if (output.trim()) {
|
|
205
|
+
process.stdout.write(output);
|
|
206
|
+
// Add trailing newline if output doesn't end with one
|
|
207
|
+
if (!output.endsWith('\n')) {
|
|
208
|
+
process.stdout.write('\n');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return Promise.resolve(output);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Clean up temp files
|
|
215
|
+
const cleanupTempFiles = () => {
|
|
216
|
+
try {
|
|
217
|
+
fs.unlinkSync(logFile);
|
|
218
|
+
} catch {
|
|
219
|
+
// Ignore cleanup errors
|
|
220
|
+
}
|
|
221
|
+
if (screenrcFile) {
|
|
222
|
+
try {
|
|
223
|
+
fs.unlinkSync(screenrcFile);
|
|
224
|
+
} catch {
|
|
225
|
+
// Ignore cleanup errors
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Poll for session completion
|
|
231
|
+
const checkInterval = 100; // ms
|
|
232
|
+
const maxWait = 300000; // 5 minutes max
|
|
233
|
+
let waited = 0;
|
|
234
|
+
|
|
235
|
+
const checkCompletion = () => {
|
|
236
|
+
try {
|
|
237
|
+
// Check if session still exists
|
|
238
|
+
const sessions = execSync('screen -ls', {
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (!sessions.includes(sessionName)) {
|
|
244
|
+
// Session ended, read output (with retry for tee path race condition)
|
|
245
|
+
readAndDisplayOutput().then((output) => {
|
|
246
|
+
cleanupTempFiles();
|
|
247
|
+
resolve({
|
|
248
|
+
success: true,
|
|
249
|
+
sessionName,
|
|
250
|
+
message: `Screen session "${sessionName}" exited with code 0`,
|
|
251
|
+
exitCode: 0,
|
|
252
|
+
output,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
waited += checkInterval;
|
|
259
|
+
if (waited >= maxWait) {
|
|
260
|
+
cleanupTempFiles();
|
|
261
|
+
resolve({
|
|
262
|
+
success: false,
|
|
263
|
+
sessionName,
|
|
264
|
+
message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
|
|
265
|
+
exitCode: 1,
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setTimeout(checkCompletion, checkInterval);
|
|
271
|
+
} catch {
|
|
272
|
+
// screen -ls failed, session probably ended
|
|
273
|
+
readAndDisplayOutput().then((output) => {
|
|
274
|
+
cleanupTempFiles();
|
|
275
|
+
resolve({
|
|
276
|
+
success: true,
|
|
277
|
+
sessionName,
|
|
278
|
+
message: `Screen session "${sessionName}" exited with code 0`,
|
|
279
|
+
exitCode: 0,
|
|
280
|
+
output,
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Start checking after a brief delay
|
|
287
|
+
setTimeout(checkCompletion, checkInterval);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
resolve({
|
|
290
|
+
success: false,
|
|
291
|
+
sessionName,
|
|
292
|
+
message: `Failed to run in screen: ${err.message}`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Reset screen version cache (useful for testing) */
|
|
299
|
+
function resetScreenVersionCache() {
|
|
300
|
+
cachedScreenVersion = null;
|
|
301
|
+
screenVersionChecked = false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
getScreenVersion,
|
|
306
|
+
supportsLogfileOption,
|
|
307
|
+
runScreenWithLogCapture,
|
|
308
|
+
resetScreenVersionCache,
|
|
309
|
+
};
|
package/test/isolation.test.js
CHANGED
|
@@ -678,6 +678,39 @@ describe('Isolation Runner with Available Backends', () => {
|
|
|
678
678
|
);
|
|
679
679
|
console.log(` Verified output capture: "${result.output.trim()}"`);
|
|
680
680
|
});
|
|
681
|
+
|
|
682
|
+
it('should capture output from version-flag commands (issue #96)', async () => {
|
|
683
|
+
if (!isCommandAvailable('screen')) {
|
|
684
|
+
console.log(' Skipping: screen not installed');
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// This test verifies that screen isolation captures output from quick-completing
|
|
689
|
+
// commands like `agent --version` or `node --version`.
|
|
690
|
+
// Issue #96: output was silently lost because screen's internal log buffer was
|
|
691
|
+
// not flushed before the session terminated (default 10s flush interval).
|
|
692
|
+
// Fix: use a temporary screenrc with `logfile flush 0` to force immediate flushing.
|
|
693
|
+
const result = await runInScreen('node --version', {
|
|
694
|
+
session: `test-version-flag-${Date.now()}`,
|
|
695
|
+
detached: false,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
assert.strictEqual(result.success, true, 'Command should succeed');
|
|
699
|
+
assert.ok(
|
|
700
|
+
result.output !== undefined,
|
|
701
|
+
'Attached mode should always return output property'
|
|
702
|
+
);
|
|
703
|
+
assert.ok(
|
|
704
|
+
result.output.trim().length > 0,
|
|
705
|
+
'Output should not be empty (issue #96: version output was silently lost)'
|
|
706
|
+
);
|
|
707
|
+
// node --version outputs something like "v20.0.0"
|
|
708
|
+
assert.ok(
|
|
709
|
+
result.output.includes('v') || /\d+\.\d+/.test(result.output),
|
|
710
|
+
'Output should contain version string'
|
|
711
|
+
);
|
|
712
|
+
console.log(` Captured version output: "${result.output.trim()}"`);
|
|
713
|
+
});
|
|
681
714
|
});
|
|
682
715
|
|
|
683
716
|
describe('runInTmux (if available)', () => {
|