start-command 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +6 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +5 -0
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +20 -1
  7. package/src/lib/command-builder.js +3 -0
  8. package/src/lib/docker-cleanup.js +12 -5
  9. package/src/lib/docker-command-handoff.js +75 -0
  10. package/src/lib/docker-labels.js +33 -0
  11. package/src/lib/docker-runtime-args.js +8 -0
  12. package/src/lib/docker-snapshot-safety.js +164 -0
  13. package/src/lib/execution-recovery.js +8 -1
  14. package/src/lib/execution-resume.js +184 -37
  15. package/src/lib/failure-handler.js +15 -5
  16. package/src/lib/isolation.js +20 -4
  17. package/src/lib/log-sanitizer.js +267 -0
  18. package/src/lib/log-uploader.js +38 -19
  19. package/src/lib/query-commands.js +4 -3
  20. package/src/lib/shell-utils.js +67 -17
  21. package/src/lib/usage.js +5 -1
  22. package/test/ci-workflow-invariants.js +15 -14
  23. package/test/docker-command-handoff.js +107 -0
  24. package/test/docker-runtime-options.js +4 -0
  25. package/test/docker-snapshot-safety.js +106 -0
  26. package/test/execution-resume.js +10 -8
  27. package/test/failure-handler.js +11 -0
  28. package/test/javascript-first-ci.js +216 -0
  29. package/test/log-sanitizer.js +306 -0
  30. package/test/regression-176.js +7 -0
  31. package/test/regression-187.js +9 -1
  32. package/test/regression-191.js +9 -1
  33. package/test/regression-193.js +11 -1
  34. package/test/regression-198.js +281 -0
  35. package/test/regression-199.js +79 -0
  36. package/test/regression-201.js +25 -0
  37. package/test/regression-202.js +92 -0
  38. package/test/regression-91.js +23 -20
  39. package/test/shared-golden.js +54 -0
  40. package/test/status-query.js +97 -15
@@ -0,0 +1,216 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { readdirSync, readFileSync } = require('node:fs');
4
+ const { resolve } = require('node:path');
5
+ const { YAML } = require('bun');
6
+ const { spawnSync } = require('node:child_process');
7
+ const {
8
+ checkJavaScriptFirst,
9
+ } = require('../../scripts/check-javascript-first.mjs');
10
+ const {
11
+ checkFeatureChanges,
12
+ } = require('../../scripts/check-feature-parity.mjs');
13
+
14
+ const root = resolve(__dirname, '../..');
15
+ function workflows() {
16
+ return Object.fromEntries(
17
+ readdirSync(resolve(root, '.github/workflows'))
18
+ .filter((name) => name.endsWith('.yml'))
19
+ .map((name) => [
20
+ name,
21
+ YAML.parse(
22
+ readFileSync(resolve(root, '.github/workflows', name), 'utf8')
23
+ ),
24
+ ])
25
+ );
26
+ }
27
+
28
+ describe('JavaScript first CI gate', () => {
29
+ it('validates every current workflow using parsed YAML', () => {
30
+ assert.deepEqual(checkJavaScriptFirst(workflows()), []);
31
+ });
32
+
33
+ for (const [name, mutate] of [
34
+ [
35
+ 'independent Rust PR trigger',
36
+ (files) => {
37
+ files['rust.yml'].on.pull_request = {};
38
+ },
39
+ ],
40
+ [
41
+ 'removed successful-stage condition',
42
+ (files) => {
43
+ files['js.yml'].jobs['rust-stage'].if = 'always()';
44
+ },
45
+ ],
46
+ [
47
+ 'an alternative that bypasses the successful stage',
48
+ (files) => {
49
+ files['js.yml'].jobs['rust-stage'].if =
50
+ "${{ !cancelled() && needs.pipeline-status.result == 'success' || true }}";
51
+ },
52
+ ],
53
+ [
54
+ 'Rust-only path filter',
55
+ (files) => {
56
+ files['js.yml'].on.pull_request.paths = ['js/**'];
57
+ },
58
+ ],
59
+ [
60
+ 'Rust security matrix bypass',
61
+ (files) => {
62
+ files['security.yml'].jobs.codeql.strategy.matrix.language.push('rust');
63
+ },
64
+ ],
65
+ [
66
+ 'direct Rust CodeQL bypass',
67
+ (files) => {
68
+ files['new.yml'] = {
69
+ jobs: {
70
+ scan: {
71
+ steps: [
72
+ {
73
+ uses: 'github/codeql-action/init@v4',
74
+ with: { languages: 'rust' },
75
+ },
76
+ ],
77
+ },
78
+ },
79
+ };
80
+ },
81
+ ],
82
+ [
83
+ 'comma-delimited Rust CodeQL bypass',
84
+ (files) => {
85
+ files['new.yml'] = {
86
+ jobs: {
87
+ scan: {
88
+ steps: [
89
+ {
90
+ uses: 'github/codeql-action/init@v4',
91
+ with: { languages: 'javascript-typescript,rust' },
92
+ },
93
+ ],
94
+ },
95
+ },
96
+ };
97
+ },
98
+ ],
99
+ [
100
+ 'Rust CodeQL matrix include bypass',
101
+ (files) => {
102
+ files['security.yml'].jobs.codeql.strategy.matrix = {
103
+ include: [{ language: 'rust' }],
104
+ };
105
+ },
106
+ ],
107
+ [
108
+ 'independent Rustup tooling',
109
+ (files) => {
110
+ files['new.yml'] = {
111
+ jobs: {
112
+ install: {
113
+ steps: [{ run: 'rustup toolchain install stable' }],
114
+ },
115
+ },
116
+ };
117
+ },
118
+ ],
119
+ [
120
+ 'release key outside main environment',
121
+ (files) => {
122
+ delete files['js.yml'].jobs.release.environment;
123
+ },
124
+ ],
125
+ [
126
+ 'a new ungated Rust workflow',
127
+ (files) => {
128
+ files['new.yml'] = {
129
+ on: { push: {} },
130
+ jobs: { build: { steps: [{ run: 'cargo build' }] } },
131
+ };
132
+ },
133
+ ],
134
+ [
135
+ 'conditional parity',
136
+ (files) => {
137
+ files['js.yml'].jobs.parity.if = 'false';
138
+ },
139
+ ],
140
+ ]) {
141
+ it(`rejects ${name}`, () => {
142
+ const files = workflows();
143
+ mutate(files);
144
+ assert.ok(checkJavaScriptFirst(files).length > 0);
145
+ });
146
+ }
147
+
148
+ for (const result of ['failure', 'cancelled', 'skipped']) {
149
+ it(`rejects a ${result} required JavaScript check`, () => {
150
+ const check = spawnSync(
151
+ process.execPath,
152
+ [resolve(root, 'scripts/check-stage-status.mjs'), '--required', 'test'],
153
+ {
154
+ env: {
155
+ ...process.env,
156
+ NEEDS_JSON: JSON.stringify({ test: { result } }),
157
+ },
158
+ }
159
+ );
160
+ assert.equal(check.status, 1);
161
+ });
162
+ }
163
+
164
+ it('opens the gate only after all required checks succeed', () => {
165
+ const check = spawnSync(
166
+ process.execPath,
167
+ [resolve(root, 'scripts/check-stage-status.mjs'), '--required', 'test'],
168
+ {
169
+ env: {
170
+ ...process.env,
171
+ NEEDS_JSON: JSON.stringify({
172
+ test: { result: 'success' },
173
+ release: { result: 'skipped' },
174
+ }),
175
+ },
176
+ }
177
+ );
178
+ assert.equal(check.status, 0);
179
+ });
180
+
181
+ it('rejects a change to only one implementation of a feature', () => {
182
+ const features = [
183
+ {
184
+ id: 'sample',
185
+ javascript: { implementation: ['js/source.js'], tests: ['js/test.js'] },
186
+ rust: { implementation: ['rust/source.rs'], tests: ['rust/test.rs'] },
187
+ },
188
+ ];
189
+ assert.equal(checkFeatureChanges(features, ['rust/source.rs']).length, 1);
190
+ assert.equal(checkFeatureChanges(features, ['js/source.js']).length, 1);
191
+ assert.deepEqual(
192
+ checkFeatureChanges(features, ['js/source.js', 'rust/test.rs']),
193
+ []
194
+ );
195
+ });
196
+
197
+ it('makes Rust callable only after the complete JavaScript stage', () => {
198
+ const files = workflows();
199
+ assert.deepEqual(Object.keys(files['rust.yml'].on), ['workflow_call']);
200
+ const call = files['js.yml'].jobs['rust-stage'];
201
+ assert.equal(call.uses, './.github/workflows/rust.yml');
202
+ assert.deepEqual(call.needs, ['pipeline-status']);
203
+ assert.match(call.if, /needs\.pipeline-status\.result == 'success'/);
204
+ assert.equal(call.concurrency['cancel-in-progress'], false);
205
+ });
206
+
207
+ it('always runs JavaScript and its behavioral parity gate on Rust-only changes', () => {
208
+ const js = workflows()['js.yml'];
209
+ for (const trigger of ['push', 'pull_request']) {
210
+ assert.equal(js.on[trigger].paths, undefined);
211
+ assert.equal(js.on[trigger]['paths-ignore'], undefined);
212
+ }
213
+ assert.ok(js.jobs.parity);
214
+ assert.equal(js.jobs.parity.if, undefined);
215
+ });
216
+ });
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env bun
2
+ const { describe, it } = require('node:test');
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { execFileSync } = require('child_process');
8
+ const {
9
+ sanitizeLogToTemp,
10
+ StreamingSanitizer,
11
+ } = require('../src/lib/log-sanitizer');
12
+
13
+ function credentials() {
14
+ return [
15
+ ...'pousr'.split('').map((kind) => `gh${kind}_${'a'.repeat(36)}`),
16
+ `github_` + `pat_${'b'.repeat(82)}`,
17
+ `sk-${'c'.repeat(48)}`,
18
+ `sk-` + `proj-${'d'.repeat(90)}`,
19
+ `sk-` + `ant-api03-${'e'.repeat(90)}`,
20
+ `AK` + `IA${'F'.repeat(16)}`,
21
+ `AS` + `IA${'G'.repeat(16)}`,
22
+ 'ey' + 'JhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature',
23
+ 'Authorization: Bearer arbitrary-credential',
24
+ 'Proxy-Authorization: Basic encoded-credential',
25
+ 'https://user:password@example.test/repo.git',
26
+ 'AWS_SECRET_ACCESS_KEY=arbitrary-aws-secret',
27
+ 'custom-secret-value',
28
+ `${Buffer.from(' \n{"alg":"HS256"}').toString('base64url')}.${Buffer.from('{"sub":"x"}').toString('base64url')}.signature`,
29
+ ];
30
+ }
31
+
32
+ function sanitize(input, blockSize) {
33
+ const sanitizer = new StreamingSanitizer({
34
+ START_TEST_SECRET: 'custom-secret-value',
35
+ });
36
+ const blocks = [];
37
+ for (let offset = 0; offset < input.length; offset += blockSize) {
38
+ blocks.push(sanitizer.push(input.subarray(offset, offset + blockSize)));
39
+ }
40
+ blocks.push(sanitizer.push(Buffer.alloc(0), true));
41
+ return Buffer.concat(blocks);
42
+ }
43
+
44
+ describe('log sanitization', () => {
45
+ it('redacts required formats and environment values at every chunk boundary', () => {
46
+ const secrets = credentials();
47
+ for (const size of [1, 7, 31, 256, 1024, 65536]) {
48
+ const input = Buffer.from(
49
+ `ordinary utf8: café 😀\n${' '.repeat(Math.max(0, size - 43))}${secrets.join('\n')}\n${'padding\n'.repeat(400)}finished\n`
50
+ );
51
+ const output = sanitize(input, size).toString();
52
+ for (const secret of secrets) {
53
+ assert.ok(!output.includes(secret));
54
+ }
55
+ for (const value of [
56
+ 'arbitrary-credential',
57
+ 'encoded-credential',
58
+ 'password',
59
+ 'arbitrary-aws-secret',
60
+ ]) {
61
+ assert.ok(!output.includes(value));
62
+ }
63
+ assert.ok(output.startsWith('ordinary utf8: café 😀\n'));
64
+ assert.ok(output.endsWith('\nfinished\n'));
65
+ }
66
+ });
67
+
68
+ it('bounds memory for long tokens and authorization values without releasing suffixes', () => {
69
+ for (const prefix of ['gh' + 'p_', 'Authorization: Bearer ']) {
70
+ const sanitizer = new StreamingSanitizer({});
71
+ let output = sanitizer.push(Buffer.from(prefix));
72
+ for (let i = 0; i < 128; i++) {
73
+ output = Buffer.concat([
74
+ output,
75
+ sanitizer.push(Buffer.alloc(16384, 97)),
76
+ ]);
77
+ assert.ok(sanitizer.pendingBytes <= 65536);
78
+ }
79
+ output = Buffer.concat([
80
+ output,
81
+ sanitizer.push(Buffer.from('\nordinary\n'), true),
82
+ ]);
83
+ assert.strictEqual(output.toString(), '[REDACTED]\nordinary\n');
84
+ }
85
+ });
86
+
87
+ it('retains full header continuation when an inner token reaches the same block boundary', () => {
88
+ const sanitizer = new StreamingSanitizer({});
89
+ const input = Buffer.from(
90
+ `Authorization: Bearer gh${'p_'}${'a'.repeat(131072)} trailing-private-value\nordinary\n`
91
+ );
92
+ const output = [];
93
+ for (let offset = 0; offset < input.length; offset += 65536) {
94
+ output.push(sanitizer.push(input.subarray(offset, offset + 65536)));
95
+ }
96
+ output.push(sanitizer.push(Buffer.alloc(0), true));
97
+ assert.strictEqual(
98
+ Buffer.concat(output).toString(),
99
+ '[REDACTED]\nordinary\n'
100
+ );
101
+ });
102
+
103
+ it('redacts AWS assignment values enclosed in either quote style', () => {
104
+ const sanitizer = new StreamingSanitizer({});
105
+ const output = sanitizer
106
+ .push(
107
+ Buffer.from(
108
+ `AWS_SECRET_ACCESS_KEY="quoted-aws-secret"\nAWS_SESSION_TOKEN='quoted-session-value'\n`
109
+ ),
110
+ true
111
+ )
112
+ .toString();
113
+ assert.ok(!output.includes('quoted-aws-secret'));
114
+ assert.ok(!output.includes('quoted-session-value'));
115
+ });
116
+
117
+ it('redacts multiline and short known secrets, preserving unrelated bytes', () => {
118
+ const sanitizer = new StreamingSanitizer({
119
+ GH_TOKEN: 'abc\nxyz',
120
+ TEST_PASSWORD: '!',
121
+ });
122
+ const output = sanitizer
123
+ .push(Buffer.from('safe abc\nxyz value !'), true)
124
+ .toString();
125
+ assert.strictEqual(output, 'safe [REDACTED] value [REDACTED]');
126
+ });
127
+
128
+ it('merges overlapping environment secrets and preserves ordinary domain names', () => {
129
+ const sanitizer = new StreamingSanitizer({
130
+ GH_TOKEN: 'abcde',
131
+ TEST_PASSWORD: 'cdefg',
132
+ GITHUB_PAT: 'opaque-known-pat',
133
+ });
134
+ assert.strictEqual(
135
+ sanitizer
136
+ .push(
137
+ Buffer.from('abcdefg opaque-known-pat example.test.invalid'),
138
+ true
139
+ )
140
+ .toString(),
141
+ '[REDACTED] [REDACTED] example.test.invalid'
142
+ );
143
+ const short = new StreamingSanitizer({ GH_TOKEN: 'a' });
144
+ assert.strictEqual(
145
+ short.push(Buffer.alloc(65536, 97), true).toString(),
146
+ '[REDACTED]'
147
+ );
148
+ });
149
+
150
+ it('creates a private sanitized copy and removes it without changing the source', () => {
151
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-test-'));
152
+ const source = path.join(dir, 'source.log');
153
+ const input = `gh` + `p_${'x'.repeat(36)}`;
154
+ fs.writeFileSync(source, input);
155
+ try {
156
+ const copy = sanitizeLogToTemp(source, { env: {} });
157
+ assert.notStrictEqual(copy.path, source);
158
+ assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), '[REDACTED]');
159
+ if (process.platform !== 'win32') {
160
+ assert.strictEqual(fs.statSync(copy.path).mode & 0o777, 0o600);
161
+ assert.strictEqual(
162
+ fs.statSync(path.dirname(copy.path)).mode & 0o777,
163
+ 0o700
164
+ );
165
+ }
166
+ assert.strictEqual(fs.readFileSync(source, 'utf8'), input);
167
+ copy.cleanup();
168
+ assert.ok(!fs.existsSync(copy.path));
169
+ } finally {
170
+ fs.rmSync(dir, { recursive: true, force: true });
171
+ }
172
+ });
173
+
174
+ it('enforces private permissions at creation even with a permissive umask', () => {
175
+ if (process.platform === 'win32') {
176
+ return;
177
+ }
178
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-creation-'));
179
+ const source = path.join(dir, 'source.log');
180
+ fs.writeFileSync(source, 'ordinary output');
181
+ const originalOpen = fs.openSync;
182
+ const originalUmask = process.umask(0);
183
+ let creationChecked = false;
184
+ let copy;
185
+ fs.openSync = (...args) => {
186
+ if (args[1] !== 'wx') {
187
+ return originalOpen(...args);
188
+ }
189
+ assert.strictEqual(
190
+ fs.statSync(path.dirname(args[0])).mode & 0o777,
191
+ 0o700
192
+ );
193
+ const descriptor = originalOpen(...args);
194
+ assert.strictEqual(fs.fstatSync(descriptor).mode & 0o777, 0o600);
195
+ creationChecked = true;
196
+ return descriptor;
197
+ };
198
+ try {
199
+ copy = sanitizeLogToTemp(source, { env: {} });
200
+ assert.ok(creationChecked);
201
+ assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), 'ordinary output');
202
+ } finally {
203
+ fs.openSync = originalOpen;
204
+ process.umask(originalUmask);
205
+ copy?.cleanup();
206
+ fs.rmSync(dir, { recursive: true, force: true });
207
+ }
208
+ });
209
+
210
+ it('fails closed on invalid source and excessive environment secret sizes', () => {
211
+ assert.throws(
212
+ () => sanitizeLogToTemp('/does-not-exist', { env: {} }),
213
+ /sanitization failed/i
214
+ );
215
+ assert.throws(
216
+ () => new StreamingSanitizer({ GH_TOKEN: 'a'.repeat(65537) }),
217
+ /too large/i
218
+ );
219
+ });
220
+
221
+ it('blocks both upload paths on read or write failure and does not expose exception text', () => {
222
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-errors-'));
223
+ const source = path.join(dir, 'source.log');
224
+ fs.writeFileSync(source, 'ordinary log');
225
+ const { uploadLogPath } = require('../src/lib/log-uploader');
226
+ const { uploadLog } = require('../src/lib/failure-handler');
227
+ const messages = [];
228
+ const originalLog = console.log;
229
+ console.log = (message) => messages.push(message);
230
+ try {
231
+ for (const method of ['readSync', 'writeSync', 'fsyncSync']) {
232
+ const original = fs[method];
233
+ fs[method] = () => {
234
+ throw new Error('sensitive error details');
235
+ };
236
+ try {
237
+ assert.strictEqual(uploadLogPath(source).success, false);
238
+ assert.strictEqual(uploadLog(source), null);
239
+ } finally {
240
+ fs[method] = original;
241
+ }
242
+ }
243
+ assert.ok(
244
+ messages.every(
245
+ (message) => !message.includes('sensitive error details')
246
+ )
247
+ );
248
+ } finally {
249
+ console.log = originalLog;
250
+ fs.rmSync(dir, { recursive: true, force: true });
251
+ }
252
+ });
253
+
254
+ it('sanitizes manual and automatic uploads, explicitly requests private visibility, and cleans copies', () => {
255
+ if (process.platform === 'win32') {
256
+ return;
257
+ }
258
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-sanitize-test-'));
259
+ const log = path.join(dir, 'source.log');
260
+ fs.writeFileSync(log, `gh` + `p_${'x'.repeat(36)}`);
261
+ fs.writeFileSync(
262
+ path.join(dir, 'gh-upload-log'),
263
+ '#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE_ARGS"\ncat "$1" > "$CAPTURE_LOG"\nls -ld "$1" | cut -c1-10 > "$CAPTURE_MODE"\necho https://gist.github.com/test/id\n',
264
+ { mode: 0o755 }
265
+ );
266
+ try {
267
+ for (const kind of ['manual', 'automatic', 'optout']) {
268
+ const script =
269
+ kind === 'automatic'
270
+ ? `require(${JSON.stringify(require.resolve('../src/lib/failure-handler'))}).uploadLog(process.env.SOURCE_LOG)`
271
+ : `require(${JSON.stringify(require.resolve('../src/lib/log-uploader'))}).uploadLogPath(process.env.SOURCE_LOG, { noSanitize: ${kind === 'optout'} })`;
272
+ execFileSync(process.execPath, ['-e', script], {
273
+ env: {
274
+ ...process.env,
275
+ PATH: `${dir}${path.delimiter}${process.env.PATH}`,
276
+ SOURCE_LOG: log,
277
+ CAPTURE_ARGS: path.join(dir, 'args'),
278
+ CAPTURE_LOG: path.join(dir, 'uploaded'),
279
+ CAPTURE_MODE: path.join(dir, 'mode'),
280
+ GH_UPLOAD_PUBLIC: 'true',
281
+ },
282
+ stdio: 'pipe',
283
+ });
284
+ const args = fs
285
+ .readFileSync(path.join(dir, 'args'), 'utf8')
286
+ .trim()
287
+ .split('\n');
288
+ assert.strictEqual(args[1], '--private');
289
+ assert.strictEqual(
290
+ fs.readFileSync(path.join(dir, 'uploaded'), 'utf8'),
291
+ kind === 'optout' ? fs.readFileSync(log, 'utf8') : '[REDACTED]'
292
+ );
293
+ if (kind !== 'optout') {
294
+ assert.notStrictEqual(args[0], log);
295
+ assert.ok(!fs.existsSync(args[0]));
296
+ assert.strictEqual(
297
+ fs.readFileSync(path.join(dir, 'mode'), 'utf8').trim(),
298
+ '-rw-------'
299
+ );
300
+ }
301
+ }
302
+ } finally {
303
+ fs.rmSync(dir, { recursive: true, force: true });
304
+ }
305
+ });
306
+ });
@@ -127,6 +127,12 @@ function fakeRunner(hostConfig, failures = {}) {
127
127
  if (args[0] === 'inspect' && args.includes('{{json .HostConfig}}')) {
128
128
  return ok(`${JSON.stringify(hostConfig)}\n`);
129
129
  }
130
+ if (args.includes('--size')) {
131
+ return ok('1024');
132
+ }
133
+ if (args[0] === 'info') {
134
+ return ok(JSON.stringify({ DockerRootDir: os.tmpdir() }));
135
+ }
130
136
  return ok('cid\n');
131
137
  };
132
138
  runner.calls = calls;
@@ -300,6 +306,7 @@ describe('issue #176: snapshot resume keeps the resource limits', () => {
300
306
  command: 'npm run build',
301
307
  probe: stoppedProbe,
302
308
  runner,
309
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
303
310
  startWatcher: (...args) => watchers.push(args),
304
311
  });
305
312
  expect(result.success).toBe(true);
@@ -76,7 +76,15 @@ function fixture() {
76
76
  }
77
77
 
78
78
  const stopped = () => ({ alive: false, state: SessionState.STOPPED });
79
- const accepted = () => ({ success: true, stdout: 'container-id\n', status: 0 });
79
+ const accepted = (_bin, args = []) => ({
80
+ success: true,
81
+ stdout: args.includes('--size')
82
+ ? '1024'
83
+ : args[0] === 'info'
84
+ ? JSON.stringify({ DockerRootDir: os.tmpdir() })
85
+ : 'container-id\n',
86
+ status: 0,
87
+ });
80
88
 
81
89
  describe('issue #187: explicit resume attempts', () => {
82
90
  it('reattaches the current attempt without enabling kill recovery for snapshot commands', async () => {
@@ -33,9 +33,16 @@ function fixture() {
33
33
  if (args[0] === 'info') {
34
34
  return {
35
35
  success: true,
36
- stdout: JSON.stringify({ MemTotal: 1073741824, NCPU: 4 }),
36
+ stdout: JSON.stringify({
37
+ MemTotal: 1073741824,
38
+ NCPU: 4,
39
+ DockerRootDir: os.tmpdir(),
40
+ }),
37
41
  };
38
42
  }
43
+ if (args.includes('--size')) {
44
+ return { success: true, stdout: '1024' };
45
+ }
39
46
  if (args[0] === 'inspect') {
40
47
  return {
41
48
  success: true,
@@ -78,6 +85,7 @@ it('manual snapshot override applies limits to the new container at creation', a
78
85
  try {
79
86
  const result = await resumeExecution(f.store, f.record.uuid, {
80
87
  command: 'new',
88
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
81
89
  resourceOptions: { memory: '128m' },
82
90
  runner: f.runner,
83
91
  probe: () => ({ alive: false, state: SessionState.STOPPED }),
@@ -64,13 +64,23 @@ describe('issue #193: durable store locks and resume', () => {
64
64
  };
65
65
  const result = await resumeExecution(store, record.uuid, {
66
66
  command: 'new',
67
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
67
68
  probe: () => ({ alive: false, state: SessionState.STOPPED }),
68
69
  runner: (_cmd, args) => {
69
70
  if (args[0] !== 'inspect') {
70
71
  expect(saved?.options.sessionName).toBe('task-resume-1');
71
72
  expect(saved?.attempt.launchAcceptedAt).toBeNull();
72
73
  }
73
- return { success: true, stdout: args[0] === 'inspect' ? '{}' : 'id' };
74
+ return {
75
+ success: true,
76
+ stdout: args.includes('--size')
77
+ ? '1024'
78
+ : args[0] === 'info'
79
+ ? JSON.stringify({ DockerRootDir: '/docker' })
80
+ : args[0] === 'inspect'
81
+ ? '{}'
82
+ : 'id',
83
+ };
74
84
  },
75
85
  startWatcher: () => true,
76
86
  });