start-command 0.39.2 → 0.40.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.
@@ -367,13 +367,12 @@ describe('ExecutionStore', () => {
367
367
  const linoPath = path.join(TEST_APP_FOLDER, LINO_DB_FILE);
368
368
  expect(fs.existsSync(linoPath)).toBe(true);
369
369
 
370
- // Read and verify content - lino format uses base64 encoding for strings
370
+ // lino-objects-codec 0.9 writes readable links notation (#253)
371
371
  const content = fs.readFileSync(linoPath, 'utf8');
372
372
  expect(content.length).toBeGreaterThan(0);
373
- // Verify content has array structure
374
- expect(content).toContain('(array');
375
- // Verify PID is stored (as int)
376
- expect(content).toContain('(int 12345)');
373
+ expect(content).toContain('command "echo hello"');
374
+ // Verify PID is stored as a bare integer
375
+ expect(content).toContain('pid 12345');
377
376
  });
378
377
  });
379
378
 
@@ -449,6 +448,51 @@ describe('ExecutionStore with lino-objects-codec', () => {
449
448
  expect(retrieved.command).toBe('echo "Hello 世界 🌍"');
450
449
  expect(retrieved.logPath).toBe('/tmp/unicode-日本語.log');
451
450
  });
451
+
452
+ it('reads a store written by lino-objects-codec 0.4.0 and rewrites it readable (#253)', () => {
453
+ fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
454
+ fs.copyFileSync(
455
+ path.join(__dirname, 'fixtures/executions-lino-objects-codec-0.4.0.lino'),
456
+ path.join(TEST_APP_FOLDER, LINO_DB_FILE)
457
+ );
458
+ const uuid = '25300000-0000-4000-8000-000000000253';
459
+
460
+ const legacy = store.get(uuid);
461
+ expect(legacy.pid).toBe(4242);
462
+ expect(legacy.exitCode).toBe(0);
463
+ expect(legacy.command).toBe('echo "legacy store"');
464
+
465
+ store.save(new ExecutionRecord({ command: 'echo next' }));
466
+ const content = fs.readFileSync(
467
+ path.join(TEST_APP_FOLDER, LINO_DB_FILE),
468
+ 'utf8'
469
+ );
470
+ expect(content).toContain('command "echo next"');
471
+ expect(content).not.toContain('(str ');
472
+ expect(store.get(uuid).command).toBe('echo "legacy store"');
473
+ });
474
+
475
+ it('reads a readable store written by lino-objects-codec 0.9 (#253)', () => {
476
+ // rust/tests/regression_253.rs reads the same fixture
477
+ fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
478
+ fs.copyFileSync(
479
+ path.join(__dirname, 'fixtures/executions-lino-objects-codec-0.9.3.lino'),
480
+ path.join(TEST_APP_FOLDER, LINO_DB_FILE)
481
+ );
482
+
483
+ const record = store.get('25300000-0000-4000-8000-000000000930');
484
+ expect(record.pid).toBe(4242);
485
+ expect(record.exitCode).toBe(0);
486
+ expect(record.command).toBe(
487
+ `echo "readable store" (with) \\ odd chars 'q' 世界`
488
+ );
489
+ expect(record.endTime).toBe('2026-10-01T00:00:01.000Z');
490
+ expect(record.options).toEqual({
491
+ isolated: 'docker',
492
+ keepAlive: false,
493
+ nested: { list: [1, 'two', null] },
494
+ });
495
+ });
452
496
  });
453
497
 
454
498
  describe('ExecutionStore verifyConsistency', () => {
@@ -0,0 +1 @@
1
+ (array (object ((str dXVpZA==) (str MjUzMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMjUz)) ((str cGlk) (int 4242)) ((str c3RhdHVz) (str ZXhlY3V0ZWQ=)) ((str ZXhpdENvZGU=) (int 0)) ((str Y29tbWFuZA==) (str ZWNobyAibGVnYWN5IHN0b3JlIg==)) ((str bG9nUGF0aA==) (str L3RtcC9zdGFydC0yNTMubG9n)) ((str c3RhcnRUaW1l) (str MjAyNi0xMC0wMVQwMDowMDowMC4wMDBa)) ((str ZW5kVGltZQ==) (str MjAyNi0xMC0wMVQwMDowMDowMS4wMDBa)) ((str d29ya2luZ0RpcmVjdG9yeQ==) (str L3RtcA==)) ((str c2hlbGw=) (str L2Jpbi9zaA==)) ((str cGxhdGZvcm0=) (str bGludXg=)) ((str b3B0aW9ucw==) (object ((str aXNvbGF0ZWQ=) (null))))))
@@ -0,0 +1,26 @@
1
+ (
2
+ (
3
+ uuid "25300000-0000-4000-8000-000000000930"
4
+ pid 4242
5
+ status "executed"
6
+ exitCode 0
7
+ command """echo "readable store" (with) \ odd chars 'q' 世界"""
8
+ logPath "/tmp/start-253-readable.log"
9
+ startTime "2026-10-01T00:00:00.000Z"
10
+ endTime "2026-10-01T00:00:01.000Z"
11
+ workingDirectory "/tmp"
12
+ shell "/bin/sh"
13
+ platform "linux"
14
+ options (
15
+ isolated "docker"
16
+ keepAlive false
17
+ nested (
18
+ list (
19
+ 1
20
+ "two"
21
+ null
22
+ )
23
+ )
24
+ )
25
+ )
26
+ )
@@ -233,6 +233,35 @@ describe('JavaScript first CI gate', () => {
233
233
  );
234
234
  });
235
235
 
236
+ it('treats generated Rust regenerated by a translator bump as no change', () => {
237
+ const features = [
238
+ {
239
+ id: 'generated',
240
+ javascript: { implementation: ['js/source.mjs'], tests: [] },
241
+ rust: { implementation: ['rust/generated.rs'], tests: [] },
242
+ },
243
+ ];
244
+ const generated = [
245
+ { javascript: 'js/source.mjs', rust: 'rust/generated.rs' },
246
+ ];
247
+ assert.deepEqual(
248
+ checkFeatureChanges(features, ['rust/generated.rs'], generated),
249
+ []
250
+ );
251
+ assert.deepEqual(
252
+ checkFeatureChanges(
253
+ features,
254
+ ['js/source.mjs', 'rust/generated.rs'],
255
+ generated
256
+ ),
257
+ []
258
+ );
259
+ assert.equal(
260
+ checkFeatureChanges(features, ['rust/generated.rs']).length,
261
+ 1
262
+ );
263
+ });
264
+
236
265
  it('keeps both halves of a regression test under one feature', () => {
237
266
  const feature = (id, js, rust) => ({
238
267
  id,
@@ -5,8 +5,8 @@ const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
  const { execFileSync } = require('child_process');
8
+ const { createHash } = require('node:crypto');
8
9
  const {
9
- AuthorizationHeaderMask,
10
10
  SanitizationError,
11
11
  resolveSanitizerCommand,
12
12
  sanitizeLogToTemp,
@@ -59,17 +59,6 @@ function sanitizeFile(content, env = testEnv()) {
59
59
  });
60
60
  }
61
61
 
62
- function mask(input, blockSize) {
63
- const authorization = new AuthorizationHeaderMask();
64
- const blocks = [];
65
- for (let offset = 0; offset < input.length; offset += blockSize) {
66
- blocks.push(authorization.push(input.subarray(offset, offset + blockSize)));
67
- assert.ok(authorization.pending.length <= blockSize + 160);
68
- }
69
- blocks.push(authorization.push(Buffer.alloc(0), true));
70
- return Buffer.concat(blocks).toString();
71
- }
72
-
73
62
  // The three lines from issue #249, which the old rules truncated.
74
63
  const ISSUE_249_LINES = [
75
64
  "Command: gate='/tmp/hive-mind-disk-baseline-0ce9e170-e6d3-494d-91e1-0d820245d34b'",
@@ -98,6 +87,30 @@ function credentials() {
98
87
  }
99
88
 
100
89
  describe('log sanitization', () => {
90
+ it('requires the sanitizer release the Rust installer asks for', () => {
91
+ // An older CLI rejects --allow-binary (ERR_ARGUMENT), so the bundled
92
+ // dependency and the Rust installer and version check agree.
93
+ const range =
94
+ require('../package.json').dependencies[
95
+ '@link-foundation/sensitive-data-sanitizer'
96
+ ];
97
+ const rust = fs.readFileSync(
98
+ path.join(__dirname, '../../rust/src/lib/log_sanitizer.rs'),
99
+ 'utf8'
100
+ );
101
+ const [, major, minor, patch] = range.match(/^\^(\d+)\.(\d+)\.(\d+)$/);
102
+ assert.ok(
103
+ rust.includes(
104
+ `PACKAGE: &str = "@link-foundation/sensitive-data-sanitizer@${range}";`
105
+ )
106
+ );
107
+ assert.ok(
108
+ rust.includes(
109
+ `MINIMUM_VERSION: (u64, u64, u64) = (${major}, ${minor}, ${patch});`
110
+ )
111
+ );
112
+ });
113
+
101
114
  it('keeps every line from issue #249 and public account details', () => {
102
115
  const input = `${ISSUE_249_LINES.join('\n')}\n`;
103
116
  assert.strictEqual(sanitizeFile(input).toString(), input);
@@ -154,35 +167,87 @@ describe('log sanitization', () => {
154
167
  'Authorization: token $(gh auth token) and ${GITHUB_TOKEN}',
155
168
  'Authorization: token $(gh auth token) and ${GITHUB_TOKEN}',
156
169
  ],
170
+ // Since 0.12.2, placeholders and their scheme remain unchanged.
157
171
  ['Authorization: Bearer <token>', 'Authorization: Bearer <token>'],
158
172
  ];
173
+ // sensitive-data-sanitizer 0.12.1 does this itself (#45), without the
174
+ // pre-pass start used to run (#253).
159
175
  const input = cases.map(([line]) => line).join('\n');
160
176
  const expected = cases.map(([, line]) => line).join('\n');
161
- for (const size of [1, 7, 31, 256, 65536]) {
162
- assert.strictEqual(mask(Buffer.from(input), size), expected);
163
- }
164
- // The library itself masks the scheme before a placeholder; nothing is lost.
165
- assert.strictEqual(
166
- sanitizeFile(input).toString(),
167
- expected.replace('Bearer <token>', '[REDACTED] <token>')
168
- );
177
+ assert.strictEqual(sanitizeFile(input).toString(), expected);
178
+ assert.strictEqual(sanitizeText(input, { env: testEnv() }), expected);
169
179
  });
170
180
 
171
- it('masks long Authorization tokens across blocks with bounded memory', () => {
181
+ it('masks long Authorization tokens across stream blocks', () => {
172
182
  const input = Buffer.from(
173
183
  `Authorization: Bearer ${'a'.repeat(200000)} tail\nordinary\n`
174
184
  );
175
- assert.strictEqual(
176
- mask(input, 65536),
177
- 'Authorization: [REDACTED] tail\nordinary\n'
178
- );
179
185
  assert.strictEqual(
180
186
  sanitizeFile(input).toString(),
181
187
  'Authorization: [REDACTED] tail\nordinary\n'
182
188
  );
183
189
  });
184
190
 
185
- it('preserves invalid UTF-8 bytes and line endings', () => {
191
+ it('preserves Authorization placeholders and control bytes when sanitizing twice (#253)', () => {
192
+ const input = [
193
+ 'Authorization: Bearer [REDACTED] already',
194
+ 'Authorization: Bearer {{token}} x',
195
+ 'Authorization: Bearer <token> x',
196
+ 'Authorization: Bearer $GITHUB_TOKEN x',
197
+ 'Authorization: Bearer abc\0 tail',
198
+ 'Authorization: Bearer abc\x1b[0m tail',
199
+ '{"header":"Authorization: Bearer ab[c]d","next":"keep"}',
200
+ ].join('\n');
201
+ const expected = [
202
+ 'Authorization: Bearer [REDACTED] already',
203
+ // Arbitrary bracketed values are credentials; known references above
204
+ // stay intact, but every bracket of this value must be removed (#50).
205
+ 'Authorization: [REDACTED] x',
206
+ 'Authorization: Bearer <token> x',
207
+ 'Authorization: Bearer $GITHUB_TOKEN x',
208
+ 'Authorization: [REDACTED]\0 tail',
209
+ 'Authorization: [REDACTED]\x1b[0m tail',
210
+ '{"header":"Authorization: [REDACTED]","next":"keep"}',
211
+ ].join('\n');
212
+ const output = sanitizeFile(input);
213
+ assert.strictEqual(output.toString(), expected);
214
+ assert.deepStrictEqual(sanitizeFile(output), output);
215
+ });
216
+
217
+ it(
218
+ 'sanitizes dense credentials within a bounded process (#253, sanitizer #51)',
219
+ { timeout: 25000 },
220
+ () => {
221
+ const count = 5700;
222
+ const input = Array.from({ length: count }, (_, index) => {
223
+ const token = createHash('sha256').update(String(index)).digest('hex');
224
+ return `gh${'p_'}${token.slice(0, 36)} keep\n`;
225
+ }).join('');
226
+ const { command, args } = resolveSanitizerCommand({});
227
+ const output = execFileSync(
228
+ command,
229
+ [
230
+ '--max-old-space-size=384',
231
+ ...args,
232
+ 'redact',
233
+ '-',
234
+ '--stream',
235
+ '--credentials-only',
236
+ '--allow-binary',
237
+ ],
238
+ {
239
+ input,
240
+ env: testEnv(),
241
+ timeout: 20000,
242
+ killSignal: 'SIGKILL',
243
+ maxBuffer: 1024 * 1024,
244
+ }
245
+ );
246
+ assert.strictEqual(output.toString(), '[REDACTED] keep\n'.repeat(count));
247
+ }
248
+ );
249
+
250
+ it('preserves invalid UTF-8 bytes, NUL bytes and line endings', () => {
186
251
  const input = Buffer.concat([
187
252
  Buffer.from('bad '),
188
253
  Buffer.from([0xff, 0xfe, 0xc3]),
@@ -195,6 +260,12 @@ describe('log sanitization', () => {
195
260
  ]);
196
261
  assert.deepStrictEqual(sanitizeFile(input), expected);
197
262
  assert.deepStrictEqual(sanitizeFile(Buffer.alloc(0)), Buffer.alloc(0));
263
+ // --allow-binary: a NUL byte in a terminal transcript no longer blocks
264
+ // the upload with ERR_BINARY (#253), and credentials next to it are masked.
265
+ assert.strictEqual(
266
+ sanitizeFile(`line\0one gh${'p_'}${'x'.repeat(36)}\0two\n`).toString(),
267
+ 'line\0one [REDACTED]\0two\n'
268
+ );
198
269
  });
199
270
 
200
271
  it('sanitizes issue text with the same rules', () => {
@@ -255,27 +326,15 @@ describe('log sanitization', () => {
255
326
  if (process.platform === 'win32') {
256
327
  return;
257
328
  }
258
- const originalOpen = fs.openSync;
259
329
  const originalUmask = process.umask(0);
260
- let creationChecked = false;
261
- fs.openSync = (...args) => {
262
- if (args[1] !== 'wx') {
263
- return originalOpen(...args);
264
- }
265
- assert.strictEqual(
266
- fs.statSync(path.dirname(args[0])).mode & 0o777,
267
- 0o700
268
- );
269
- const descriptor = originalOpen(...args);
270
- assert.strictEqual(fs.fstatSync(descriptor).mode & 0o777, 0o600);
271
- creationChecked = true;
272
- return descriptor;
273
- };
274
330
  try {
275
331
  withLog('ordinary output', (source) => {
276
332
  const copy = sanitizeLogToTemp(source, { env: testEnv() });
277
333
  try {
278
- assert.ok(creationChecked);
334
+ assert.strictEqual(
335
+ fs.statSync(path.dirname(copy.path)).mode & 0o777,
336
+ 0o700
337
+ );
279
338
  assert.strictEqual(fs.statSync(copy.path).mode & 0o777, 0o600);
280
339
  assert.strictEqual(
281
340
  fs.readFileSync(copy.path, 'utf8'),
@@ -286,7 +345,6 @@ describe('log sanitization', () => {
286
345
  }
287
346
  });
288
347
  } finally {
289
- fs.openSync = originalOpen;
290
348
  process.umask(originalUmask);
291
349
  }
292
350
  });
@@ -323,9 +381,7 @@ describe('log sanitization', () => {
323
381
  };
324
382
  assert.match(attempt('/does-not-exist').message, /\(ERR_SOURCE\)/);
325
383
  assert.match(attempt(os.tmpdir()).message, /\(ERR_SOURCE\)/);
326
- withLog('secret\0binary', (source, dir) => {
327
- // The library rejects NUL bytes rather than guessing at binary content.
328
- assert.strictEqual(attempt(source).code, 'ERR_BINARY');
384
+ withLog('ordinary log', (source, dir) => {
329
385
  assert.strictEqual(
330
386
  attempt(
331
387
  source,
@@ -376,13 +432,24 @@ describe('log sanitization', () => {
376
432
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-errors-'));
377
433
  const source = path.join(dir, 'source.log');
378
434
  fs.writeFileSync(source, 'ordinary log');
435
+ // A fake uploader, so a regression can never publish the log for real.
436
+ const uploaded = path.join(dir, 'uploaded');
437
+ fs.writeFileSync(
438
+ path.join(dir, 'gh-upload-log'),
439
+ `#!/bin/sh\ntouch "${uploaded}"\necho https://gist.github.com/test/id\n`,
440
+ { mode: 0o755 }
441
+ );
442
+ const originalPath = process.env.PATH;
443
+ process.env.PATH = `${dir}${path.delimiter}${originalPath}`;
379
444
  const { uploadLogPath } = require('../src/lib/log-uploader');
380
445
  const { uploadLog } = require('../src/lib/failure-handler');
381
446
  const messages = [];
382
447
  const originalLog = console.log;
383
448
  console.log = (message) => messages.push(message);
384
449
  try {
385
- for (const method of ['readSync', 'writeSync', 'fsyncSync']) {
450
+ // The sanitizer child reads and writes the files itself; start only
451
+ // opens and checks the source.
452
+ for (const method of ['openSync', 'fstatSync']) {
386
453
  const original = fs[method];
387
454
  fs[method] = () => {
388
455
  throw new Error('sensitive error details');
@@ -409,8 +476,10 @@ describe('log sanitization', () => {
409
476
  'Warning: Log sanitization failed (ERR_SOURCE); upload blocked.'
410
477
  )
411
478
  );
479
+ assert.ok(!fs.existsSync(uploaded));
412
480
  } finally {
413
481
  console.log = originalLog;
482
+ process.env.PATH = originalPath;
414
483
  fs.rmSync(dir, { recursive: true, force: true });
415
484
  }
416
485
  });
@@ -78,6 +78,8 @@ function fixture() {
78
78
  }
79
79
 
80
80
  const stopped = () => ({ alive: false, state: SessionState.STOPPED });
81
+ // The snapshot preflight must not depend on the free disk of the test host.
82
+ const plentyOfDisk = { freeBytes: () => 100 * 1024 ** 3 };
81
83
  const accepted = (_bin, args = []) => ({
82
84
  success: true,
83
85
  stdout: args.includes('--size')
@@ -102,6 +104,7 @@ describe('issue #187: explicit resume attempts', () => {
102
104
  ? () => ({ alive: false, state: SessionState.MISSING })
103
105
  : stopped,
104
106
  runner: accepted,
107
+ snapshotOptions: plentyOfDisk,
105
108
  runIsolated: () => ({ success: true, containerId: 'new-container' }),
106
109
  startWatcher: (_name, _policy, _log, _uuid, options) => {
107
110
  expect(options.recoverOnKill).toBe(mode !== 'docker-snapshot');
@@ -357,6 +360,7 @@ esac
357
360
  command: 'replacement',
358
361
  probe: stopped,
359
362
  runner: accepted,
363
+ snapshotOptions: plentyOfDisk,
360
364
  startWatcher: () => {},
361
365
  });
362
366
  expect(result.success).toBe(true);
@@ -59,6 +59,8 @@ afterEach(() => {
59
59
  const SAFETY = require.resolve('../src/lib/docker-snapshot-safety');
60
60
 
61
61
  /** Hold the snapshot lock in another process for `holdMs`. */
62
+ // A waiter polls for 1 s before it reads the holder's name, so tests that
63
+ // expect the name hold the lock well past that, even on a slow runner.
62
64
  async function holdLockElsewhere(lockPath, holder, holdMs) {
63
65
  const ready = `${lockPath}.ready`;
64
66
  const child = spawn(
@@ -264,7 +266,7 @@ describe('issue #224 item 1: waiting for the snapshot lock', () => {
264
266
 
265
267
  it('waits for the holder with --snapshot-wait and says so once', async () => {
266
268
  const lockPath = path.join(makeTempDir('lock-224-'), 'snapshot.lock');
267
- await holdLockElsewhere(lockPath, 'task-a', 1500);
269
+ await holdLockElsewhere(lockPath, 'task-a', 3000);
268
270
  const lines = [];
269
271
  const started = Date.now();
270
272
  const lock = acquireSnapshotLock({
@@ -307,7 +309,7 @@ describe('issue #224 item 1: waiting for the snapshot lock', () => {
307
309
 
308
310
  it('serializes two concurrent CLI resumes instead of failing the second', async () => {
309
311
  const lockPath = path.join(makeTempDir('lock-224-'), 'snapshot.lock');
310
- await holdLockElsewhere(lockPath, 'task-a', 1200);
312
+ await holdLockElsewhere(lockPath, 'task-a', 3000);
311
313
  const { store } = snapshotRecord();
312
314
  const calls = [];
313
315
  const lines = [];
@@ -12,6 +12,7 @@ const { parseArgs } = require('../src/lib/args-parser');
12
12
  const {
13
13
  ExecutionStore,
14
14
  ExecutionRecord,
15
+ isClinkInstalled,
15
16
  } = require('../src/lib/execution-store');
16
17
  const {
17
18
  executionAliases,
@@ -126,6 +127,25 @@ describe('issue #231: execution names', () => {
126
127
  ]);
127
128
  });
128
129
 
130
+ // Issue #253: the rename used to reach only the lino store; clink 4.x
131
+ // rejected every links query the store built.
132
+ it.skipIf(!isClinkInstalled())(
133
+ '--rename reaches the links database',
134
+ () => {
135
+ const { store } = sandbox();
136
+ store.save(record(A, { sessionName: 'screen-a' }));
137
+ nameExecution(store, A, 'first', { rename: true });
138
+ const links = store.execClink('((($id: $s $t)) (($id: $s $t)))', [
139
+ '--changes',
140
+ ]).output;
141
+ expect(links).toContain(
142
+ `${A}.options: ${A} 'options={"sessionName":"screen-a","name":"first"}'`
143
+ );
144
+ expect(store.verifyConsistency().errors).toEqual([]);
145
+ },
146
+ 30000
147
+ );
148
+
129
149
  it('a UUID-shaped alias resolves like any other name', () => {
130
150
  const { store } = sandbox();
131
151
  store.save(record(A));
@@ -87,7 +87,8 @@ describe('release-git (#211)', () => {
87
87
  await withRepo({ reject: true }, async () => {
88
88
  const result = await $`git push origin main`.run(quiet);
89
89
  expect(result.code).not.toBe(0);
90
- expect(result.stderr).toContain('GH013');
90
+ // command-stream 2.x returns stderr as a readable snapshot (#253).
91
+ expect(String(result.stderr)).toContain('GH013');
91
92
  });
92
93
  });
93
94