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,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replies of the offline Formal AI stand-in (issue #255), shared by the fake
|
|
3
|
+
* runtime process (fake-server.cjs) and in-process tests.
|
|
4
|
+
*
|
|
5
|
+
* A request gets its recorded response number k from receipts.json, where k
|
|
6
|
+
* is the number of assistant turns already in the conversation. Unknown
|
|
7
|
+
* requests get the runtime's "did not understand" reply. SYNTHETIC scenarios
|
|
8
|
+
* (requests starting with `synthetic:`) cover states a real runtime cannot be
|
|
9
|
+
* made to produce on demand. A number reply is an HTTP error status.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { receipts } = JSON.parse(
|
|
17
|
+
fs.readFileSync(path.join(__dirname, 'receipts.json'), 'utf8')
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
function toolCall(command, id = `call_${Math.random().toString(16).slice(2)}`) {
|
|
21
|
+
return {
|
|
22
|
+
role: 'assistant',
|
|
23
|
+
content: 'Let me run the requested command to get that for you.',
|
|
24
|
+
tool_calls: [
|
|
25
|
+
{
|
|
26
|
+
id,
|
|
27
|
+
type: 'function',
|
|
28
|
+
function: { name: 'bash', arguments: JSON.stringify({ command }) },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const answer = (content) => ({ role: 'assistant', content });
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* SYNTHETIC scenarios: request -> (assistantTurns, messages) -> message|status.
|
|
38
|
+
* A constant list matched by string equality: request text only selects among
|
|
39
|
+
* these entries and is never used as a property key, so `constructor`,
|
|
40
|
+
* `toString` and similar names reach the "did not understand" reply.
|
|
41
|
+
*/
|
|
42
|
+
const SYNTHETIC = Object.freeze(
|
|
43
|
+
Object.entries({
|
|
44
|
+
// Never stops asking for more reads: exhausts the step budget.
|
|
45
|
+
'synthetic: loop forever': () => toolCall('pwd'),
|
|
46
|
+
// A tool start must not run.
|
|
47
|
+
'synthetic: call an unknown tool': () => ({
|
|
48
|
+
role: 'assistant',
|
|
49
|
+
content: '',
|
|
50
|
+
tool_calls: [
|
|
51
|
+
{
|
|
52
|
+
id: 'call_x',
|
|
53
|
+
type: 'function',
|
|
54
|
+
function: { name: 'python', arguments: '{"code":"1"}' },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
// A long-running read (a fake `cat` from the test PATH sleeps).
|
|
59
|
+
'synthetic: read slowly': (turns) =>
|
|
60
|
+
turns === 0 ? toolCall('cat slow.txt') : answer('done'),
|
|
61
|
+
// A tool that does not exist on PATH.
|
|
62
|
+
'synthetic: run a missing tool': (turns) =>
|
|
63
|
+
turns === 0 ? toolCall('tree') : answer('The `tree` command failed.'),
|
|
64
|
+
// Writes outside the observed workspace.
|
|
65
|
+
'synthetic: overwrite without looking': () => toolCall('touch README.md'),
|
|
66
|
+
'synthetic: write outside': () => toolCall('touch ../escape.txt'),
|
|
67
|
+
// A server error on every request.
|
|
68
|
+
'synthetic: server error': () => 500,
|
|
69
|
+
// A write that claims success but leaves no trace (fake `mkdir` on PATH).
|
|
70
|
+
'synthetic: unverified mkdir': (turns) =>
|
|
71
|
+
turns === 0 ? toolCall('test ! -e out') : toolCall('mkdir out'),
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
function reply(body) {
|
|
76
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
77
|
+
const request = (messages.find((m) => m.role === 'user') || {}).content || '';
|
|
78
|
+
const turns = messages.filter((m) => m.role === 'assistant').length;
|
|
79
|
+
const scenario = SYNTHETIC.find(([name]) => name === request);
|
|
80
|
+
if (scenario) {
|
|
81
|
+
const [, respond] = scenario;
|
|
82
|
+
return respond(turns, messages);
|
|
83
|
+
}
|
|
84
|
+
const receipt = receipts.find((r) => r.request === request);
|
|
85
|
+
if (receipt && receipt.responses[turns]) {
|
|
86
|
+
return receipt.responses[turns].message;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
role: 'assistant',
|
|
90
|
+
content: "I didn't understand you. Could you rephrase?",
|
|
91
|
+
thinking_steps: [{ step: 'dispatch_handler', detail: 'unknown' }],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* HTTP server speaking the agent-mode subset start uses.
|
|
97
|
+
* @param {string} [log] - File that receives every request body as a JSON line
|
|
98
|
+
* @returns {import('http').Server}
|
|
99
|
+
*/
|
|
100
|
+
function createFakeServer(log) {
|
|
101
|
+
return http.createServer((req, res) => {
|
|
102
|
+
let text = '';
|
|
103
|
+
req.on('data', (chunk) => {
|
|
104
|
+
text += chunk;
|
|
105
|
+
});
|
|
106
|
+
req.on('end', () => {
|
|
107
|
+
const send = (status, payload) => {
|
|
108
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
109
|
+
res.end(JSON.stringify(payload));
|
|
110
|
+
};
|
|
111
|
+
if (req.method === 'GET' && req.url.endsWith('/models')) {
|
|
112
|
+
send(200, { object: 'list', data: [{ id: 'formal-ai' }] });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (req.method !== 'POST' || !req.url.endsWith('/chat/completions')) {
|
|
116
|
+
send(404, { error: 'not found' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const body = JSON.parse(text);
|
|
120
|
+
if (log) {
|
|
121
|
+
fs.appendFileSync(log, `${JSON.stringify(body)}\n`);
|
|
122
|
+
}
|
|
123
|
+
const message = reply(body);
|
|
124
|
+
if (typeof message === 'number') {
|
|
125
|
+
send(message, { error: 'synthetic failure' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
send(200, {
|
|
129
|
+
id: 'chatcmpl_fake',
|
|
130
|
+
object: 'chat.completion',
|
|
131
|
+
model: 'formal-ai',
|
|
132
|
+
choices: [
|
|
133
|
+
{
|
|
134
|
+
index: 0,
|
|
135
|
+
message,
|
|
136
|
+
finish_reason: message.tool_calls ? 'tool_calls' : 'stop',
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { SYNTHETIC, createFakeServer, receipts, reply };
|
|
@@ -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,
|
package/test/log-sanitizer.js
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
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
|
|
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
|
|
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.
|
|
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('
|
|
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
|
-
|
|
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
|
});
|
package/test/regression-187.js
CHANGED
|
@@ -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);
|
package/test/regression-224.js
CHANGED
|
@@ -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',
|
|
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',
|
|
312
|
+
await holdLockElsewhere(lockPath, 'task-a', 3000);
|
|
311
313
|
const { store } = snapshotRecord();
|
|
312
314
|
const calls = [];
|
|
313
315
|
const lines = [];
|
package/test/regression-231.js
CHANGED
|
@@ -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));
|