start-command 0.41.0 → 0.42.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.
@@ -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
+ });
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Issue #258: the structured read/edit/write tools of `--use-formal-ai`
3
+ * (src/lib/formal-ai-files.js) and their loop integration, offline.
4
+ */
5
+ const { describe, it, expect, afterAll } = require('bun:test');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const {
9
+ BOUND_DEFAULTS,
10
+ resolveBounds,
11
+ EXIT,
12
+ } = require('../src/lib/formal-ai-policy');
13
+ const files = require('../src/lib/formal-ai-files');
14
+ const { runFormalAiTask } = require('../src/lib/formal-ai');
15
+ const {
16
+ cleanup,
17
+ populate,
18
+ sink,
19
+ snapshot,
20
+ tempDir,
21
+ } = require('./fixtures/formal-ai/helpers.cjs');
22
+
23
+ const posix = process.platform !== 'win32';
24
+ const dirs = [];
25
+ afterAll(() => cleanup(...dirs));
26
+
27
+ const TEXT =
28
+ '// keep this comment\r\nconst a = 1;\t \n\n const b = 2; // b\nconst a2 = 1;\n';
29
+
30
+ function workspace(extra = {}) {
31
+ const dir = populate(tempDir('files'), {
32
+ 'a.js': TEXT,
33
+ 'sub/b.txt': 'one two one\n',
34
+ ...extra,
35
+ });
36
+ dirs.push(dir);
37
+ return dir;
38
+ }
39
+
40
+ function context(cwd, effects = 'workspace-write') {
41
+ return {
42
+ cwd,
43
+ effects,
44
+ observed: new Set(),
45
+ observedDirs: new Set(),
46
+ contents: new Map(),
47
+ };
48
+ }
49
+
50
+ const bounds = { maxFileBytes: BOUND_DEFAULTS.maxFileBytes.value };
51
+
52
+ /** Normalize, decide and run one call like the loop does. */
53
+ function call(tool, args, ctx, limits = bounds) {
54
+ const normalized = files.normalizeFileArgs(tool, args);
55
+ if (normalized.problem) {
56
+ return { problem: normalized.problem };
57
+ }
58
+ const decision = files.evaluateFileCall(tool, normalized.args, ctx);
59
+ if (!decision.allowed) {
60
+ return { refused: decision.reason };
61
+ }
62
+ return files.runFileCall(tool, normalized.args, decision, ctx, limits);
63
+ }
64
+
65
+ describe('file tool arguments (#258)', () => {
66
+ it('normalizes aliases and rejects malformed arguments', () => {
67
+ expect(
68
+ files.normalizeFileArgs('edit', {
69
+ filePath: 'a.js',
70
+ oldString: 'x',
71
+ newString: 'y',
72
+ })
73
+ ).toEqual({
74
+ args: { path: 'a.js', oldString: 'x', newString: 'y', replaceAll: false },
75
+ });
76
+ for (const [tool, args] of [
77
+ ['read', null],
78
+ ['read', []],
79
+ ['read', {}],
80
+ ['read', { path: 1 }],
81
+ ['read', { path: ' ' }],
82
+ ['read', { path: 'a\0b' }],
83
+ ['read', { path: 'a', file_path: 'b' }],
84
+ ['edit', { path: 'a', old_string: 'x' }],
85
+ ['edit', { path: 'a', old_string: 'x', new_string: 'y', replace_all: 1 }],
86
+ ['write', { path: 'a' }],
87
+ ]) {
88
+ expect(files.normalizeFileArgs(tool, args).problem).toBeString();
89
+ }
90
+ });
91
+ });
92
+
93
+ describe('file tool policy (#258)', () => {
94
+ it('refuses changes in read-only mode, outside paths and unobserved targets', () => {
95
+ const cwd = workspace();
96
+ const outside = tempDir('outside');
97
+ dirs.push(outside);
98
+ fs.writeFileSync(path.join(outside, 'secret.txt'), 'secret');
99
+ fs.symlinkSync(path.join(outside, 'secret.txt'), path.join(cwd, 'link'));
100
+ const ro = context(cwd, 'read-only');
101
+ expect(
102
+ call('edit', { path: 'a.js', old_string: 'a', new_string: 'b' }, ro)
103
+ ).toMatchObject({ refused: expect.stringContaining('read-only') });
104
+ expect(call('write', { path: 'a.js', content: '' }, ro).refused).toContain(
105
+ 'START_FORMAL_AI_EFFECTS=workspace-write'
106
+ );
107
+ const rw = context(cwd);
108
+ for (const target of [
109
+ '../x',
110
+ '/etc/passwd',
111
+ '~/.ssh/id_rsa',
112
+ 'link',
113
+ path.join(outside, 'secret.txt'),
114
+ ]) {
115
+ expect(call('read', { path: target }, rw).refused).toContain(
116
+ 'outside the working directory'
117
+ );
118
+ }
119
+ expect(call('read', { path: '.' }, rw).refused).toContain('not a file');
120
+ expect(
121
+ call('edit', { path: 'a.js', old_string: 'a', new_string: 'b' }, rw)
122
+ .refused
123
+ ).toBe('target "a.js" was not read before the change');
124
+ expect(call('write', { path: 'new.txt', content: 'x' }, rw).refused).toBe(
125
+ 'target "new.txt" was not observed before the change'
126
+ );
127
+ rw.observedDirs.add('.');
128
+ // A new file in a listed directory may be created, an existing one not.
129
+ expect(call('write', { path: 'a.js', content: 'x' }, rw).refused).toBe(
130
+ 'target "a.js" was not observed before the change'
131
+ );
132
+ expect(
133
+ call('write', { path: 'new.txt', content: 'x\n' }, rw)
134
+ ).toMatchObject({ exitCode: 0, verification: { verified: true } });
135
+ expect(fs.readFileSync(path.join(cwd, 'new.txt'), 'utf8')).toBe('x\n');
136
+ expect(fs.readFileSync(path.join(outside, 'secret.txt'), 'utf8')).toBe(
137
+ 'secret'
138
+ );
139
+ });
140
+ });
141
+
142
+ describe('read (#258)', () => {
143
+ it('returns the exact text and records the observation', () => {
144
+ const cwd = workspace();
145
+ const ctx = context(cwd, 'read-only');
146
+ const receipt = call('read', { path: './a.js' }, ctx);
147
+ expect(receipt).toMatchObject({
148
+ exitCode: 0,
149
+ result: TEXT,
150
+ bytes: Buffer.byteLength(TEXT),
151
+ });
152
+ expect(ctx.observed.has('a.js')).toBe(true);
153
+ expect(ctx.contents.get('a.js')).toBe(receipt.sha256);
154
+ });
155
+
156
+ it('fails for missing, directory, oversized and non-text files', () => {
157
+ const cwd = workspace({ 'bin.dat': '', 'big.txt': 'x'.repeat(64) });
158
+ fs.writeFileSync(path.join(cwd, 'bin.dat'), Buffer.from([0xff, 0xfe, 0]));
159
+ fs.writeFileSync(path.join(cwd, 'nul.txt'), 'a\0b');
160
+ const ctx = context(cwd, 'read-only');
161
+ const error = (name, limits) => call('read', { path: name }, ctx, limits);
162
+ expect(error('missing.txt')).toMatchObject({
163
+ exitCode: 1,
164
+ error: 'missing.txt: no such file',
165
+ });
166
+ expect(JSON.parse(error('missing.txt').result)).toEqual({
167
+ ok: false,
168
+ is_error: true,
169
+ error: 'missing.txt: no such file',
170
+ });
171
+ expect(error('sub').error).toBe('sub: is a directory, not a file');
172
+ expect(error('bin.dat').error).toBe('bin.dat: is not UTF-8 text');
173
+ expect(error('nul.txt').error).toBe('nul.txt: is not UTF-8 text');
174
+ expect(error('big.txt', { maxFileBytes: 10 }).error).toBe(
175
+ 'big.txt: has 64 bytes; START_FORMAL_AI_MAX_FILE_BYTES is 10'
176
+ );
177
+ expect(ctx.observed.size).toBe(0);
178
+ });
179
+ });
180
+
181
+ describe('edit (#258)', () => {
182
+ it('replaces one literal occurrence, preserving every other byte and the mode', () => {
183
+ const cwd = workspace();
184
+ const file = path.join(cwd, 'a.js');
185
+ if (posix) {
186
+ fs.chmodSync(file, 0o640);
187
+ }
188
+ const ctx = context(cwd);
189
+ call('read', { path: 'a.js' }, ctx);
190
+ const receipt = call(
191
+ 'edit',
192
+ {
193
+ path: 'a.js',
194
+ old_string: 'const b = 2;',
195
+ new_string: 'const b = $&3;',
196
+ },
197
+ ctx
198
+ );
199
+ expect(receipt).toMatchObject({
200
+ exitCode: 0,
201
+ result: '',
202
+ replacements: 1,
203
+ verification: { verified: true },
204
+ });
205
+ expect(fs.readFileSync(file, 'utf8')).toBe(
206
+ TEXT.replace('const b = 2;', () => 'const b = $&3;')
207
+ );
208
+ if (posix) {
209
+ expect(fs.statSync(file).mode & 0o777).toBe(0o640);
210
+ }
211
+ // No temporary file is left behind.
212
+ expect(fs.readdirSync(cwd).sort()).toEqual(['a.js', 'sub']);
213
+ });
214
+
215
+ it('keeps a byte order mark', () => {
216
+ const cwd = workspace({ 'bom.txt': '\ufefffirst\nsecond\n' });
217
+ const ctx = context(cwd);
218
+ expect(call('read', { path: 'bom.txt' }, ctx).result).toBe(
219
+ '\ufefffirst\nsecond\n'
220
+ );
221
+ const receipt = call(
222
+ 'edit',
223
+ { path: 'bom.txt', old_string: 'second', new_string: '2nd' },
224
+ ctx
225
+ );
226
+ expect(receipt.exitCode).toBe(0);
227
+ expect(fs.readFileSync(path.join(cwd, 'bom.txt'))).toEqual(
228
+ Buffer.from('\ufefffirst\n2nd\n', 'utf8')
229
+ );
230
+ });
231
+
232
+ it('refuses ambiguous, missing, empty and no-op replacements', () => {
233
+ const cwd = workspace();
234
+ const ctx = context(cwd);
235
+ call('read', { path: 'sub/b.txt' }, ctx);
236
+ const edit = (args) => call('edit', { path: 'sub/b.txt', ...args }, ctx);
237
+ expect(edit({ old_string: 'one', new_string: '1' }).error).toBe(
238
+ 'sub/b.txt: old_string occurs 2 times; give more surrounding text or set replace_all; nothing changed'
239
+ );
240
+ expect(edit({ old_string: 'three', new_string: '3' }).error).toContain(
241
+ 'not found'
242
+ );
243
+ expect(edit({ old_string: '', new_string: '3' }).error).toContain('empty');
244
+ expect(edit({ old_string: 'two', new_string: 'two' }).error).toContain(
245
+ 'the same'
246
+ );
247
+ expect(fs.readFileSync(path.join(cwd, 'sub/b.txt'), 'utf8')).toBe(
248
+ 'one two one\n'
249
+ );
250
+ expect(
251
+ edit({ old_string: 'one', new_string: '1', replace_all: true })
252
+ ).toMatchObject({ exitCode: 0, replacements: 2 });
253
+ expect(fs.readFileSync(path.join(cwd, 'sub/b.txt'), 'utf8')).toBe(
254
+ '1 two 1\n'
255
+ );
256
+ });
257
+
258
+ it('does not overwrite a file changed since it was read', () => {
259
+ const cwd = workspace();
260
+ const ctx = context(cwd);
261
+ call('read', { path: 'a.js' }, ctx);
262
+ fs.writeFileSync(path.join(cwd, 'a.js'), 'someone else\n');
263
+ expect(
264
+ call('edit', { path: 'a.js', old_string: 'else', new_string: 'x' }, ctx)
265
+ .error
266
+ ).toContain('changed since start read it');
267
+ expect(call('write', { path: 'a.js', content: 'x' }, ctx).error).toContain(
268
+ 'changed since start read it'
269
+ );
270
+ expect(fs.readFileSync(path.join(cwd, 'a.js'), 'utf8')).toBe(
271
+ 'someone else\n'
272
+ );
273
+ });
274
+
275
+ it('keeps the file size bound for the new text', () => {
276
+ const cwd = workspace();
277
+ const ctx = context(cwd);
278
+ const limits = { maxFileBytes: Buffer.byteLength(TEXT) };
279
+ call('read', { path: 'a.js' }, ctx, limits);
280
+ expect(
281
+ call(
282
+ 'edit',
283
+ {
284
+ path: 'a.js',
285
+ old_string: 'const a = 1;',
286
+ new_string: 'const a = 10;',
287
+ },
288
+ ctx,
289
+ limits
290
+ ).error
291
+ ).toContain('START_FORMAL_AI_MAX_FILE_BYTES');
292
+ expect(fs.readFileSync(path.join(cwd, 'a.js'), 'utf8')).toBe(TEXT);
293
+ });
294
+ });
295
+
296
+ describe('write (#258)', () => {
297
+ it('replaces a read file and refuses a missing directory', () => {
298
+ const cwd = workspace();
299
+ const ctx = context(cwd);
300
+ call('read', { path: 'a.js' }, ctx);
301
+ expect(
302
+ call('write', { path: 'a.js', content: 'new\n' }, ctx)
303
+ ).toMatchObject({
304
+ exitCode: 0,
305
+ bytes: 4,
306
+ verification: { verified: true },
307
+ });
308
+ expect(fs.readFileSync(path.join(cwd, 'a.js'), 'utf8')).toBe('new\n');
309
+ ctx.observedDirs.add('gone');
310
+ expect(call('write', { path: 'gone/x.txt', content: 'x' }, ctx).error).toBe(
311
+ 'gone/x.txt: its directory does not exist'
312
+ );
313
+ });
314
+ });
315
+
316
+ describe('START_FORMAL_AI_MAX_FILE_BYTES (#258)', () => {
317
+ it('defaults to 1 MiB and is bounded', () => {
318
+ expect(resolveBounds({}).maxFileBytes).toBe(1024 * 1024);
319
+ expect(
320
+ resolveBounds({ START_FORMAL_AI_MAX_FILE_BYTES: '4096' }).maxFileBytes
321
+ ).toBe(4096);
322
+ for (const value of ['0', '-1', 'abc', String(64 * 1024 * 1024)]) {
323
+ expect(() =>
324
+ resolveBounds({ START_FORMAL_AI_MAX_FILE_BYTES: value })
325
+ ).toThrow();
326
+ }
327
+ });
328
+ });
329
+
330
+ describe('file tools in the task loop (#258)', () => {
331
+ /** A scripted client: each turn returns the next message. */
332
+ function scripted(turns) {
333
+ let index = 0;
334
+ return {
335
+ complete: () => {
336
+ const message = turns[index++];
337
+ return Promise.resolve({
338
+ content: message.content || '',
339
+ finishReason: message.toolCalls ? 'tool_calls' : 'stop',
340
+ handler: null,
341
+ toolCalls: message.toolCalls || [],
342
+ raw: {},
343
+ });
344
+ },
345
+ };
346
+ }
347
+ const fileCall = (tool, args) => ({
348
+ id: `c_${tool}`,
349
+ name: tool,
350
+ tool,
351
+ args: files.normalizeFileArgs(tool, args).args,
352
+ arguments: JSON.stringify(args),
353
+ command: `${tool} ${args.path}`,
354
+ });
355
+
356
+ async function loop(turns, effects = 'workspace-write') {
357
+ const cwd = workspace();
358
+ const before = snapshot(cwd);
359
+ const evidence = await runFormalAiTask({
360
+ request: 'scripted',
361
+ cwd,
362
+ env: { PATH: process.env.PATH },
363
+ bounds: { ...resolveBounds({}), effects },
364
+ client: scripted(turns),
365
+ io: { stdout: sink(), stderr: sink(), note: () => {} },
366
+ });
367
+ return { cwd, before, after: snapshot(cwd), evidence };
368
+ }
369
+
370
+ it('stops with 77 before an unobserved edit runs', async () => {
371
+ const result = await loop([
372
+ {
373
+ toolCalls: [
374
+ fileCall('edit', { path: 'a.js', old_string: 'a', new_string: 'b' }),
375
+ ],
376
+ },
377
+ ]);
378
+ expect(result.evidence.outcome).toMatchObject({
379
+ status: 'refused',
380
+ exitCode: EXIT.REFUSED,
381
+ remainingObligations: [
382
+ 'the refused call did not run; nothing after it ran either',
383
+ ],
384
+ });
385
+ expect(result.after).toEqual(result.before);
386
+ });
387
+
388
+ it('refuses edits in read-only mode even after a read', async () => {
389
+ const result = await loop(
390
+ [
391
+ { toolCalls: [fileCall('read', { path: 'a.js' })] },
392
+ {
393
+ toolCalls: [
394
+ fileCall('edit', {
395
+ path: 'a.js',
396
+ old_string: 'a',
397
+ new_string: 'b',
398
+ }),
399
+ ],
400
+ },
401
+ ],
402
+ 'read-only'
403
+ );
404
+ expect(result.evidence.outcome.exitCode).toBe(EXIT.REFUSED);
405
+ expect(result.evidence.outcome.message).toContain('read-only');
406
+ expect(result.after).toEqual(result.before);
407
+ });
408
+
409
+ it('reports a failed edit with its real status and no change', async () => {
410
+ const result = await loop([
411
+ { toolCalls: [fileCall('read', { path: 'a.js' })] },
412
+ {
413
+ toolCalls: [
414
+ fileCall('edit', {
415
+ path: 'a.js',
416
+ old_string: 'zzz',
417
+ new_string: 'b',
418
+ }),
419
+ ],
420
+ },
421
+ { content: 'The edit failed.' },
422
+ ]);
423
+ expect(result.evidence.outcome).toMatchObject({
424
+ status: 'completed',
425
+ exitCode: 1,
426
+ remainingObligations: ['the last tool call failed'],
427
+ });
428
+ expect(result.after).toEqual(result.before);
429
+ });
430
+ });