start-command 0.40.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.
- package/CHANGELOG.md +22 -0
- package/eslint.config.mjs +31 -0
- package/package.json +3 -3
- package/src/bin/cli.js +11 -1
- package/src/lib/args-parser.js +33 -0
- package/src/lib/formal-ai-cli.js +237 -0
- package/src/lib/formal-ai-client.js +390 -0
- package/src/lib/formal-ai-files.js +384 -0
- package/src/lib/formal-ai-policy.js +508 -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 +482 -0
- package/src/lib/usage.js +5 -1
- 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-258.json +431 -0
- package/test/fixtures/formal-ai/receipts.json +776 -0
- package/test/fixtures/formal-ai/replay.cjs +194 -0
- package/test/regression-255-cli.js +296 -0
- package/test/regression-255-e2e.js +167 -0
- package/test/regression-255-loop.js +480 -0
- package/test/regression-255-policy.js +436 -0
- package/test/regression-257.js +265 -0
- package/test/regression-258-files.js +430 -0
- package/test/regression-258.js +443 -0
- package/test/shared-golden.js +3 -0
|
@@ -0,0 +1,194 @@
|
|
|
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, and a
|
|
10
|
+
* `{status, text}` reply an HTTP error with that exact body.
|
|
11
|
+
*
|
|
12
|
+
* Receipts of issue #258 (receipts-258.json) also record the tool names start
|
|
13
|
+
* offered; they answer only a request offering exactly those tools, so the
|
|
14
|
+
* released Bash-only contract and the structured file tools replay apart.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const http = require('http');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const load = (name) =>
|
|
22
|
+
JSON.parse(fs.readFileSync(path.join(__dirname, name), 'utf8'));
|
|
23
|
+
const { receipts } = load('receipts.json');
|
|
24
|
+
const edits = load('receipts-258.json');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The HTTP 500 body a released start discarded (issue #257), captured from a
|
|
28
|
+
* Formal AI 0.355.28 runtime whose local policy forbids its Rust grammar WASM.
|
|
29
|
+
*/
|
|
30
|
+
const CAPTURED_500 =
|
|
31
|
+
'{"error":{"message":"Aborted(Error: LocalProductWasmForbidden). Build with -sASSERTIONS for more info.","type":"formal_ai_error"}}';
|
|
32
|
+
|
|
33
|
+
function toolCall(command, id = `call_${Math.random().toString(16).slice(2)}`) {
|
|
34
|
+
return {
|
|
35
|
+
role: 'assistant',
|
|
36
|
+
content: 'Let me run the requested command to get that for you.',
|
|
37
|
+
tool_calls: [
|
|
38
|
+
{
|
|
39
|
+
id,
|
|
40
|
+
type: 'function',
|
|
41
|
+
function: { name: 'bash', arguments: JSON.stringify({ command }) },
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const answer = (content) => ({ role: 'assistant', content });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* SYNTHETIC scenarios: request -> (assistantTurns, messages, body) ->
|
|
51
|
+
* message | status | {status, text}.
|
|
52
|
+
* A constant list matched by string equality: request text only selects among
|
|
53
|
+
* these entries and is never used as a property key, so `constructor`,
|
|
54
|
+
* `toString` and similar names reach the "did not understand" reply.
|
|
55
|
+
*/
|
|
56
|
+
const SYNTHETIC = Object.freeze(
|
|
57
|
+
Object.entries({
|
|
58
|
+
// Never stops asking for more reads: exhausts the step budget.
|
|
59
|
+
'synthetic: loop forever': () => toolCall('pwd'),
|
|
60
|
+
// A tool start must not run.
|
|
61
|
+
'synthetic: call an unknown tool': () => ({
|
|
62
|
+
role: 'assistant',
|
|
63
|
+
content: '',
|
|
64
|
+
tool_calls: [
|
|
65
|
+
{
|
|
66
|
+
id: 'call_x',
|
|
67
|
+
type: 'function',
|
|
68
|
+
function: { name: 'python', arguments: '{"code":"1"}' },
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
}),
|
|
72
|
+
// A long-running read (a fake `cat` from the test PATH sleeps).
|
|
73
|
+
'synthetic: read slowly': (turns) =>
|
|
74
|
+
turns === 0 ? toolCall('cat slow.txt') : answer('done'),
|
|
75
|
+
// A tool that does not exist on PATH.
|
|
76
|
+
'synthetic: run a missing tool': (turns) =>
|
|
77
|
+
turns === 0 ? toolCall('tree') : answer('The `tree` command failed.'),
|
|
78
|
+
// Writes outside the observed workspace.
|
|
79
|
+
'synthetic: overwrite without looking': () => toolCall('touch README.md'),
|
|
80
|
+
'synthetic: write outside': () => toolCall('touch ../escape.txt'),
|
|
81
|
+
// A server error on every request.
|
|
82
|
+
'synthetic: server error': () => 500,
|
|
83
|
+
// The captured runtime failure of issue #257 on every request.
|
|
84
|
+
'synthetic: server error (captured #257)': () => ({
|
|
85
|
+
status: 500,
|
|
86
|
+
text: CAPTURED_500,
|
|
87
|
+
}),
|
|
88
|
+
// A 503 whose body echoes credentials and control characters.
|
|
89
|
+
'synthetic: server error with secrets': () => ({
|
|
90
|
+
status: 503,
|
|
91
|
+
text: JSON.stringify({
|
|
92
|
+
error: {
|
|
93
|
+
type: 'upstream_error',
|
|
94
|
+
message: `proxy refused Authorization: Bearer sk-test-0123456789\u0007 api_key=sk-live-abcdef`,
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
// A write that claims success but leaves no trace (fake `mkdir` on PATH).
|
|
99
|
+
'synthetic: unverified mkdir': (turns) =>
|
|
100
|
+
turns === 0 ? toolCall('test ! -e out') : toolCall('mkdir out'),
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
function reply(body) {
|
|
105
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
106
|
+
const request = (messages.find((m) => m.role === 'user') || {}).content || '';
|
|
107
|
+
const turns = messages.filter((m) => m.role === 'assistant').length;
|
|
108
|
+
const scenario = SYNTHETIC.find(([name]) => name === request);
|
|
109
|
+
if (scenario) {
|
|
110
|
+
const [, respond] = scenario;
|
|
111
|
+
return respond(turns, messages, body);
|
|
112
|
+
}
|
|
113
|
+
const offered = (Array.isArray(body.tools) ? body.tools : [])
|
|
114
|
+
.map((tool) => tool && tool.function && tool.function.name)
|
|
115
|
+
.join(',');
|
|
116
|
+
const edit = edits.receipts.find(
|
|
117
|
+
(r) => r.request === request && r.offered.join(',') === offered
|
|
118
|
+
);
|
|
119
|
+
if (edit && edit.responses[turns]) {
|
|
120
|
+
return edit.responses[turns].message;
|
|
121
|
+
}
|
|
122
|
+
const receipt = receipts.find((r) => r.request === request);
|
|
123
|
+
if (receipt && receipt.responses[turns]) {
|
|
124
|
+
return receipt.responses[turns].message;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
role: 'assistant',
|
|
128
|
+
content: "I didn't understand you. Could you rephrase?",
|
|
129
|
+
thinking_steps: [{ step: 'dispatch_handler', detail: 'unknown' }],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* HTTP server speaking the agent-mode subset start uses.
|
|
135
|
+
* @param {string} [log] - File that receives every request body as a JSON line
|
|
136
|
+
* @returns {import('http').Server}
|
|
137
|
+
*/
|
|
138
|
+
function createFakeServer(log) {
|
|
139
|
+
return http.createServer((req, res) => {
|
|
140
|
+
let text = '';
|
|
141
|
+
req.on('data', (chunk) => {
|
|
142
|
+
text += chunk;
|
|
143
|
+
});
|
|
144
|
+
req.on('end', () => {
|
|
145
|
+
const send = (status, payload) => {
|
|
146
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
147
|
+
res.end(JSON.stringify(payload));
|
|
148
|
+
};
|
|
149
|
+
if (req.method === 'GET' && req.url.endsWith('/models')) {
|
|
150
|
+
send(200, { object: 'list', data: [{ id: 'formal-ai' }] });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (req.method !== 'POST' || !req.url.endsWith('/chat/completions')) {
|
|
154
|
+
send(404, { error: 'not found' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const body = JSON.parse(text);
|
|
158
|
+
if (log) {
|
|
159
|
+
fs.appendFileSync(log, `${JSON.stringify(body)}\n`);
|
|
160
|
+
}
|
|
161
|
+
const message = reply(body);
|
|
162
|
+
if (typeof message === 'number') {
|
|
163
|
+
send(message, { error: 'synthetic failure' });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (typeof message.status === 'number') {
|
|
167
|
+
res.writeHead(message.status, { 'content-type': 'application/json' });
|
|
168
|
+
res.end(message.text);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
send(200, {
|
|
172
|
+
id: 'chatcmpl_fake',
|
|
173
|
+
object: 'chat.completion',
|
|
174
|
+
model: 'formal-ai',
|
|
175
|
+
choices: [
|
|
176
|
+
{
|
|
177
|
+
index: 0,
|
|
178
|
+
message,
|
|
179
|
+
finish_reason: message.tool_calls ? 'tool_calls' : 'stop',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
CAPTURED_500,
|
|
189
|
+
SYNTHETIC,
|
|
190
|
+
createFakeServer,
|
|
191
|
+
edits,
|
|
192
|
+
receipts,
|
|
193
|
+
reply,
|
|
194
|
+
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #255: `$ --use-formal-ai -- <request>` end to end through the CLI,
|
|
3
|
+
* with an offline Formal AI checkout (test/fixtures/formal-ai) started exactly
|
|
4
|
+
* like a real one. Covers parsing of `--`, option-like text and shell
|
|
5
|
+
* metacharacters, the no-fallback guarantee, logs, evidence and cancellation.
|
|
6
|
+
*/
|
|
7
|
+
const { describe, it, expect, afterAll } = require('bun:test');
|
|
8
|
+
const { spawn, spawnSync } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
12
|
+
const { requestText, EVIDENCE_SCHEMA } = require('../src/lib/formal-ai-cli');
|
|
13
|
+
const {
|
|
14
|
+
CLI,
|
|
15
|
+
cleanup,
|
|
16
|
+
cliEnv,
|
|
17
|
+
fakeBin,
|
|
18
|
+
fakeCheckout,
|
|
19
|
+
readEvidence,
|
|
20
|
+
readLog,
|
|
21
|
+
receiptFixture,
|
|
22
|
+
sentRequests,
|
|
23
|
+
snapshot,
|
|
24
|
+
tempDir,
|
|
25
|
+
} = require('./fixtures/formal-ai/helpers.cjs');
|
|
26
|
+
|
|
27
|
+
const posix = process.platform !== 'win32';
|
|
28
|
+
const dirs = [];
|
|
29
|
+
const checkout = fakeCheckout();
|
|
30
|
+
const oldCheckout = fakeCheckout({ version: '0.355.26' });
|
|
31
|
+
dirs.push(checkout, oldCheckout);
|
|
32
|
+
|
|
33
|
+
afterAll(() => cleanup(...dirs));
|
|
34
|
+
|
|
35
|
+
/** Run the CLI in a fresh fixture with an isolated app folder. */
|
|
36
|
+
function cli(args, { env = {}, root = tempDir('cli'), cwd } = {}) {
|
|
37
|
+
dirs.push(root);
|
|
38
|
+
const dir = cwd || receiptFixture();
|
|
39
|
+
dirs.push(dir);
|
|
40
|
+
const before = snapshot(dir);
|
|
41
|
+
const result = spawnSync(process.execPath, [CLI, ...args], {
|
|
42
|
+
cwd: dir,
|
|
43
|
+
env: cliEnv(root, env),
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
timeout: 60000,
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
...result,
|
|
49
|
+
root,
|
|
50
|
+
cwd: dir,
|
|
51
|
+
before,
|
|
52
|
+
after: snapshot(dir),
|
|
53
|
+
requests: sentRequests(root),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const withRuntime = { START_FORMAL_AI_ROOT: checkout };
|
|
58
|
+
|
|
59
|
+
describe('--use-formal-ai parsing (#255)', () => {
|
|
60
|
+
it('keeps everything after -- as request text', () => {
|
|
61
|
+
const cases = [
|
|
62
|
+
[
|
|
63
|
+
['print', 'list', 'of', 'files', 'in', 'current', 'directory'],
|
|
64
|
+
'print list of files in current directory',
|
|
65
|
+
],
|
|
66
|
+
[['--help'], '--help'],
|
|
67
|
+
[['--isolated', 'docker', 'ls'], '--isolated docker ls'],
|
|
68
|
+
[['ls;', 'rm', '-rf', '~'], 'ls; rm -rf ~'],
|
|
69
|
+
[
|
|
70
|
+
['ls; rm -rf ~ && echo $(id) | sh > x'],
|
|
71
|
+
'ls; rm -rf ~ && echo $(id) | sh > x',
|
|
72
|
+
],
|
|
73
|
+
[['--', 'what is', '--version?'], '-- what is --version?'],
|
|
74
|
+
];
|
|
75
|
+
for (const [words, text] of cases) {
|
|
76
|
+
const result = parseArgs(['--use-formal-ai', '--', ...words]);
|
|
77
|
+
expect(result.wrapperOptions.useFormalAi).toBe(true);
|
|
78
|
+
expect(result.rawCommand).toEqual(words);
|
|
79
|
+
expect(requestText(result.rawCommand)).toBe(text);
|
|
80
|
+
// Option-like request text never becomes a start option.
|
|
81
|
+
expect(result.wrapperOptions.isolated).toBeFalsy();
|
|
82
|
+
expect(result.wrapperOptions.help).toBeFalsy();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('is off unless requested', () => {
|
|
87
|
+
expect(parseArgs(['--', 'ls']).wrapperOptions.useFormalAi).toBe(false);
|
|
88
|
+
expect(
|
|
89
|
+
parseArgs(['ls', '--use-formal-ai']).wrapperOptions.useFormalAi
|
|
90
|
+
).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects combinations the experimental mode does not support', () => {
|
|
94
|
+
for (const args of [
|
|
95
|
+
['--use-formal-ai', '--isolated', 'docker', '--', 'ls'],
|
|
96
|
+
['--use-formal-ai', '--detached', '--isolated', 'screen', '--', 'ls'],
|
|
97
|
+
['--use-formal-ai', '--use-command-stream', '--', 'ls'],
|
|
98
|
+
]) {
|
|
99
|
+
expect(() => parseArgs(args)).toThrow(
|
|
100
|
+
/--use-formal-ai cannot be combined with/
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe.skipIf(!posix)('--use-formal-ai never falls back (#255)', () => {
|
|
107
|
+
it('runs nothing without a qualified runtime', () => {
|
|
108
|
+
const cases = [
|
|
109
|
+
[{}, 'needs a Formal AI runtime and has no fallback'],
|
|
110
|
+
[{ START_FORMAL_AI_ROOT: oldCheckout }, 'needs 0.355.27 or newer'],
|
|
111
|
+
[
|
|
112
|
+
{ ...withRuntime, START_FORMAL_AI_EXPECTED_REVISION: 'abc123' },
|
|
113
|
+
'not START_FORMAL_AI_EXPECTED_REVISION=abc123',
|
|
114
|
+
],
|
|
115
|
+
[{ START_FORMAL_AI_URL: 'http://127.0.0.1:9' }, 'cannot be qualified'],
|
|
116
|
+
];
|
|
117
|
+
for (const [env, message] of cases) {
|
|
118
|
+
// "list files in docs" is also a built-in alias for `ls docs`.
|
|
119
|
+
const result = cli(
|
|
120
|
+
['--use-formal-ai', '--', 'list', 'files', 'in', 'docs'],
|
|
121
|
+
{
|
|
122
|
+
env,
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
expect(result.status).toBe(69);
|
|
126
|
+
expect(result.stderr).toContain(message);
|
|
127
|
+
expect(result.stderr).toContain(
|
|
128
|
+
'No command was run: --use-formal-ai never falls back to aliases, a shell or an LLM.'
|
|
129
|
+
);
|
|
130
|
+
expect(result.stdout).not.toContain('guide.md');
|
|
131
|
+
expect(result.after).toEqual(result.before);
|
|
132
|
+
expect(fs.existsSync(path.join(result.root, 'logs'))).toBe(false);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('rejects an empty, overlong or misconfigured request before starting', () => {
|
|
137
|
+
let result = cli(['--use-formal-ai', '--', 'list', 'files'], {
|
|
138
|
+
env: { ...withRuntime, START_FORMAL_AI_MAX_REQUEST_CHARS: '5' },
|
|
139
|
+
});
|
|
140
|
+
expect(result.status).toBe(64);
|
|
141
|
+
expect(result.stderr).toContain('START_FORMAL_AI_MAX_REQUEST_CHARS is 5');
|
|
142
|
+
result = cli(['--use-formal-ai', '--', 'ls'], {
|
|
143
|
+
env: { ...withRuntime, START_FORMAL_AI_MAX_STEPS: 'many' },
|
|
144
|
+
});
|
|
145
|
+
expect(result.status).toBe(78);
|
|
146
|
+
expect(result.requests).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe.skipIf(!posix)('--use-formal-ai end to end (#255)', () => {
|
|
151
|
+
it('lists the working directory, leaves it unchanged and keeps evidence', () => {
|
|
152
|
+
const result = cli(
|
|
153
|
+
[
|
|
154
|
+
'--use-formal-ai',
|
|
155
|
+
'--',
|
|
156
|
+
'print',
|
|
157
|
+
'list',
|
|
158
|
+
'of',
|
|
159
|
+
'files',
|
|
160
|
+
'in',
|
|
161
|
+
'current',
|
|
162
|
+
'directory',
|
|
163
|
+
],
|
|
164
|
+
{ env: withRuntime }
|
|
165
|
+
);
|
|
166
|
+
expect(result.status).toBe(0);
|
|
167
|
+
for (const name of fs.readdirSync(result.cwd)) {
|
|
168
|
+
expect(result.stdout).toContain(name);
|
|
169
|
+
}
|
|
170
|
+
expect(result.stdout).toContain(
|
|
171
|
+
'--use-formal-ai -- print list of files in current directory'
|
|
172
|
+
);
|
|
173
|
+
expect(result.stderr).toContain('[formal-ai] runtime: formal-ai 0.355.27');
|
|
174
|
+
expect(result.stderr).toContain('[formal-ai] $ ls');
|
|
175
|
+
expect(result.stderr).toContain(
|
|
176
|
+
'[formal-ai] completed: finished after 1 tool call(s); no file changed'
|
|
177
|
+
);
|
|
178
|
+
expect(result.after).toEqual(result.before);
|
|
179
|
+
|
|
180
|
+
// The request went to Formal AI verbatim; read-only mode offers bash and
|
|
181
|
+
// the structured read tool, never the change tools (#258).
|
|
182
|
+
expect(result.requests[0].messages).toEqual([
|
|
183
|
+
{ role: 'user', content: 'print list of files in current directory' },
|
|
184
|
+
]);
|
|
185
|
+
expect(result.requests[0].tools.map((t) => t.function.name)).toEqual([
|
|
186
|
+
'bash',
|
|
187
|
+
'read',
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const log = readLog(result.root);
|
|
191
|
+
expect(log).toContain('Mode: --use-formal-ai (experimental)');
|
|
192
|
+
expect(log).toContain('Request: print list of files in current directory');
|
|
193
|
+
expect(log).toContain('Effects: read-only');
|
|
194
|
+
expect(log).toMatch(/Exit Code: 0\n$/);
|
|
195
|
+
const evidence = readEvidence(log);
|
|
196
|
+
expect(evidence).toMatchObject({
|
|
197
|
+
schema: EVIDENCE_SCHEMA,
|
|
198
|
+
request: 'print list of files in current directory',
|
|
199
|
+
effectsMode: 'read-only',
|
|
200
|
+
runtime: {
|
|
201
|
+
source: 'checkout',
|
|
202
|
+
version: '0.355.27',
|
|
203
|
+
entry: 'js/server/main.mjs',
|
|
204
|
+
memory: 'START_FORMAL_AI_MEMORY_PATH',
|
|
205
|
+
},
|
|
206
|
+
effects: { added: [], removed: [], changed: [], complete: true },
|
|
207
|
+
outcome: { status: 'completed', exitCode: 0, remainingObligations: [] },
|
|
208
|
+
formalAiRequests: 2,
|
|
209
|
+
});
|
|
210
|
+
expect(evidence.steps.map((s) => s.kind)).toEqual([
|
|
211
|
+
'response',
|
|
212
|
+
'tool',
|
|
213
|
+
'response',
|
|
214
|
+
]);
|
|
215
|
+
expect(evidence.steps[1]).toMatchObject({
|
|
216
|
+
command: 'ls',
|
|
217
|
+
argv: ['ls'],
|
|
218
|
+
decision: 'allowed',
|
|
219
|
+
effect: 'read',
|
|
220
|
+
exitCode: 0,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const db = fs.readFileSync(
|
|
224
|
+
path.join(result.root, 'app', 'executions.lino'),
|
|
225
|
+
'utf8'
|
|
226
|
+
);
|
|
227
|
+
expect(db).toContain('print list of files in current directory');
|
|
228
|
+
expect(db).toContain('formal-ai');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('never applies aliases or a shell to the request', () => {
|
|
232
|
+
const home = tempDir('home');
|
|
233
|
+
dirs.push(home);
|
|
234
|
+
fs.writeFileSync(path.join(home, 'canary'), 'keep');
|
|
235
|
+
let result = cli(['--use-formal-ai', '--', 'list', 'files', 'in', 'docs'], {
|
|
236
|
+
env: withRuntime,
|
|
237
|
+
});
|
|
238
|
+
expect(result.requests[0].messages[0].content).toBe('list files in docs');
|
|
239
|
+
expect(result.status).toBe(0);
|
|
240
|
+
|
|
241
|
+
result = cli(['--use-formal-ai', '--', 'ls; rm -rf ~'], {
|
|
242
|
+
env: { ...withRuntime, HOME: home },
|
|
243
|
+
});
|
|
244
|
+
expect(result.requests[0].messages[0].content).toBe('ls; rm -rf ~');
|
|
245
|
+
expect(result.status).toBe(77);
|
|
246
|
+
expect(result.stderr).toContain('needs shell syntax start does not run');
|
|
247
|
+
expect(fs.readFileSync(path.join(home, 'canary'), 'utf8')).toBe('keep');
|
|
248
|
+
expect(result.after).toEqual(result.before);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('reports unsupported requests explicitly', () => {
|
|
252
|
+
const result = cli(['--use-formal-ai', '--', 'book me a flight to Paris'], {
|
|
253
|
+
env: withRuntime,
|
|
254
|
+
});
|
|
255
|
+
expect(result.status).toBe(65);
|
|
256
|
+
expect(result.stderr).toContain(
|
|
257
|
+
'unsupported: Formal AI did not understand the request; no tool ran'
|
|
258
|
+
);
|
|
259
|
+
expect(result.after).toEqual(result.before);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('ends with the signal status and evidence when cancelled', async () => {
|
|
263
|
+
const root = tempDir('cancel');
|
|
264
|
+
const cwd = receiptFixture();
|
|
265
|
+
const bin = fakeBin({ cat: 'exec sleep 30' });
|
|
266
|
+
dirs.push(root, cwd, bin);
|
|
267
|
+
const env = cliEnv(root, {
|
|
268
|
+
...withRuntime,
|
|
269
|
+
PATH: `${bin}:${process.env.PATH}`,
|
|
270
|
+
});
|
|
271
|
+
const child = spawn(
|
|
272
|
+
process.execPath,
|
|
273
|
+
[CLI, '--use-formal-ai', '--', 'synthetic: read slowly'],
|
|
274
|
+
{ cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
275
|
+
);
|
|
276
|
+
let stderr = '';
|
|
277
|
+
const status = await new Promise((resolve) => {
|
|
278
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 20000);
|
|
279
|
+
child.stderr.on('data', (chunk) => {
|
|
280
|
+
stderr += chunk;
|
|
281
|
+
if (stderr.includes('$ cat slow.txt') && !child.signalled) {
|
|
282
|
+
child.signalled = true;
|
|
283
|
+
setTimeout(() => child.kill('SIGTERM'), 200);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
child.on('close', (code) => {
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
resolve(code);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
expect(status).toBe(143);
|
|
292
|
+
const evidence = readEvidence(readLog(root));
|
|
293
|
+
expect(evidence.outcome.status).toBe('cancelled');
|
|
294
|
+
expect(evidence.outcome.exitCode).toBe(143);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #255: opt-in end-to-end test of `--use-formal-ai` against a REAL
|
|
3
|
+
* Formal AI checkout. Skipped unless both are set:
|
|
4
|
+
*
|
|
5
|
+
* START_FORMAL_AI_E2E=1 START_FORMAL_AI_ROOT=/path/to/formal-ai \
|
|
6
|
+
* bun test ./test/regression-255-e2e.js
|
|
7
|
+
*
|
|
8
|
+
* It runs the issue's demo in an isolated directory whose names contain
|
|
9
|
+
* spaces and Unicode, compares the listing with an independent readdir and
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
const { describe, it, expect, afterAll } = require('bun:test');
|
|
17
|
+
const { spawnSync } = require('child_process');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const {
|
|
21
|
+
CLI,
|
|
22
|
+
cleanup,
|
|
23
|
+
cliEnv,
|
|
24
|
+
populate,
|
|
25
|
+
readEvidence,
|
|
26
|
+
readLog,
|
|
27
|
+
snapshot,
|
|
28
|
+
tempDir,
|
|
29
|
+
} = require('./fixtures/formal-ai/helpers.cjs');
|
|
30
|
+
|
|
31
|
+
const enabled =
|
|
32
|
+
process.env.START_FORMAL_AI_E2E === '1' &&
|
|
33
|
+
Boolean(process.env.START_FORMAL_AI_ROOT) &&
|
|
34
|
+
process.platform !== 'win32';
|
|
35
|
+
const dirs = [];
|
|
36
|
+
afterAll(() => cleanup(...dirs));
|
|
37
|
+
|
|
38
|
+
function run(request, files, extra = {}) {
|
|
39
|
+
const root = tempDir('e2e');
|
|
40
|
+
const cwd = populate(tempDir('e2e-fixture'), files);
|
|
41
|
+
dirs.push(root, cwd);
|
|
42
|
+
const before = snapshot(cwd);
|
|
43
|
+
const env = cliEnv(root, {
|
|
44
|
+
START_FORMAL_AI_ROOT: process.env.START_FORMAL_AI_ROOT,
|
|
45
|
+
START_FORMAL_AI_NODE: process.env.START_FORMAL_AI_NODE || 'node',
|
|
46
|
+
...extra,
|
|
47
|
+
});
|
|
48
|
+
delete env.START_FORMAL_AI_MEMORY_PATH;
|
|
49
|
+
const result = spawnSync(
|
|
50
|
+
process.execPath,
|
|
51
|
+
[CLI, '--use-formal-ai', '--', ...request.split(' ')],
|
|
52
|
+
{ cwd, env, encoding: 'utf8', timeout: 120000 }
|
|
53
|
+
);
|
|
54
|
+
return { ...result, root, cwd, before, after: snapshot(cwd) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe.skipIf(!enabled)('--use-formal-ai with a real runtime (#255)', () => {
|
|
58
|
+
it(
|
|
59
|
+
'prints the list of files in the current directory',
|
|
60
|
+
() => {
|
|
61
|
+
const result = run('print list of files in current directory', {
|
|
62
|
+
'a b.txt': 'one\n',
|
|
63
|
+
'ünï cødé.md': '# two\n',
|
|
64
|
+
'sub dir': null,
|
|
65
|
+
plain: '',
|
|
66
|
+
});
|
|
67
|
+
expect(result.status).toBe(0);
|
|
68
|
+
const listed = result.stdout
|
|
69
|
+
.split('\n')
|
|
70
|
+
.filter((line) => line && fs.existsSync(path.join(result.cwd, line)));
|
|
71
|
+
expect(listed.sort()).toEqual(fs.readdirSync(result.cwd).sort());
|
|
72
|
+
expect(result.after).toEqual(result.before);
|
|
73
|
+
const evidence = readEvidence(readLog(result.root));
|
|
74
|
+
expect(evidence.runtime.source).toBe('checkout');
|
|
75
|
+
expect(evidence.outcome.status).toBe('completed');
|
|
76
|
+
expect(evidence.effects).toMatchObject({
|
|
77
|
+
added: [],
|
|
78
|
+
removed: [],
|
|
79
|
+
changed: [],
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
{ timeout: 150000 }
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it(
|
|
86
|
+
'fails explicitly on an unsupported request',
|
|
87
|
+
() => {
|
|
88
|
+
const result = run('book me a flight to Paris', { 'a.txt': 'a' });
|
|
89
|
+
expect(result.status).toBe(65);
|
|
90
|
+
expect(result.after).toEqual(result.before);
|
|
91
|
+
},
|
|
92
|
+
{ timeout: 150000 }
|
|
93
|
+
);
|
|
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
|
+
);
|