start-command 0.36.0 → 0.37.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 +14 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/bin/cli.js +5 -0
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +20 -1
- package/src/lib/command-builder.js +3 -0
- package/src/lib/cpu-penalty-monitor.js +209 -87
- package/src/lib/cpu-penalty.js +30 -1
- package/src/lib/docker-cleanup.js +12 -5
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- package/src/lib/docker-runtime-args.js +8 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +8 -1
- package/src/lib/execution-resume.js +184 -37
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +20 -4
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +4 -3
- package/src/lib/resume-resources.js +15 -3
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/store-lock.js +54 -3
- package/src/lib/usage.js +5 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +4 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +10 -8
- package/test/failure-handler.js +11 -0
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-176.js +7 -0
- package/test/regression-187.js +9 -1
- package/test/regression-191.js +9 -1
- package/test/regression-193.js +11 -1
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-207.js +249 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +77 -0
- package/test/status-query.js +97 -15
package/src/lib/isolation.js
CHANGED
|
@@ -565,6 +565,14 @@ function runInDocker(command, options = {}) {
|
|
|
565
565
|
}
|
|
566
566
|
}
|
|
567
567
|
const containerName = options.session || generateSessionName('docker');
|
|
568
|
+
const attribution = {
|
|
569
|
+
...options,
|
|
570
|
+
containerName,
|
|
571
|
+
sessionId:
|
|
572
|
+
options.sessionId ||
|
|
573
|
+
options.executionId ||
|
|
574
|
+
require('crypto').randomUUID(),
|
|
575
|
+
};
|
|
568
576
|
const containerExistedBeforeLaunch =
|
|
569
577
|
readDockerContainerStatus(containerName) !== null;
|
|
570
578
|
const cleanupPolicy = getDockerContainerCleanupPolicy(options);
|
|
@@ -604,7 +612,7 @@ function runInDocker(command, options = {}) {
|
|
|
604
612
|
dockerArgs.push('--user', options.user);
|
|
605
613
|
}
|
|
606
614
|
|
|
607
|
-
dockerArgs.push(...buildDockerRuntimeArgs(
|
|
615
|
+
dockerArgs.push(...buildDockerRuntimeArgs(attribution));
|
|
608
616
|
|
|
609
617
|
const effectiveCommand = options.keepAlive
|
|
610
618
|
? `${command}; exec ${shellToUse}`
|
|
@@ -614,12 +622,12 @@ function runInDocker(command, options = {}) {
|
|
|
614
622
|
: [shellToUse];
|
|
615
623
|
const mainCmdArgs = isBareShell
|
|
616
624
|
? toShellWords(command)
|
|
617
|
-
: isShellInvocationWithArgs(
|
|
625
|
+
: isShellInvocationWithArgs(effectiveCommand)
|
|
618
626
|
? buildShellWithArgsCmdArgs(effectiveCommand)
|
|
619
627
|
: [...shellArgs, '-c', effectiveCommand];
|
|
620
628
|
// A recovery command is selected by a marker that `docker cp` drops
|
|
621
629
|
// into the container before it is started again (issue #176).
|
|
622
|
-
const
|
|
630
|
+
const recoveryArgs = options.recoveryCommand
|
|
623
631
|
? buildRecoverySelectorArgs(mainCmdArgs, {
|
|
624
632
|
shell: shellToUse,
|
|
625
633
|
shellFlag: shellInteractiveFlag,
|
|
@@ -628,6 +636,14 @@ function runInDocker(command, options = {}) {
|
|
|
628
636
|
: options.recoveryCommand,
|
|
629
637
|
})
|
|
630
638
|
: mainCmdArgs;
|
|
639
|
+
const {
|
|
640
|
+
buildCommandHandoffArgs,
|
|
641
|
+
commandHandoffPath,
|
|
642
|
+
} = require('./docker-command-handoff');
|
|
643
|
+
const cmdArgs = buildCommandHandoffArgs(
|
|
644
|
+
recoveryArgs,
|
|
645
|
+
commandHandoffPath(containerName)
|
|
646
|
+
);
|
|
631
647
|
dockerArgs.push(options.image, ...cmdArgs);
|
|
632
648
|
|
|
633
649
|
if (DEBUG) {
|
|
@@ -717,7 +733,7 @@ function runInDocker(command, options = {}) {
|
|
|
717
733
|
if (options.user) {
|
|
718
734
|
dockerArgs.push('--user', options.user);
|
|
719
735
|
}
|
|
720
|
-
dockerArgs.push(...buildDockerRuntimeArgs(
|
|
736
|
+
dockerArgs.push(...buildDockerRuntimeArgs(attribution));
|
|
721
737
|
if (DEBUG) {
|
|
722
738
|
console.log(`[DEBUG] shell: ${shellToUse}`);
|
|
723
739
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** Bounded byte-wise sanitization for logs prepared for publication. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const BLOCK_SIZE = 64 * 1024;
|
|
7
|
+
const MARKER = '[REDACTED]';
|
|
8
|
+
const SECRET_ENV_NAME =
|
|
9
|
+
/(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)/i;
|
|
10
|
+
|
|
11
|
+
// Prefixes intentionally match even truncated tokens. Unbounded values retain a
|
|
12
|
+
// continuation state, so a token or header larger than a block cannot leak its
|
|
13
|
+
// suffix. Latin-1 provides a reversible byte mapping, including invalid UTF-8.
|
|
14
|
+
const RULES = [
|
|
15
|
+
{
|
|
16
|
+
pattern:
|
|
17
|
+
/\b[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{0,512}/g,
|
|
18
|
+
validate: (value) => {
|
|
19
|
+
try {
|
|
20
|
+
return (
|
|
21
|
+
typeof JSON.parse(
|
|
22
|
+
Buffer.from(value.split('.')[0], 'base64url').toString()
|
|
23
|
+
).alg === 'string'
|
|
24
|
+
);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
continuation: /[A-Za-z0-9_.-]/,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
pattern: /[A-Za-z0-9_-]{512,}[A-Za-z0-9_.-]*/g,
|
|
33
|
+
continuation: /[A-Za-z0-9_.-]/,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: /(?:gh[pousr]_|github_pat_|sk-)[A-Za-z0-9_-]*/g,
|
|
37
|
+
continuation: /[A-Za-z0-9_-]/,
|
|
38
|
+
},
|
|
39
|
+
{ pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/g },
|
|
40
|
+
{ pattern: /eyJ[A-Za-z0-9_.-]*/g, continuation: /[A-Za-z0-9_.-]/ },
|
|
41
|
+
{
|
|
42
|
+
pattern: /(?:Proxy-)?Authorization[ \t]{0,32}:[^\r\n]*/gi,
|
|
43
|
+
continuation: /[^\r\n]/,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
pattern:
|
|
47
|
+
/(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)[ \t]{0,32}[=:][ \t]*["']?[^\s"';,]*/g,
|
|
48
|
+
continuation: /[^\s"';,]/,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
pattern: /(?:https?|git|ssh):\/\/[^\s:/@]{1,256}:[^\s@]*/g,
|
|
52
|
+
continuation: /[^\s@]/,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
class StreamingSanitizer {
|
|
57
|
+
constructor(env = process.env) {
|
|
58
|
+
this.secrets = [
|
|
59
|
+
...new Set(
|
|
60
|
+
Object.entries(env)
|
|
61
|
+
.filter(
|
|
62
|
+
([name, value]) =>
|
|
63
|
+
SECRET_ENV_NAME.test(name) &&
|
|
64
|
+
typeof value === 'string' &&
|
|
65
|
+
value.length
|
|
66
|
+
)
|
|
67
|
+
.map(([, value]) => Buffer.from(value).toString('latin1'))
|
|
68
|
+
),
|
|
69
|
+
].sort((a, b) => b.length - a.length);
|
|
70
|
+
if (this.secrets.some((value) => value.length > BLOCK_SIZE)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'Known environment secret is too large for bounded sanitization.'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
this.holdback = this.secrets.reduce(
|
|
76
|
+
(size, value) => Math.max(size, value.length),
|
|
77
|
+
2048
|
|
78
|
+
);
|
|
79
|
+
this.pending = '';
|
|
80
|
+
this.continuation = null;
|
|
81
|
+
this.redactions = 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get pendingBytes() {
|
|
85
|
+
return this.pending.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
findMatches(input) {
|
|
89
|
+
const matches = [];
|
|
90
|
+
for (const rule of RULES) {
|
|
91
|
+
rule.pattern.lastIndex = 0;
|
|
92
|
+
for (
|
|
93
|
+
let match = rule.pattern.exec(input);
|
|
94
|
+
match;
|
|
95
|
+
match = rule.pattern.exec(input)
|
|
96
|
+
) {
|
|
97
|
+
if (!rule.validate || rule.validate(match[0])) {
|
|
98
|
+
matches.push({
|
|
99
|
+
index: match.index,
|
|
100
|
+
end: match.index + match[0].length,
|
|
101
|
+
continuation: rule.continuation,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const secret of this.secrets) {
|
|
107
|
+
for (
|
|
108
|
+
let index = input.indexOf(secret);
|
|
109
|
+
index >= 0;
|
|
110
|
+
index = input.indexOf(secret, index + 1)
|
|
111
|
+
) {
|
|
112
|
+
matches.push({ index, end: index + secret.length });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
matches.sort((a, b) => a.index - b.index || b.end - a.end);
|
|
116
|
+
const merged = [];
|
|
117
|
+
for (const match of matches) {
|
|
118
|
+
const previous = merged[merged.length - 1];
|
|
119
|
+
if (previous && match.index <= previous.end) {
|
|
120
|
+
if (match.end > previous.end) {
|
|
121
|
+
previous.end = match.end;
|
|
122
|
+
previous.continuation = match.continuation;
|
|
123
|
+
} else if (match.end === previous.end && match.continuation) {
|
|
124
|
+
// Nested tokens must not narrow a containing header/URL's suffix.
|
|
125
|
+
const retained = previous.continuation;
|
|
126
|
+
const additional = match.continuation;
|
|
127
|
+
previous.continuation = retained
|
|
128
|
+
? { test: (byte) => retained.test(byte) || additional.test(byte) }
|
|
129
|
+
: additional;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
merged.push({ ...match });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
push(block, final = false) {
|
|
139
|
+
let input = this.pending + block.toString('latin1');
|
|
140
|
+
this.pending = '';
|
|
141
|
+
if (this.continuation) {
|
|
142
|
+
let end = 0;
|
|
143
|
+
while (end < input.length && this.continuation.test(input[end])) {
|
|
144
|
+
end++;
|
|
145
|
+
}
|
|
146
|
+
input = input.slice(end);
|
|
147
|
+
if (!input.length && !final) {
|
|
148
|
+
return Buffer.alloc(0);
|
|
149
|
+
}
|
|
150
|
+
this.continuation = null;
|
|
151
|
+
}
|
|
152
|
+
const safeEnd = final
|
|
153
|
+
? input.length
|
|
154
|
+
: Math.max(0, input.length - this.holdback);
|
|
155
|
+
let offset = 0;
|
|
156
|
+
const output = [];
|
|
157
|
+
for (const match of this.findMatches(input)) {
|
|
158
|
+
if (match.index >= safeEnd) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
output.push(input.slice(offset, match.index), MARKER);
|
|
162
|
+
this.redactions++;
|
|
163
|
+
offset = match.end;
|
|
164
|
+
if (offset === input.length && match.continuation && !final) {
|
|
165
|
+
this.continuation = match.continuation;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const end = Math.max(offset, safeEnd);
|
|
170
|
+
output.push(input.slice(offset, end));
|
|
171
|
+
this.pending = input.slice(end);
|
|
172
|
+
return Buffer.from(output.join(''), 'latin1');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Returns a private copy plus cleanup; every preparation error blocks upload. */
|
|
177
|
+
function sanitizeLogToTemp(sourcePath, options = {}) {
|
|
178
|
+
let directory;
|
|
179
|
+
let source;
|
|
180
|
+
let destination;
|
|
181
|
+
let ready = false;
|
|
182
|
+
let stage = 'environment';
|
|
183
|
+
try {
|
|
184
|
+
const sanitizer = new StreamingSanitizer(options.env || process.env);
|
|
185
|
+
stage = 'source';
|
|
186
|
+
source = fs.openSync(sourcePath, 'r');
|
|
187
|
+
if (!fs.fstatSync(source).isFile()) {
|
|
188
|
+
throw new Error('Source must be a regular file.');
|
|
189
|
+
}
|
|
190
|
+
stage = 'private-file';
|
|
191
|
+
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'start-sanitized-'));
|
|
192
|
+
fs.chmodSync(directory, 0o700);
|
|
193
|
+
const target = path.join(directory, 'execution.log');
|
|
194
|
+
destination = fs.openSync(target, 'wx', 0o600);
|
|
195
|
+
fs.fchmodSync(destination, 0o600);
|
|
196
|
+
const buffer = Buffer.alloc(BLOCK_SIZE);
|
|
197
|
+
let bytes = 0;
|
|
198
|
+
const write = (output) => {
|
|
199
|
+
stage = 'write';
|
|
200
|
+
let offset = 0;
|
|
201
|
+
while (offset < output.length) {
|
|
202
|
+
const written = fs.writeSync(
|
|
203
|
+
destination,
|
|
204
|
+
output,
|
|
205
|
+
offset,
|
|
206
|
+
output.length - offset
|
|
207
|
+
);
|
|
208
|
+
if (!written) {
|
|
209
|
+
throw new Error('No progress writing sanitized log.');
|
|
210
|
+
}
|
|
211
|
+
offset += written;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
for (;;) {
|
|
215
|
+
stage = 'read';
|
|
216
|
+
const length = fs.readSync(source, buffer, 0, buffer.length, null);
|
|
217
|
+
if (!length) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
bytes += length;
|
|
221
|
+
stage = 'scan';
|
|
222
|
+
write(sanitizer.push(buffer.subarray(0, length)));
|
|
223
|
+
}
|
|
224
|
+
write(sanitizer.push(Buffer.alloc(0), true));
|
|
225
|
+
stage = 'sync';
|
|
226
|
+
fs.fsyncSync(destination);
|
|
227
|
+
fs.closeSync(destination);
|
|
228
|
+
destination = undefined;
|
|
229
|
+
fs.closeSync(source);
|
|
230
|
+
source = undefined;
|
|
231
|
+
if (options.verbose) {
|
|
232
|
+
console.log(
|
|
233
|
+
`Prepared private sanitized log: ${bytes} bytes scanned, ${sanitizer.redactions} redactions.`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
ready = true;
|
|
237
|
+
return {
|
|
238
|
+
path: target,
|
|
239
|
+
cleanup: () => fs.rmSync(directory, { recursive: true, force: true }),
|
|
240
|
+
};
|
|
241
|
+
} catch {
|
|
242
|
+
if (options.verbose) {
|
|
243
|
+
console.log(`Log sanitization failed at stage: ${stage}.`);
|
|
244
|
+
}
|
|
245
|
+
// Do not echo error messages: paths and exception details can carry secrets.
|
|
246
|
+
throw new Error('Log sanitization failed; upload blocked.');
|
|
247
|
+
} finally {
|
|
248
|
+
for (const fd of [destination, source]) {
|
|
249
|
+
if (fd !== undefined) {
|
|
250
|
+
try {
|
|
251
|
+
fs.closeSync(fd);
|
|
252
|
+
} catch {
|
|
253
|
+
/* Best effort after failure. */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (directory && !ready) {
|
|
258
|
+
try {
|
|
259
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
260
|
+
} catch {
|
|
261
|
+
/* Upload remains blocked. */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { StreamingSanitizer, sanitizeLogToTemp };
|
package/src/lib/log-uploader.js
CHANGED
|
@@ -6,6 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { spawnSync } = require('child_process');
|
|
9
|
+
const { sanitizeLogToTemp } = require('./log-sanitizer');
|
|
9
10
|
|
|
10
11
|
function isExecutable(filePath) {
|
|
11
12
|
try {
|
|
@@ -135,7 +136,7 @@ function ensureGhUploadLogAvailable() {
|
|
|
135
136
|
};
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
function uploadLogPath(logPath) {
|
|
139
|
+
function uploadLogPath(logPath, options = {}) {
|
|
139
140
|
if (!logPath) {
|
|
140
141
|
return {
|
|
141
142
|
success: false,
|
|
@@ -146,29 +147,47 @@ function uploadLogPath(logPath) {
|
|
|
146
147
|
return { success: false, error: `Log file not found: ${logPath}` };
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
150
|
+
let prepared;
|
|
151
|
+
try {
|
|
152
|
+
if (!options.noSanitize) {
|
|
153
|
+
// Wrapper options.env holds Docker arguments, not the process secrets.
|
|
154
|
+
prepared = sanitizeLogToTemp(logPath, { verbose: options.verbose });
|
|
155
|
+
}
|
|
156
|
+
const availability = ensureGhUploadLogAvailable();
|
|
157
|
+
if (!availability.success) {
|
|
158
|
+
return availability;
|
|
159
|
+
}
|
|
160
|
+
const result = runCommand(
|
|
161
|
+
availability.command,
|
|
162
|
+
[prepared ? prepared.path : logPath, '--private'],
|
|
163
|
+
{
|
|
164
|
+
stdio: 'inherit',
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
const exitCode =
|
|
168
|
+
result.status !== null && result.status !== undefined ? result.status : 1;
|
|
169
|
+
if (exitCode !== 0) {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
exitCode,
|
|
173
|
+
error: `gh-upload-log exited with code ${exitCode}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { success: true, exitCode: 0 };
|
|
177
|
+
} catch {
|
|
161
178
|
return {
|
|
162
179
|
success: false,
|
|
163
|
-
exitCode,
|
|
164
|
-
error:
|
|
180
|
+
exitCode: 1,
|
|
181
|
+
error: 'Log preparation or upload failed; upload blocked.',
|
|
165
182
|
};
|
|
183
|
+
} finally {
|
|
184
|
+
if (prepared) {
|
|
185
|
+
prepared.cleanup();
|
|
186
|
+
}
|
|
166
187
|
}
|
|
167
|
-
|
|
168
|
-
return { success: true, exitCode: 0 };
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
function uploadExecutionLog(store, identifier) {
|
|
190
|
+
function uploadExecutionLog(store, identifier, options = {}) {
|
|
172
191
|
if (!store) {
|
|
173
192
|
return { success: false, error: 'Execution tracking is disabled.' };
|
|
174
193
|
}
|
|
@@ -181,7 +200,7 @@ function uploadExecutionLog(store, identifier) {
|
|
|
181
200
|
};
|
|
182
201
|
}
|
|
183
202
|
|
|
184
|
-
return uploadLogPath(record.logPath);
|
|
203
|
+
return uploadLogPath(record.logPath, options);
|
|
185
204
|
}
|
|
186
205
|
|
|
187
206
|
module.exports = {
|
|
@@ -38,8 +38,8 @@ function handleListQuery(store, outputFormat, options = {}) {
|
|
|
38
38
|
return reportResult(listExecutions(store, outputFormat, options));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function handleUploadLogQuery(store, identifier) {
|
|
42
|
-
const result = uploadExecutionLog(store, identifier);
|
|
41
|
+
function handleUploadLogQuery(store, identifier, options = {}) {
|
|
42
|
+
const result = uploadExecutionLog(store, identifier, options);
|
|
43
43
|
if (result.success) {
|
|
44
44
|
return result.exitCode || 0;
|
|
45
45
|
}
|
|
@@ -67,6 +67,7 @@ async function handleResumeQuery(
|
|
|
67
67
|
command,
|
|
68
68
|
outputFormat,
|
|
69
69
|
resourceOptions,
|
|
70
|
+
removeOriginal: resourceOptions.removeOriginal === true,
|
|
70
71
|
})
|
|
71
72
|
);
|
|
72
73
|
}
|
|
@@ -164,7 +165,7 @@ async function dispatchQueryCommand(options, context = {}) {
|
|
|
164
165
|
return handleListQuery(store, outputFormat, { running: options.running });
|
|
165
166
|
}
|
|
166
167
|
if (hasValue(options.uploadLog)) {
|
|
167
|
-
return handleUploadLogQuery(store, options.uploadLog);
|
|
168
|
+
return handleUploadLogQuery(store, options.uploadLog, options);
|
|
168
169
|
}
|
|
169
170
|
if (hasValue(options.stop)) {
|
|
170
171
|
return handleControlQuery(store, options.stop, ControlAction.STOP);
|
|
@@ -7,13 +7,23 @@ const {
|
|
|
7
7
|
const { resolveResourceOptions } = require('./docker-resource-options');
|
|
8
8
|
|
|
9
9
|
// Temporary penalty quotas must never become a resumed execution's base quota.
|
|
10
|
-
function restoreBaseCpu(limits, options) {
|
|
10
|
+
function restoreBaseCpu(limits, options, saved = null, inspected = false) {
|
|
11
11
|
if (!options.cpuPenaltyConfig) {
|
|
12
12
|
return limits;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
if (
|
|
15
|
+
saved &&
|
|
16
|
+
inspected &&
|
|
17
|
+
!(saved.phase === 'penalized' && cpuCount(limits) === saved.limitCpus)
|
|
18
|
+
) {
|
|
19
|
+
return limits;
|
|
20
|
+
}
|
|
21
|
+
let base = normalizeResourceLimits(
|
|
15
22
|
options.baseResourceLimits || options.resourceLimits
|
|
16
23
|
);
|
|
24
|
+
if (saved && Object.hasOwn(saved, 'baseCpus')) {
|
|
25
|
+
base = saved.baseCpus > 0 ? withCpuLimit(limits, saved.baseCpus) : [];
|
|
26
|
+
}
|
|
17
27
|
return [
|
|
18
28
|
...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
19
29
|
...base.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
@@ -38,7 +48,9 @@ function prepareResumeResources(record, probe, overrides, runner, random) {
|
|
|
38
48
|
: null;
|
|
39
49
|
const base = restoreBaseCpu(
|
|
40
50
|
normalizeResourceLimits(live || options.resourceLimits),
|
|
41
|
-
options
|
|
51
|
+
options,
|
|
52
|
+
require('./cpu-penalty-monitor').readState(record),
|
|
53
|
+
live !== null
|
|
42
54
|
);
|
|
43
55
|
const oldMemory = base.find((s) => s.startsWith('--memory='))?.slice(9);
|
|
44
56
|
const result = resolveResourceOptions(
|
package/src/lib/shell-utils.js
CHANGED
|
@@ -86,6 +86,10 @@ function splitShellWords(command, style = defaultShellQuotingStyle()) {
|
|
|
86
86
|
if (i + 1 >= command.length) {
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
|
+
if (quote === '"' && !'$`"\\\n'.includes(command[i + 1])) {
|
|
90
|
+
current += char;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
89
93
|
current += command[++i];
|
|
90
94
|
continue;
|
|
91
95
|
}
|
|
@@ -121,10 +125,69 @@ function toShellWords(command) {
|
|
|
121
125
|
);
|
|
122
126
|
}
|
|
123
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Return literal argv only when no outer shell evaluation is needed. Operators,
|
|
130
|
+
* expansions and comments belong to the outer command line, not the -c script.
|
|
131
|
+
* Single quotes protect their contents; double quotes still permit expansion.
|
|
132
|
+
*/
|
|
133
|
+
function simpleShellWords(command) {
|
|
134
|
+
let quote = null;
|
|
135
|
+
for (let i = 0; i < command.length; i++) {
|
|
136
|
+
const char = command[i];
|
|
137
|
+
if (char === '\\' && quote !== "'") {
|
|
138
|
+
if (command[i + 1] === '\n') {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
if (quote !== '"' || '$`"\\\n'.includes(command[i + 1])) {
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (quote === "'") {
|
|
147
|
+
if (char === "'") {
|
|
148
|
+
quote = null;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (char === '$' || char === '`') {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (quote === '"') {
|
|
156
|
+
if (char === '"') {
|
|
157
|
+
quote = null;
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (';&|<>(){}\n\r*?[]~#'.includes(char)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (/\s/.test(char) && !' \t'.includes(char)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (char === "'" || char === '"') {
|
|
168
|
+
quote = char;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return splitShellWords(command, 'posix');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function shellScriptIndex(parts) {
|
|
175
|
+
if (!parts || !SHELL_NAMES.includes(path.basename(parts[0] || ''))) {
|
|
176
|
+
return -1;
|
|
177
|
+
}
|
|
178
|
+
const index = parts.indexOf('-c');
|
|
179
|
+
return index > 0 &&
|
|
180
|
+
index + 1 < parts.length &&
|
|
181
|
+
parts.slice(1, index).every((part) => /^--?[a-zA-Z][a-zA-Z-]*$/.test(part))
|
|
182
|
+
? index
|
|
183
|
+
: -1;
|
|
184
|
+
}
|
|
185
|
+
|
|
124
186
|
/** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
|
|
125
187
|
function isInteractiveShellCommand(command) {
|
|
126
|
-
const parts =
|
|
188
|
+
const parts = simpleShellWords(command);
|
|
127
189
|
return (
|
|
190
|
+
parts !== null &&
|
|
128
191
|
parts.length > 0 &&
|
|
129
192
|
SHELL_NAMES.includes(path.basename(parts[0])) &&
|
|
130
193
|
!parts.includes('-c')
|
|
@@ -133,25 +196,12 @@ function isInteractiveShellCommand(command) {
|
|
|
133
196
|
|
|
134
197
|
/** True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91). */
|
|
135
198
|
function isShellInvocationWithArgs(command) {
|
|
136
|
-
|
|
137
|
-
return (
|
|
138
|
-
parts.length > 0 &&
|
|
139
|
-
SHELL_NAMES.includes(path.basename(parts[0])) &&
|
|
140
|
-
parts.includes('-c')
|
|
141
|
-
);
|
|
199
|
+
return shellScriptIndex(simpleShellWords(command)) !== -1;
|
|
142
200
|
}
|
|
143
201
|
|
|
144
|
-
/**
|
|
202
|
+
/** Preserve the script word and every positional argument as separate argv. */
|
|
145
203
|
function buildShellWithArgsCmdArgs(command) {
|
|
146
|
-
|
|
147
|
-
const cIdx = parts.indexOf('-c');
|
|
148
|
-
if (cIdx === -1) {
|
|
149
|
-
return parts;
|
|
150
|
-
}
|
|
151
|
-
const scriptArg = parts.slice(cIdx + 1).join(' ');
|
|
152
|
-
return scriptArg.length > 0
|
|
153
|
-
? [...parts.slice(0, cIdx + 1), scriptArg]
|
|
154
|
-
: parts.slice(0, cIdx + 1);
|
|
204
|
+
return simpleShellWords(command) || toShellWords(command);
|
|
155
205
|
}
|
|
156
206
|
|
|
157
207
|
/** Quote an argument for display only, keeping the user-facing double-quoted form (issue #91). */
|