start-command 0.41.0 → 0.43.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 +23 -0
- package/package.json +3 -3
- package/src/lib/formal-ai-client.js +249 -22
- package/src/lib/formal-ai-files.js +797 -0
- package/src/lib/formal-ai-policy.js +10 -3
- package/src/lib/formal-ai.js +328 -12
- package/src/lib/log-sanitizer.js +4 -0
- package/src/lib/usage.js +3 -1
- package/test/fixtures/formal-ai/receipts-258.json +449 -0
- package/test/fixtures/formal-ai/receipts-261.json +589 -0
- package/test/fixtures/formal-ai/replay.cjs +60 -7
- package/test/regression-255-cli.js +3 -1
- package/test/regression-255-e2e.js +80 -3
- package/test/regression-255-loop.js +6 -1
- package/test/regression-255-policy.js +1 -1
- package/test/regression-257.js +265 -0
- package/test/regression-258-files.js +452 -0
- package/test/regression-258.js +456 -0
- package/test/regression-261.js +856 -0
|
@@ -177,12 +177,14 @@ describe.skipIf(!posix)('--use-formal-ai end to end (#255)', () => {
|
|
|
177
177
|
);
|
|
178
178
|
expect(result.after).toEqual(result.before);
|
|
179
179
|
|
|
180
|
-
// The request went to Formal AI verbatim
|
|
180
|
+
// The request went to Formal AI verbatim; read-only mode offers bash and
|
|
181
|
+
// the structured read tool, never the change tools (#258).
|
|
181
182
|
expect(result.requests[0].messages).toEqual([
|
|
182
183
|
{ role: 'user', content: 'print list of files in current directory' },
|
|
183
184
|
]);
|
|
184
185
|
expect(result.requests[0].tools.map((t) => t.function.name)).toEqual([
|
|
185
186
|
'bash',
|
|
187
|
+
'read',
|
|
186
188
|
]);
|
|
187
189
|
|
|
188
190
|
const log = readLog(result.root);
|
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
*
|
|
8
8
|
* It runs the issue's demo in an isolated directory whose names contain
|
|
9
9
|
* spaces and Unicode, compares the listing with an independent readdir and
|
|
10
|
-
* proves the directory is unchanged.
|
|
11
|
-
*
|
|
10
|
+
* proves the directory is unchanged. Issue #258 adds an isolated
|
|
11
|
+
* read/edit/read task in workspace-write mode, the explicit capability gap of
|
|
12
|
+
* the same request in read-only mode, and a plain question answered without
|
|
13
|
+
* tools. Use START_FORMAL_AI_NODE to pick the Node.js that runs the Formal AI
|
|
14
|
+
* server.
|
|
12
15
|
*/
|
|
13
16
|
const { describe, it, expect, afterAll } = require('bun:test');
|
|
14
17
|
const { spawnSync } = require('child_process');
|
|
@@ -32,7 +35,7 @@ const enabled =
|
|
|
32
35
|
const dirs = [];
|
|
33
36
|
afterAll(() => cleanup(...dirs));
|
|
34
37
|
|
|
35
|
-
function run(request, files) {
|
|
38
|
+
function run(request, files, extra = {}) {
|
|
36
39
|
const root = tempDir('e2e');
|
|
37
40
|
const cwd = populate(tempDir('e2e-fixture'), files);
|
|
38
41
|
dirs.push(root, cwd);
|
|
@@ -40,6 +43,7 @@ function run(request, files) {
|
|
|
40
43
|
const env = cliEnv(root, {
|
|
41
44
|
START_FORMAL_AI_ROOT: process.env.START_FORMAL_AI_ROOT,
|
|
42
45
|
START_FORMAL_AI_NODE: process.env.START_FORMAL_AI_NODE || 'node',
|
|
46
|
+
...extra,
|
|
43
47
|
});
|
|
44
48
|
delete env.START_FORMAL_AI_MEMORY_PATH;
|
|
45
49
|
const result = spawnSync(
|
|
@@ -88,3 +92,76 @@ describe.skipIf(!enabled)('--use-formal-ai with a real runtime (#255)', () => {
|
|
|
88
92
|
{ timeout: 150000 }
|
|
89
93
|
);
|
|
90
94
|
});
|
|
95
|
+
|
|
96
|
+
const SNIPPET =
|
|
97
|
+
'// keep this comment\nconst label = "before";\n\n\texport const shown = label; \n/* "before" stays here */\n';
|
|
98
|
+
const EDIT =
|
|
99
|
+
'In snippet.mjs, replace «const label = "before";» with «const label = "after";». Preserve all other content.';
|
|
100
|
+
|
|
101
|
+
describe.skipIf(!enabled)(
|
|
102
|
+
'--use-formal-ai edits with a real runtime (#258)',
|
|
103
|
+
() => {
|
|
104
|
+
it(
|
|
105
|
+
'reads, edits and reads back one file in workspace-write mode',
|
|
106
|
+
() => {
|
|
107
|
+
const result = run(
|
|
108
|
+
EDIT,
|
|
109
|
+
{ 'snippet.mjs': SNIPPET, 'other.txt': 'unrelated\n' },
|
|
110
|
+
{ START_FORMAL_AI_EFFECTS: 'workspace-write' }
|
|
111
|
+
);
|
|
112
|
+
expect(result.status).toBe(0);
|
|
113
|
+
expect(
|
|
114
|
+
fs.readFileSync(path.join(result.cwd, 'snippet.mjs'), 'utf8')
|
|
115
|
+
).toBe(
|
|
116
|
+
SNIPPET.replace('const label = "before";', 'const label = "after";')
|
|
117
|
+
);
|
|
118
|
+
expect(
|
|
119
|
+
fs.readFileSync(path.join(result.cwd, 'other.txt'), 'utf8')
|
|
120
|
+
).toBe('unrelated\n');
|
|
121
|
+
const evidence = readEvidence(readLog(result.root));
|
|
122
|
+
expect(
|
|
123
|
+
evidence.steps.filter((s) => s.kind === 'tool').map((s) => s.command)
|
|
124
|
+
).toEqual([
|
|
125
|
+
'read snippet.mjs',
|
|
126
|
+
'edit snippet.mjs',
|
|
127
|
+
'sha256sum -- snippet.mjs',
|
|
128
|
+
]);
|
|
129
|
+
expect(evidence.effects).toMatchObject({
|
|
130
|
+
added: [],
|
|
131
|
+
removed: [],
|
|
132
|
+
changed: ['snippet.mjs'],
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
{ timeout: 150000 }
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
it(
|
|
139
|
+
'exits nonzero with the capability gap in read-only mode',
|
|
140
|
+
() => {
|
|
141
|
+
const result = run(EDIT, { 'snippet.mjs': SNIPPET });
|
|
142
|
+
expect(result.status).toBe(65);
|
|
143
|
+
expect(result.after).toEqual(result.before);
|
|
144
|
+
const evidence = readEvidence(readLog(result.root));
|
|
145
|
+
expect(evidence.outcome).toMatchObject({
|
|
146
|
+
status: 'capability-gap',
|
|
147
|
+
gap: {
|
|
148
|
+
schema: 'workspace-edit-capability-gap/v1',
|
|
149
|
+
target: 'snippet.mjs',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
{ timeout: 150000 }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
it(
|
|
157
|
+
'answers a plain question with exit 0',
|
|
158
|
+
() => {
|
|
159
|
+
const result = run('What is 2 + 2?', { 'a.txt': 'a' });
|
|
160
|
+
expect(result.status).toBe(0);
|
|
161
|
+
expect(result.stderr).toContain('2 + 2 = 4');
|
|
162
|
+
expect(result.after).toEqual(result.before);
|
|
163
|
+
},
|
|
164
|
+
{ timeout: 150000 }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
);
|
|
@@ -339,8 +339,13 @@ describe('--use-formal-ai refusals and unsupported requests (#255)', () => {
|
|
|
339
339
|
expect(result.outcome.status).toBe('protocol-error');
|
|
340
340
|
expect(result.outcome.exitCode).toBe(EXIT.PROTOCOL);
|
|
341
341
|
expect(result.outcome.message).toBe(
|
|
342
|
-
'Formal AI returned HTTP 500 (after 2 retries)'
|
|
342
|
+
'Formal AI returned HTTP 500: synthetic failure (after 2 retries)'
|
|
343
343
|
);
|
|
344
|
+
expect(result.outcome.diagnostics).toMatchObject({
|
|
345
|
+
httpStatus: 500,
|
|
346
|
+
errorMessage: 'synthetic failure',
|
|
347
|
+
attempts: 3,
|
|
348
|
+
});
|
|
344
349
|
expect(result.requests).toBe(3);
|
|
345
350
|
});
|
|
346
351
|
});
|
|
@@ -340,7 +340,7 @@ describe('--use-formal-ai protocol (#255)', () => {
|
|
|
340
340
|
).toEqual({
|
|
341
341
|
content: 'x',
|
|
342
342
|
finishReason: 'tool_calls',
|
|
343
|
-
toolCalls: [{ id: 'c1', name: 'bash', command: 'ls' }],
|
|
343
|
+
toolCalls: [{ id: 'c1', name: 'bash', tool: 'bash', command: 'ls' }],
|
|
344
344
|
handler: null,
|
|
345
345
|
});
|
|
346
346
|
expect(
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #257: an HTTP 5xx reply of the Formal AI runtime keeps its bounded,
|
|
3
|
+
* sanitized cause (error type, message and body preview) and the attempt
|
|
4
|
+
* count in the final error and in the evidence, also after bounded retries,
|
|
5
|
+
* without a fallback and without exposing credentials or the environment.
|
|
6
|
+
*
|
|
7
|
+
* The offline stand-in replays the exact 500 body a released start discarded:
|
|
8
|
+
* {"error":{"message":"Aborted(Error: LocalProductWasmForbidden). ...",
|
|
9
|
+
* "type":"formal_ai_error"}}.
|
|
10
|
+
*/
|
|
11
|
+
const { describe, it, expect, beforeAll, afterAll } = require('bun:test');
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const { resolveBounds, EXIT } = require('../src/lib/formal-ai-policy');
|
|
15
|
+
const {
|
|
16
|
+
ProtocolError,
|
|
17
|
+
createClient,
|
|
18
|
+
httpErrorDetails,
|
|
19
|
+
sanitizeDiagnostic,
|
|
20
|
+
} = require('../src/lib/formal-ai-client');
|
|
21
|
+
const { runFormalAiTask } = require('../src/lib/formal-ai');
|
|
22
|
+
const {
|
|
23
|
+
CAPTURED_500,
|
|
24
|
+
createFakeServer,
|
|
25
|
+
} = require('./fixtures/formal-ai/replay.cjs');
|
|
26
|
+
const {
|
|
27
|
+
CLI,
|
|
28
|
+
cleanup,
|
|
29
|
+
cliEnv,
|
|
30
|
+
fakeCheckout,
|
|
31
|
+
readEvidence,
|
|
32
|
+
readLog,
|
|
33
|
+
receiptFixture,
|
|
34
|
+
sentRequests,
|
|
35
|
+
snapshot,
|
|
36
|
+
tempDir,
|
|
37
|
+
} = require('./fixtures/formal-ai/helpers.cjs');
|
|
38
|
+
|
|
39
|
+
const CAUSE =
|
|
40
|
+
'Aborted(Error: LocalProductWasmForbidden). Build with -sASSERTIONS for more info.';
|
|
41
|
+
|
|
42
|
+
let server;
|
|
43
|
+
let baseUrl;
|
|
44
|
+
const dirs = [];
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
server = createFakeServer();
|
|
48
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
49
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
server.close();
|
|
54
|
+
cleanup(...dirs);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const ask = (request) => [{ role: 'user', content: request }];
|
|
58
|
+
|
|
59
|
+
/** One client request against the stand-in; resolves to the thrown error. */
|
|
60
|
+
async function failure(request, { maxRetries = 0, token = null } = {}) {
|
|
61
|
+
const bounds = { ...resolveBounds({}), maxRetries };
|
|
62
|
+
const client = createClient({
|
|
63
|
+
baseUrl,
|
|
64
|
+
bounds,
|
|
65
|
+
deadline: Date.now() + bounds.timeoutMs,
|
|
66
|
+
token,
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
await client.complete(ask(request));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { error, requests: client.requests };
|
|
72
|
+
}
|
|
73
|
+
throw new Error('expected the request to fail');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('5xx diagnostics of the Formal AI client (#257)', () => {
|
|
77
|
+
it('keeps the captured cause when no retry is allowed', async () => {
|
|
78
|
+
const { error, requests } = await failure(
|
|
79
|
+
'synthetic: server error (captured #257)'
|
|
80
|
+
);
|
|
81
|
+
expect(error).toBeInstanceOf(ProtocolError);
|
|
82
|
+
expect(error.exitCode).toBe(EXIT.PROTOCOL);
|
|
83
|
+
expect(error.message).toBe(
|
|
84
|
+
`Formal AI returned HTTP 500: formal_ai_error: ${CAUSE}`
|
|
85
|
+
);
|
|
86
|
+
expect(error.details).toEqual({
|
|
87
|
+
httpStatus: 500,
|
|
88
|
+
errorType: 'formal_ai_error',
|
|
89
|
+
errorMessage: CAUSE,
|
|
90
|
+
bodyPreview: CAPTURED_500,
|
|
91
|
+
bodyChars: CAPTURED_500.length,
|
|
92
|
+
attempts: 1,
|
|
93
|
+
});
|
|
94
|
+
expect(requests).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('keeps the cause and the attempt count after bounded retries', async () => {
|
|
98
|
+
const { error, requests } = await failure(
|
|
99
|
+
'synthetic: server error (captured #257)',
|
|
100
|
+
{ maxRetries: 1 }
|
|
101
|
+
);
|
|
102
|
+
expect(error.exitCode).toBe(EXIT.PROTOCOL);
|
|
103
|
+
expect(error.message).toBe(
|
|
104
|
+
`Formal AI returned HTTP 500: formal_ai_error: ${CAUSE} (after 1 retry)`
|
|
105
|
+
);
|
|
106
|
+
expect(error.details).toMatchObject({
|
|
107
|
+
httpStatus: 500,
|
|
108
|
+
errorType: 'formal_ai_error',
|
|
109
|
+
errorMessage: CAUSE,
|
|
110
|
+
attempts: 2,
|
|
111
|
+
});
|
|
112
|
+
expect(requests).toBe(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('never exposes the token, authorization values or control characters', async () => {
|
|
116
|
+
const token = 'sk-test-0123456789';
|
|
117
|
+
const { error } = await failure('synthetic: server error with secrets', {
|
|
118
|
+
maxRetries: 1,
|
|
119
|
+
token,
|
|
120
|
+
});
|
|
121
|
+
const text = JSON.stringify({ message: error.message, ...error.details });
|
|
122
|
+
expect(text).not.toContain(token);
|
|
123
|
+
expect(text).not.toContain('sk-live-abcdef');
|
|
124
|
+
expect(text).not.toContain('\u0007');
|
|
125
|
+
expect(error.details).toMatchObject({
|
|
126
|
+
httpStatus: 503,
|
|
127
|
+
errorType: 'upstream_error',
|
|
128
|
+
attempts: 2,
|
|
129
|
+
});
|
|
130
|
+
expect(error.message).toContain('HTTP 503: upstream_error: proxy refused');
|
|
131
|
+
expect(error.message).toContain('[REDACTED]');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('bounds and sanitizes any body', () => {
|
|
135
|
+
const long = `${'x'.repeat(2000)}`;
|
|
136
|
+
const details = httpErrorDetails(502, long, []);
|
|
137
|
+
expect(details.bodyChars).toBe(2000);
|
|
138
|
+
expect(details.bodyPreview.length).toBeLessThan(600);
|
|
139
|
+
expect(details.bodyPreview).toContain('[1500 more chars]');
|
|
140
|
+
expect(details.errorMessage).toBeNull();
|
|
141
|
+
expect(httpErrorDetails(500, '', [])).toMatchObject({
|
|
142
|
+
bodyPreview: '',
|
|
143
|
+
bodyChars: 0,
|
|
144
|
+
errorType: null,
|
|
145
|
+
errorMessage: null,
|
|
146
|
+
});
|
|
147
|
+
expect(httpErrorDetails(500, '{"error":"plain text"}', [])).toMatchObject({
|
|
148
|
+
errorMessage: 'plain text',
|
|
149
|
+
});
|
|
150
|
+
expect(
|
|
151
|
+
sanitizeDiagnostic(
|
|
152
|
+
'Authorization: Basic dXNlcjpwYXNz\nproxy-authorization: Bearer abc password=hunter2 secret-value',
|
|
153
|
+
500,
|
|
154
|
+
['secret-value']
|
|
155
|
+
)
|
|
156
|
+
).not.toMatch(/dXNlcjpwYXNz|abc|hunter2|secret-value|\n/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('keeps 4xx bodies and does not retry them', async () => {
|
|
160
|
+
const error4xx = http.createServer((req, res) => {
|
|
161
|
+
req.resume();
|
|
162
|
+
req.on('end', () => {
|
|
163
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
164
|
+
res.end('{"error":{"message":"bad tools","type":"invalid_request"}}');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
await new Promise((resolve) => error4xx.listen(0, '127.0.0.1', resolve));
|
|
168
|
+
try {
|
|
169
|
+
const bounds = { ...resolveBounds({}), maxRetries: 2 };
|
|
170
|
+
const client = createClient({
|
|
171
|
+
baseUrl: `http://127.0.0.1:${error4xx.address().port}`,
|
|
172
|
+
bounds,
|
|
173
|
+
deadline: Date.now() + bounds.timeoutMs,
|
|
174
|
+
});
|
|
175
|
+
const error = await client.complete(ask('x')).catch((e) => e);
|
|
176
|
+
expect(error.message).toBe(
|
|
177
|
+
'Formal AI returned HTTP 400: invalid_request: bad tools'
|
|
178
|
+
);
|
|
179
|
+
expect(error.details).toMatchObject({ httpStatus: 400, attempts: 1 });
|
|
180
|
+
expect(client.requests).toBe(1);
|
|
181
|
+
} finally {
|
|
182
|
+
error4xx.close();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('records the cause in the task evidence and changes nothing', async () => {
|
|
187
|
+
const cwd = receiptFixture();
|
|
188
|
+
dirs.push(cwd);
|
|
189
|
+
const before = snapshot(cwd);
|
|
190
|
+
const bounds = {
|
|
191
|
+
...resolveBounds({}),
|
|
192
|
+
maxRetries: 1,
|
|
193
|
+
effects: 'workspace-write',
|
|
194
|
+
};
|
|
195
|
+
const client = createClient({
|
|
196
|
+
baseUrl,
|
|
197
|
+
bounds,
|
|
198
|
+
deadline: Date.now() + bounds.timeoutMs,
|
|
199
|
+
});
|
|
200
|
+
const evidence = await runFormalAiTask({
|
|
201
|
+
request: 'synthetic: server error (captured #257)',
|
|
202
|
+
cwd,
|
|
203
|
+
env: { PATH: process.env.PATH },
|
|
204
|
+
bounds,
|
|
205
|
+
client,
|
|
206
|
+
});
|
|
207
|
+
expect(evidence.outcome).toMatchObject({
|
|
208
|
+
status: 'protocol-error',
|
|
209
|
+
exitCode: EXIT.PROTOCOL,
|
|
210
|
+
message: `Formal AI returned HTTP 500: formal_ai_error: ${CAUSE} (after 1 retry)`,
|
|
211
|
+
diagnostics: {
|
|
212
|
+
httpStatus: 500,
|
|
213
|
+
errorType: 'formal_ai_error',
|
|
214
|
+
errorMessage: CAUSE,
|
|
215
|
+
attempts: 2,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
expect(evidence.steps.filter((s) => s.kind === 'tool')).toEqual([]);
|
|
219
|
+
expect(snapshot(cwd)).toEqual(before);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('5xx diagnostics through the CLI (#257)', () => {
|
|
224
|
+
it('exits 70 with the cause in stderr, log and evidence', () => {
|
|
225
|
+
const checkout = fakeCheckout();
|
|
226
|
+
const root = tempDir('cli-257');
|
|
227
|
+
const cwd = receiptFixture();
|
|
228
|
+
dirs.push(checkout, root, cwd);
|
|
229
|
+
const before = snapshot(cwd);
|
|
230
|
+
const result = spawnSync(
|
|
231
|
+
process.execPath,
|
|
232
|
+
[CLI, '--use-formal-ai', '--', 'synthetic: server error (captured #257)'],
|
|
233
|
+
{
|
|
234
|
+
cwd,
|
|
235
|
+
env: cliEnv(root, {
|
|
236
|
+
START_FORMAL_AI_ROOT: checkout,
|
|
237
|
+
START_FORMAL_AI_MAX_RETRIES: '0',
|
|
238
|
+
}),
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
timeout: 60000,
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
expect(result.status).toBe(EXIT.PROTOCOL);
|
|
244
|
+
expect(result.stderr).toContain(CAUSE);
|
|
245
|
+
expect(sentRequests(root)).toHaveLength(1);
|
|
246
|
+
expect(snapshot(cwd)).toEqual(before);
|
|
247
|
+
const log = readLog(root);
|
|
248
|
+
const evidence = readEvidence(log);
|
|
249
|
+
expect(evidence.outcome).toMatchObject({
|
|
250
|
+
status: 'protocol-error',
|
|
251
|
+
exitCode: EXIT.PROTOCOL,
|
|
252
|
+
message: `Formal AI returned HTTP 500: formal_ai_error: ${CAUSE}`,
|
|
253
|
+
diagnostics: {
|
|
254
|
+
httpStatus: 500,
|
|
255
|
+
errorType: 'formal_ai_error',
|
|
256
|
+
errorMessage: CAUSE,
|
|
257
|
+
bodyPreview: CAPTURED_500,
|
|
258
|
+
attempts: 1,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
expect(evidence.formalAiRequests).toBe(1);
|
|
262
|
+
// The evidence never carries the environment.
|
|
263
|
+
expect(JSON.stringify(evidence)).not.toContain(process.env.PATH);
|
|
264
|
+
});
|
|
265
|
+
});
|