start-command 0.39.2 → 0.41.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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/eslint.config.mjs +32 -0
  3. package/package.json +9 -10
  4. package/src/bin/cli.js +18 -6
  5. package/src/lib/args-parser.js +33 -0
  6. package/src/lib/command-stream.js +19 -5
  7. package/src/lib/execution-chain.js +2 -2
  8. package/src/lib/execution-store.js +62 -44
  9. package/src/lib/formal-ai-cli.js +237 -0
  10. package/src/lib/formal-ai-client.js +166 -0
  11. package/src/lib/formal-ai-policy.js +501 -0
  12. package/src/lib/formal-ai-runtime.js +304 -0
  13. package/src/lib/formal-ai-tool.js +182 -0
  14. package/src/lib/formal-ai.js +329 -0
  15. package/src/lib/log-sanitizer.js +16 -137
  16. package/src/lib/usage.js +3 -1
  17. package/test/ci-workflow-invariants.js +18 -0
  18. package/test/command-stream-log.js +85 -0
  19. package/test/dependency-freshness.mjs +552 -0
  20. package/test/execution-store-clink.js +152 -0
  21. package/test/execution-store.js +49 -5
  22. package/test/fixtures/executions-lino-objects-codec-0.4.0.lino +1 -0
  23. package/test/fixtures/executions-lino-objects-codec-0.9.3.lino +26 -0
  24. package/test/fixtures/formal-ai/fake-server.cjs +17 -0
  25. package/test/fixtures/formal-ai/helpers.cjs +189 -0
  26. package/test/fixtures/formal-ai/receipts.json +776 -0
  27. package/test/fixtures/formal-ai/replay.cjs +144 -0
  28. package/test/javascript-first-ci.js +29 -0
  29. package/test/log-sanitizer.js +116 -47
  30. package/test/regression-187.js +4 -0
  31. package/test/regression-224.js +4 -2
  32. package/test/regression-231.js +20 -0
  33. package/test/regression-255-cli.js +294 -0
  34. package/test/regression-255-e2e.js +90 -0
  35. package/test/regression-255-loop.js +475 -0
  36. package/test/regression-255-policy.js +436 -0
  37. package/test/release-git.mjs +2 -1
  38. package/test/shared-golden.js +3 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * The task loop of the experimental `--use-formal-ai` mode (issue #255).
3
+ *
4
+ * The natural-language request goes to Formal AI verbatim. Formal AI decides
5
+ * what to observe and which tool calls to make; start checks every call
6
+ * against the effect policy, runs approved calls, reports each receipt back
7
+ * and keeps evidence of what actually happened. The run ends when Formal AI
8
+ * gives its final answer, says it did not understand, or a bound, refusal,
9
+ * protocol error or cancellation stops it, and the exit status says which.
10
+ */
11
+
12
+ const policy = require('./formal-ai-policy');
13
+ const { formatToolResult, runTool } = require('./formal-ai-tool');
14
+
15
+ const { EXIT } = policy;
16
+ const PREVIEW_CHARS = 2000;
17
+ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
18
+
19
+ function preview(text) {
20
+ return text.length > PREVIEW_CHARS
21
+ ? `${text.slice(0, PREVIEW_CHARS)}… [${text.length - PREVIEW_CHARS} more chars]`
22
+ : text;
23
+ }
24
+
25
+ /** Observed working-directory changes, in words. */
26
+ function describeEffects(effects) {
27
+ const parts = ['added', 'removed', 'changed']
28
+ .filter((key) => effects[key].length)
29
+ .map((key) => {
30
+ const names = effects[key];
31
+ const more = names.length > 10 ? ` and ${names.length - 10} more` : '';
32
+ return `${key} ${names.slice(0, 10).join(', ')}${more}`;
33
+ });
34
+ const text = parts.length ? parts.join('; ') : 'no file changed';
35
+ return effects.complete ? text : `${text} (fingerprint incomplete)`;
36
+ }
37
+
38
+ /**
39
+ * Run one request to completion.
40
+ * @param {object} options
41
+ * @param {string} options.request - Natural-language request, never executed
42
+ * @param {string} options.cwd - Working directory of the tools
43
+ * @param {object} options.env - Tool environment
44
+ * @param {object} options.bounds - policy.resolveBounds() result
45
+ * @param {object} options.client - formal-ai-client createClient() result
46
+ * @param {object} [options.io] - {stdout, stderr, note(text)} mirrors
47
+ * @param {AbortSignal} [options.signal] - Cancellation
48
+ * @param {object} [options.evidence] - Evidence object to fill (for crash-safe writes)
49
+ * @returns {Promise<object>} Evidence with `outcome`
50
+ */
51
+ async function runFormalAiTask(options) {
52
+ const { request, cwd, env, bounds, client, signal = null } = options;
53
+ const io = options.io || {};
54
+ const note = io.note || (() => {});
55
+ const deadline = options.deadline || Date.now() + bounds.timeoutMs;
56
+ const evidence = options.evidence || {};
57
+ Object.assign(evidence, {
58
+ request,
59
+ cwd,
60
+ effectsMode: bounds.effects,
61
+ bounds: { ...bounds },
62
+ steps: [],
63
+ effects: { added: [], removed: [], changed: [], complete: true },
64
+ outcome: null,
65
+ });
66
+ const context = {
67
+ cwd,
68
+ effects: bounds.effects,
69
+ observed: new Set(),
70
+ observedDirs: new Set(),
71
+ };
72
+ const finish = (status, exitCode, message, remaining = []) => {
73
+ evidence.outcome = {
74
+ status,
75
+ exitCode,
76
+ message,
77
+ remainingObligations: remaining,
78
+ };
79
+ return evidence;
80
+ };
81
+ const cancelled = () =>
82
+ finish(
83
+ 'cancelled',
84
+ SIGNAL_EXIT[signal && signal.reason] || 130,
85
+ `cancelled by ${(signal && signal.reason) || 'SIGINT'}`,
86
+ ['the task was interrupted; later steps did not run']
87
+ );
88
+ if (signal) {
89
+ // Cancellation stops the running tool's whole process group.
90
+ signal.addEventListener('abort', () => {
91
+ if (evidence.activeKill) {
92
+ evidence.activeKill('SIGTERM');
93
+ }
94
+ });
95
+ }
96
+ const messages = [{ role: 'user', content: request }];
97
+ let lastExit = null;
98
+ let toolCalls = 0;
99
+
100
+ for (let turn = 1; turn <= bounds.maxSteps; turn++) {
101
+ if (signal && signal.aborted) {
102
+ return cancelled();
103
+ }
104
+ if (Date.now() >= deadline) {
105
+ return finish('budget-exhausted', EXIT.BUDGET, 'time budget exhausted', [
106
+ 'the task did not finish within START_FORMAL_AI_TIMEOUT_MS',
107
+ ]);
108
+ }
109
+ let response;
110
+ try {
111
+ response = await client.complete(messages, { signal });
112
+ } catch (error) {
113
+ if (signal && signal.aborted) {
114
+ return cancelled();
115
+ }
116
+ const status =
117
+ error.exitCode === EXIT.BUDGET
118
+ ? 'budget-exhausted'
119
+ : error.exitCode === EXIT.UNAVAILABLE
120
+ ? 'unavailable'
121
+ : 'protocol-error';
122
+ return finish(status, error.exitCode || EXIT.PROTOCOL, error.message, [
123
+ 'Formal AI did not complete the task',
124
+ ]);
125
+ }
126
+ evidence.steps.push({
127
+ kind: 'response',
128
+ turn,
129
+ finishReason: response.finishReason,
130
+ handler: response.handler,
131
+ content: preview(response.content),
132
+ toolCalls: response.toolCalls.map((c) => c.command),
133
+ });
134
+ if (!response.toolCalls.length) {
135
+ if (response.content) {
136
+ note(response.content);
137
+ }
138
+ if (response.handler === 'unknown' && toolCalls === 0) {
139
+ return finish(
140
+ 'unsupported',
141
+ EXIT.UNSUPPORTED,
142
+ 'Formal AI did not understand the request; no tool ran',
143
+ ['rephrase the request or perform it manually']
144
+ );
145
+ }
146
+ if (lastExit === null) {
147
+ return finish('answered', 0, 'Formal AI answered without tools');
148
+ }
149
+ // The status reports what the tools did, not what Formal AI claims:
150
+ // a run of reads is never presented as a completed change.
151
+ const status =
152
+ lastExit === 0
153
+ ? `finished after ${toolCalls} tool call(s)`
154
+ : `finished; the last tool call exited with ${lastExit}`;
155
+ return finish(
156
+ 'completed',
157
+ lastExit,
158
+ `${status}; ${describeEffects(evidence.effects)}`,
159
+ lastExit === 0 ? [] : ['the last tool call failed']
160
+ );
161
+ }
162
+ messages.push({
163
+ role: 'assistant',
164
+ content: response.content,
165
+ tool_calls: response.toolCalls.map((call, index) => ({
166
+ id: call.id || `call_${turn}_${index}`,
167
+ type: 'function',
168
+ function: {
169
+ name: call.name,
170
+ arguments: JSON.stringify({ command: call.command }),
171
+ },
172
+ })),
173
+ });
174
+ for (const [index, call] of response.toolCalls.entries()) {
175
+ const callId = messages[messages.length - 1].tool_calls[index].id;
176
+ toolCalls++;
177
+ if (toolCalls > bounds.maxSteps) {
178
+ return finish(
179
+ 'budget-exhausted',
180
+ EXIT.BUDGET,
181
+ `tool call budget of ${bounds.maxSteps} exhausted`,
182
+ ['the task did not finish within START_FORMAL_AI_MAX_STEPS']
183
+ );
184
+ }
185
+ const outcome = await runCall({
186
+ call,
187
+ context,
188
+ cwd,
189
+ env,
190
+ bounds,
191
+ deadline,
192
+ io,
193
+ note,
194
+ evidence,
195
+ });
196
+ if (signal && signal.aborted) {
197
+ return cancelled();
198
+ }
199
+ if (outcome.stop) {
200
+ return finish(...outcome.stop);
201
+ }
202
+ lastExit = outcome.exitCode;
203
+ messages.push({
204
+ role: 'tool',
205
+ tool_call_id: callId,
206
+ content: outcome.result,
207
+ });
208
+ }
209
+ }
210
+ return finish(
211
+ 'budget-exhausted',
212
+ EXIT.BUDGET,
213
+ `step budget of ${bounds.maxSteps} exhausted`,
214
+ ['the task did not finish within START_FORMAL_AI_MAX_STEPS']
215
+ );
216
+ }
217
+
218
+ async function runCall({
219
+ call,
220
+ context,
221
+ cwd,
222
+ env,
223
+ bounds,
224
+ deadline,
225
+ io,
226
+ note,
227
+ evidence,
228
+ }) {
229
+ const decision = policy.evaluateCommand(call.command, context);
230
+ const step = { kind: 'tool', command: call.command, argv: decision.tokens };
231
+ evidence.steps.push(step);
232
+ if (!decision.allowed) {
233
+ step.decision = 'refused';
234
+ step.reason = decision.reason;
235
+ note(`refused \`${call.command}\`: ${decision.reason}`);
236
+ return {
237
+ stop: [
238
+ 'refused',
239
+ EXIT.REFUSED,
240
+ `refused \`${call.command}\`: ${decision.reason}`,
241
+ ['the refused call did not run; nothing after it ran either'],
242
+ ],
243
+ };
244
+ }
245
+ step.decision = 'allowed';
246
+ step.effect = decision.kind;
247
+ note(`$ ${call.command}`);
248
+ const before = policy.fingerprint(cwd, bounds.maxFingerprintEntries);
249
+ const receipt = await runTool({
250
+ argv: decision.tokens,
251
+ cwd,
252
+ env,
253
+ timeoutMs: Math.max(
254
+ 1,
255
+ Math.min(bounds.toolTimeoutMs, deadline - Date.now())
256
+ ),
257
+ maxOutputBytes: bounds.maxOutputBytes,
258
+ stdout: io.stdout,
259
+ stderr: io.stderr,
260
+ onSpawn: (kill) => {
261
+ evidence.activeKill = kill;
262
+ },
263
+ });
264
+ delete evidence.activeKill;
265
+ const diff = policy.diffFingerprints(
266
+ before,
267
+ policy.fingerprint(cwd, bounds.maxFingerprintEntries)
268
+ );
269
+ Object.assign(step, {
270
+ exitCode: receipt.exitCode,
271
+ signal: receipt.signal,
272
+ timedOut: receipt.timedOut,
273
+ durationMs: receipt.durationMs,
274
+ truncated: receipt.truncated,
275
+ spawnError: receipt.spawnError,
276
+ stdout: preview(receipt.stdout),
277
+ stderr: preview(receipt.stderr),
278
+ effects: diff,
279
+ });
280
+ for (const key of ['added', 'removed', 'changed']) {
281
+ evidence.effects[key].push(...diff[key]);
282
+ }
283
+ evidence.effects.complete = evidence.effects.complete && diff.complete;
284
+ if (receipt.truncated) {
285
+ note(
286
+ `output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES=${bounds.maxOutputBytes}`
287
+ );
288
+ }
289
+ if (receipt.timedOut) {
290
+ return {
291
+ stop: [
292
+ 'budget-exhausted',
293
+ EXIT.BUDGET,
294
+ `\`${call.command}\` exceeded the tool time limit and was stopped`,
295
+ ['the stopped call may have done part of its work'],
296
+ ],
297
+ };
298
+ }
299
+ if (decision.kind === 'read') {
300
+ policy.recordObservation(decision, context);
301
+ if (policy.hasEffects(diff)) {
302
+ step.unexpectedEffects = true;
303
+ note(
304
+ `warning: the working directory changed during a read-only call (${[...diff.added, ...diff.removed, ...diff.changed].slice(0, 5).join(', ')})`
305
+ );
306
+ }
307
+ } else if (receipt.exitCode === 0) {
308
+ step.verification = policy.verifyChange(decision.tokens, cwd);
309
+ if (!step.verification.verified) {
310
+ return {
311
+ stop: [
312
+ 'unverified',
313
+ EXIT.PROTOCOL,
314
+ `\`${call.command}\` exited 0 but ${step.verification.detail}`,
315
+ ['the change could not be confirmed by readback'],
316
+ ],
317
+ };
318
+ }
319
+ for (const target of decision.targets) {
320
+ context.observed.add(target);
321
+ }
322
+ }
323
+ return {
324
+ exitCode: receipt.exitCode,
325
+ result: formatToolResult(receipt, call.command, cwd),
326
+ };
327
+ }
328
+
329
+ module.exports = { SIGNAL_EXIT, describeEffects, runFormalAiTask };
@@ -8,11 +8,10 @@
8
8
  * local gh hosts.yml (--gh-config). It streams the log from stdin, keeps
9
9
  * invalid UTF-8 bytes and writes a new private file atomically (--output).
10
10
  *
11
- * One rule stays here: the library masks an unquoted or single-quoted
12
- * `Authorization: <scheme> <value>` to the end of the line, which drops the
13
- * rest of a logged one-line JSON prompt (issue #249). The pre-pass below masks
14
- * only the scheme and its token first; the library keeps values that already
15
- * start with [REDACTED]. Remove it with sensitive-data-sanitizer#45.
11
+ * Since 0.12.1 the library masks only the scheme and token of an
12
+ * `Authorization: <scheme> <value>` header, so a logged one-line JSON prompt
13
+ * keeps the rest of its line (issue #249, sensitive-data-sanitizer#45); the
14
+ * pre-pass that did this here is gone (issue #253).
16
15
  */
17
16
 
18
17
  const fs = require('fs');
@@ -27,89 +26,6 @@ const COMMAND = 'sensitive-data-sanitizer';
27
26
  const OVERRIDE = 'START_SENSITIVE_DATA_SANITIZER';
28
27
  const OPTIONS = ['--credentials-only', '--env-secrets', '--gh-config'];
29
28
 
30
- // The longest header and scheme prefix is 122 bytes.
31
- const HOLDBACK = 160;
32
- const HEADER =
33
- /((?:Proxy-)?Authorization[ \t]{0,32}:[ \t]{0,32})(?:Bearer|Basic|Digest|token)[ \t]{1,32}/gi;
34
- // Shell expressions, placeholders and masked values are not credentials.
35
- const NOT_A_VALUE = '$<[{';
36
-
37
- function isValueByte(code) {
38
- return (
39
- code > 0x20 &&
40
- code !== 0x22 &&
41
- code !== 0x27 &&
42
- code !== 0x2c &&
43
- code !== 0x5c
44
- );
45
- }
46
-
47
- function valueEnd(text, start) {
48
- let end = start;
49
- while (end < text.length && isValueByte(text.charCodeAt(end))) {
50
- end++;
51
- }
52
- return end;
53
- }
54
-
55
- /**
56
- * Masks `Authorization: <scheme> <token>` as `Authorization: [REDACTED]`.
57
- * It works on bytes (latin1 maps each byte to one character), so invalid
58
- * UTF-8 passes through unchanged, and holds at most HOLDBACK pending bytes.
59
- */
60
- class AuthorizationHeaderMask {
61
- constructor() {
62
- this.pending = '';
63
- this.inValue = false;
64
- this.redactions = 0;
65
- }
66
-
67
- push(block, final = false) {
68
- const text = this.pending + block.toString('latin1');
69
- this.pending = '';
70
- let output = '';
71
- let offset = 0;
72
- if (this.inValue) {
73
- offset = valueEnd(text, 0);
74
- if (offset === text.length && !final) {
75
- return Buffer.alloc(0);
76
- }
77
- this.inValue = false;
78
- }
79
- HEADER.lastIndex = offset;
80
- let match;
81
- while ((match = HEADER.exec(text))) {
82
- const valueStart = match.index + match[0].length;
83
- if (valueStart === text.length && !final) {
84
- output += text.slice(offset, match.index);
85
- this.pending = text.slice(match.index);
86
- return Buffer.from(output, 'latin1');
87
- }
88
- if (
89
- valueStart === text.length ||
90
- !isValueByte(text.charCodeAt(valueStart)) ||
91
- NOT_A_VALUE.includes(text[valueStart])
92
- ) {
93
- continue;
94
- }
95
- output += text.slice(offset, match.index + match[1].length) + MARKER;
96
- this.redactions++;
97
- offset = valueEnd(text, valueStart);
98
- if (offset === text.length && !final) {
99
- this.inValue = true;
100
- return Buffer.from(output, 'latin1');
101
- }
102
- HEADER.lastIndex = offset;
103
- }
104
- const safeEnd = final
105
- ? text.length
106
- : Math.max(offset, text.length - HOLDBACK);
107
- output += text.slice(offset, safeEnd);
108
- this.pending = text.slice(safeEnd);
109
- return Buffer.from(output, 'latin1');
110
- }
111
- }
112
-
113
29
  /** Value-free failure: `code` is a fixed ERR_* name, never log content. */
114
30
  class SanitizationError extends Error {
115
31
  constructor(code, subject = 'Log') {
@@ -195,10 +111,9 @@ function runSanitizer(args, options, subject) {
195
111
  * @returns {string}
196
112
  */
197
113
  function sanitizeText(text, options = {}) {
198
- const input = new AuthorizationHeaderMask().push(Buffer.from(text), true);
199
114
  const result = runSanitizer(
200
115
  ['redact', '-', ...OPTIONS],
201
- { ...options, input },
116
+ { ...options, input: text },
202
117
  'Text'
203
118
  );
204
119
  return result.stdout.toString();
@@ -230,46 +145,6 @@ function countMarkers(filePath) {
230
145
  }
231
146
  }
232
147
 
233
- function createPrivateFile(filePath) {
234
- const descriptor = fs.openSync(filePath, 'wx', 0o600);
235
- if (process.platform !== 'win32') {
236
- fs.fchmodSync(descriptor, 0o600);
237
- }
238
- return descriptor;
239
- }
240
-
241
- function maskAuthorizationHeaders(sourcePath, stagePath) {
242
- const source = fs.openSync(sourcePath, 'r');
243
- let stage;
244
- try {
245
- if (!fs.fstatSync(source).isFile()) {
246
- throw new SanitizationError('ERR_SOURCE');
247
- }
248
- stage = createPrivateFile(stagePath);
249
- const mask = new AuthorizationHeaderMask();
250
- const buffer = Buffer.alloc(BLOCK_SIZE);
251
- let bytes = 0;
252
- for (;;) {
253
- const length = fs.readSync(source, buffer, 0, BLOCK_SIZE, null);
254
- const output = mask.push(buffer.subarray(0, length), length === 0);
255
- if (output.length) {
256
- fs.writeSync(stage, output);
257
- }
258
- if (length === 0) {
259
- break;
260
- }
261
- bytes += length;
262
- }
263
- fs.fsyncSync(stage);
264
- return bytes;
265
- } finally {
266
- fs.closeSync(source);
267
- if (stage !== undefined) {
268
- fs.closeSync(stage);
269
- }
270
- }
271
- }
272
-
273
148
  /**
274
149
  * Create a private sanitized copy of a log. The source is never modified;
275
150
  * any failure removes the copy and throws a value-free SanitizationError.
@@ -288,19 +163,25 @@ function sanitizeLogToTemp(sourcePath, options = {}) {
288
163
  if (process.platform !== 'win32') {
289
164
  fs.chmodSync(directory, 0o700);
290
165
  }
291
- const stagePath = path.join(directory, 'authorization-masked.log');
292
166
  const target = path.join(directory, 'execution.log');
293
167
  code = 'ERR_SOURCE';
294
- const bytes = maskAuthorizationHeaders(sourcePath, stagePath);
295
- code = 'ERR_PRIVATE_FILE';
296
- const input = fs.openSync(stagePath, 'r');
168
+ const input = fs.openSync(sourcePath, 'r');
169
+ let bytes;
297
170
  try {
298
- const size = Math.max(1, fs.fstatSync(input).size);
171
+ const source = fs.fstatSync(input);
172
+ if (!source.isFile()) {
173
+ throw new SanitizationError('ERR_SOURCE');
174
+ }
175
+ bytes = source.size;
176
+ const size = Math.max(1, bytes);
177
+ code = 'ERR_PRIVATE_FILE';
299
178
  runSanitizer(
300
179
  [
301
180
  'redact',
302
181
  '-',
303
182
  '--stream',
183
+ // A NUL byte in a terminal transcript must not block the upload.
184
+ '--allow-binary',
304
185
  ...OPTIONS,
305
186
  '--max-bytes',
306
187
  String(size),
@@ -326,7 +207,6 @@ function sanitizeLogToTemp(sourcePath, options = {}) {
326
207
  `Prepared private sanitized log: ${bytes} bytes scanned, ${Math.max(0, redactions)} redactions.`
327
208
  );
328
209
  }
329
- fs.rmSync(stagePath, { force: true });
330
210
  return { path: target, cleanup };
331
211
  } catch (error) {
332
212
  cleanup();
@@ -341,7 +221,6 @@ function sanitizeLogToTemp(sourcePath, options = {}) {
341
221
  }
342
222
 
343
223
  module.exports = {
344
- AuthorizationHeaderMask,
345
224
  BLOCK_SIZE,
346
225
  SanitizationError,
347
226
  resolveSanitizerCommand,
package/src/lib/usage.js CHANGED
@@ -46,6 +46,7 @@ Options:
46
46
  --keep-container-on-fail Remove successful docker containers, keep failed or OOM-killed ones
47
47
  --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
48
48
  --use-command-stream Use command-stream library for execution (experimental)
49
+ --use-formal-ai Perform a natural-language request through Formal AI (experimental)
49
50
  --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
50
51
  --list List all tracked executions (--output-format: links-notation|json|text)
51
52
  --running With --list, only report executions that are still running
@@ -103,7 +104,8 @@ Examples:
103
104
  $ --rename <uuid> nightly-build # Address it as nightly-build from now on
104
105
  $ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
105
106
  $ -i docker -d --on-kill-resume 3 --on-kill-resume-delay 30-90 -- cargo test
106
- $ --use-command-stream echo "Hello" # Use command-stream library`);
107
+ $ --use-command-stream echo "Hello" # Use command-stream library
108
+ $ --use-formal-ai -- print list of files in current directory`);
107
109
  console.log('');
108
110
  console.log('Piping with $:');
109
111
  console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command');
@@ -504,6 +504,24 @@ describe('CI workflow invariants', () => {
504
504
  assert.match(lint.body, /cargo metadata --locked/);
505
505
  });
506
506
 
507
+ it('gates pull requests on every dependency being its latest release', () => {
508
+ // Obsolete dependencies, sensitive-data-sanitizer among them, stayed in
509
+ // use unnoticed (issue #253).
510
+ const jsJobs = parseJobs(readWorkflow('js.yml'));
511
+ const freshness = jsJobs.find((job) => job.name === 'dependency-freshness');
512
+ assert.ok(freshness, 'js.yml has no dependency-freshness job');
513
+ assert.match(
514
+ freshness.body,
515
+ /scripts\/check-dependency-freshness\.mjs --lockfiles\n/
516
+ );
517
+ const status = jsJobs.find((job) => job.name === 'pipeline-status');
518
+ assert.ok(parseNeeds(status.body).includes('dependency-freshness'));
519
+ const lint = parseJobs(readWorkflow('rust.yml')).find(
520
+ (job) => job.name === 'lint'
521
+ );
522
+ assert.match(lint.body, /cargo update --dry-run/);
523
+ });
524
+
507
525
  it('reports a failed Rust test as a failed test, not low coverage', () => {
508
526
  // Without pipefail a failing test reached the threshold check as an empty
509
527
  // value: "Coverage % is below the 50% threshold" (issue #234).
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Issue #253: command-stream 2.x returns completed `stdout` and `stderr` as
3
+ * readable stream snapshots instead of strings. `--use-command-stream` must
4
+ * still write the command's output to its log file.
5
+ */
6
+ const { describe, it, expect } = require('bun:test');
7
+ const { spawnSync } = require('child_process');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { pathToFileURL } = require('url');
12
+
13
+ const CLI = path.join(__dirname, '../src/bin/cli.js');
14
+
15
+ describe('--use-command-stream log (#253)', () => {
16
+ it('loads execution without compatibility and terminal modules', () => {
17
+ const wrapper = pathToFileURL(
18
+ path.join(__dirname, '../src/lib/command-stream.js')
19
+ ).href;
20
+ const result = spawnSync(
21
+ 'node',
22
+ [
23
+ '--input-type=module',
24
+ '-e',
25
+ `import { registerHooks } from 'node:module';
26
+ import assert from 'node:assert/strict';
27
+ const extras = [];
28
+ registerHooks({ load(url, context, next) {
29
+ if (/command-stream\\/src\\/(shelljs|terminal|rust-repl)/.test(url)) extras.push(url);
30
+ return next(url, context);
31
+ }});
32
+ const { default: wrapper } = await import(${JSON.stringify(wrapper)});
33
+ const core = await wrapper.getCommandStream();
34
+ assert.equal(typeof core.$, 'function');
35
+ assert.equal(typeof core.ProcessRunner, 'function');
36
+ assert.deepEqual(extras, []);`,
37
+ ],
38
+ { encoding: 'utf8', timeout: 30000 }
39
+ );
40
+ expect(result.error).toBeUndefined();
41
+ expect(result.stderr).toBe('');
42
+ expect(result.status).toBe(0);
43
+ });
44
+
45
+ it.skipIf(process.platform === 'win32')(
46
+ 'writes stdout and stderr to the log file',
47
+ () => {
48
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-253-'));
49
+ try {
50
+ const result = spawnSync(
51
+ process.execPath,
52
+ [
53
+ CLI,
54
+ '--use-command-stream',
55
+ '--',
56
+ 'sh',
57
+ '-c',
58
+ 'echo out-253; echo err-253 >&2',
59
+ ],
60
+ {
61
+ env: {
62
+ ...process.env,
63
+ START_APP_FOLDER: path.join(root, 'app'),
64
+ START_LOG_DIR: path.join(root, 'logs'),
65
+ START_DISABLE_AUTO_ISSUE: '1',
66
+ START_DISABLE_LOG_UPLOAD: '1',
67
+ },
68
+ encoding: 'utf8',
69
+ timeout: 30000,
70
+ }
71
+ );
72
+ expect(result.status).toBe(0);
73
+ expect(result.stderr).not.toContain('Could not append log file');
74
+ const logDir = path.join(root, 'logs', 'direct');
75
+ const [logName] = fs.readdirSync(logDir);
76
+ const log = fs.readFileSync(path.join(logDir, logName), 'utf8');
77
+ expect(log).toContain('out-253\n');
78
+ expect(log).toContain('err-253\n');
79
+ } finally {
80
+ fs.rmSync(root, { recursive: true, force: true });
81
+ }
82
+ },
83
+ 40000
84
+ );
85
+ });