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.
- package/CHANGELOG.md +26 -0
- package/eslint.config.mjs +32 -0
- package/package.json +9 -10
- package/src/bin/cli.js +18 -6
- package/src/lib/args-parser.js +33 -0
- package/src/lib/command-stream.js +19 -5
- package/src/lib/execution-chain.js +2 -2
- package/src/lib/execution-store.js +62 -44
- package/src/lib/formal-ai-cli.js +237 -0
- package/src/lib/formal-ai-client.js +166 -0
- package/src/lib/formal-ai-policy.js +501 -0
- package/src/lib/formal-ai-runtime.js +304 -0
- package/src/lib/formal-ai-tool.js +182 -0
- package/src/lib/formal-ai.js +329 -0
- package/src/lib/log-sanitizer.js +16 -137
- package/src/lib/usage.js +3 -1
- package/test/ci-workflow-invariants.js +18 -0
- package/test/command-stream-log.js +85 -0
- package/test/dependency-freshness.mjs +552 -0
- package/test/execution-store-clink.js +152 -0
- package/test/execution-store.js +49 -5
- package/test/fixtures/executions-lino-objects-codec-0.4.0.lino +1 -0
- package/test/fixtures/executions-lino-objects-codec-0.9.3.lino +26 -0
- package/test/fixtures/formal-ai/fake-server.cjs +17 -0
- package/test/fixtures/formal-ai/helpers.cjs +189 -0
- package/test/fixtures/formal-ai/receipts.json +776 -0
- package/test/fixtures/formal-ai/replay.cjs +144 -0
- package/test/javascript-first-ci.js +29 -0
- package/test/log-sanitizer.js +116 -47
- package/test/regression-187.js +4 -0
- package/test/regression-224.js +4 -2
- package/test/regression-231.js +20 -0
- package/test/regression-255-cli.js +294 -0
- package/test/regression-255-e2e.js +90 -0
- package/test/regression-255-loop.js +475 -0
- package/test/regression-255-policy.js +436 -0
- package/test/release-git.mjs +2 -1
- 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 };
|
package/src/lib/log-sanitizer.js
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
* `Authorization: <scheme> <value>`
|
|
13
|
-
* rest of
|
|
14
|
-
*
|
|
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
|
|
295
|
-
|
|
296
|
-
const input = fs.openSync(stagePath, 'r');
|
|
168
|
+
const input = fs.openSync(sourcePath, 'r');
|
|
169
|
+
let bytes;
|
|
297
170
|
try {
|
|
298
|
-
const
|
|
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
|
+
});
|