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.
Files changed (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/eslint.config.mjs +32 -0
  3. package/package.json +9 -10
  4. package/src/bin/cli.js +18 -6
  5. package/src/lib/args-parser.js +33 -0
  6. package/src/lib/command-stream.js +19 -5
  7. package/src/lib/execution-chain.js +2 -2
  8. package/src/lib/execution-store.js +62 -44
  9. package/src/lib/formal-ai-cli.js +237 -0
  10. package/src/lib/formal-ai-client.js +166 -0
  11. package/src/lib/formal-ai-policy.js +501 -0
  12. package/src/lib/formal-ai-runtime.js +304 -0
  13. package/src/lib/formal-ai-tool.js +182 -0
  14. package/src/lib/formal-ai.js +329 -0
  15. package/src/lib/log-sanitizer.js +16 -137
  16. package/src/lib/usage.js +3 -1
  17. package/test/ci-workflow-invariants.js +18 -0
  18. package/test/command-stream-log.js +85 -0
  19. package/test/dependency-freshness.mjs +552 -0
  20. package/test/execution-store-clink.js +152 -0
  21. package/test/execution-store.js +49 -5
  22. package/test/fixtures/executions-lino-objects-codec-0.4.0.lino +1 -0
  23. package/test/fixtures/executions-lino-objects-codec-0.9.3.lino +26 -0
  24. package/test/fixtures/formal-ai/fake-server.cjs +17 -0
  25. package/test/fixtures/formal-ai/helpers.cjs +189 -0
  26. package/test/fixtures/formal-ai/receipts.json +776 -0
  27. package/test/fixtures/formal-ai/replay.cjs +144 -0
  28. package/test/javascript-first-ci.js +29 -0
  29. package/test/log-sanitizer.js +116 -47
  30. package/test/regression-187.js +4 -0
  31. package/test/regression-224.js +4 -2
  32. package/test/regression-231.js +20 -0
  33. package/test/regression-255-cli.js +294 -0
  34. package/test/regression-255-e2e.js +90 -0
  35. package/test/regression-255-loop.js +475 -0
  36. package/test/regression-255-policy.js +436 -0
  37. package/test/release-git.mjs +2 -1
  38. package/test/shared-golden.js +3 -0
@@ -0,0 +1,294 @@
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, with only the bash tool.
181
+ expect(result.requests[0].messages).toEqual([
182
+ { role: 'user', content: 'print list of files in current directory' },
183
+ ]);
184
+ expect(result.requests[0].tools.map((t) => t.function.name)).toEqual([
185
+ 'bash',
186
+ ]);
187
+
188
+ const log = readLog(result.root);
189
+ expect(log).toContain('Mode: --use-formal-ai (experimental)');
190
+ expect(log).toContain('Request: print list of files in current directory');
191
+ expect(log).toContain('Effects: read-only');
192
+ expect(log).toMatch(/Exit Code: 0\n$/);
193
+ const evidence = readEvidence(log);
194
+ expect(evidence).toMatchObject({
195
+ schema: EVIDENCE_SCHEMA,
196
+ request: 'print list of files in current directory',
197
+ effectsMode: 'read-only',
198
+ runtime: {
199
+ source: 'checkout',
200
+ version: '0.355.27',
201
+ entry: 'js/server/main.mjs',
202
+ memory: 'START_FORMAL_AI_MEMORY_PATH',
203
+ },
204
+ effects: { added: [], removed: [], changed: [], complete: true },
205
+ outcome: { status: 'completed', exitCode: 0, remainingObligations: [] },
206
+ formalAiRequests: 2,
207
+ });
208
+ expect(evidence.steps.map((s) => s.kind)).toEqual([
209
+ 'response',
210
+ 'tool',
211
+ 'response',
212
+ ]);
213
+ expect(evidence.steps[1]).toMatchObject({
214
+ command: 'ls',
215
+ argv: ['ls'],
216
+ decision: 'allowed',
217
+ effect: 'read',
218
+ exitCode: 0,
219
+ });
220
+
221
+ const db = fs.readFileSync(
222
+ path.join(result.root, 'app', 'executions.lino'),
223
+ 'utf8'
224
+ );
225
+ expect(db).toContain('print list of files in current directory');
226
+ expect(db).toContain('formal-ai');
227
+ });
228
+
229
+ it('never applies aliases or a shell to the request', () => {
230
+ const home = tempDir('home');
231
+ dirs.push(home);
232
+ fs.writeFileSync(path.join(home, 'canary'), 'keep');
233
+ let result = cli(['--use-formal-ai', '--', 'list', 'files', 'in', 'docs'], {
234
+ env: withRuntime,
235
+ });
236
+ expect(result.requests[0].messages[0].content).toBe('list files in docs');
237
+ expect(result.status).toBe(0);
238
+
239
+ result = cli(['--use-formal-ai', '--', 'ls; rm -rf ~'], {
240
+ env: { ...withRuntime, HOME: home },
241
+ });
242
+ expect(result.requests[0].messages[0].content).toBe('ls; rm -rf ~');
243
+ expect(result.status).toBe(77);
244
+ expect(result.stderr).toContain('needs shell syntax start does not run');
245
+ expect(fs.readFileSync(path.join(home, 'canary'), 'utf8')).toBe('keep');
246
+ expect(result.after).toEqual(result.before);
247
+ });
248
+
249
+ it('reports unsupported requests explicitly', () => {
250
+ const result = cli(['--use-formal-ai', '--', 'book me a flight to Paris'], {
251
+ env: withRuntime,
252
+ });
253
+ expect(result.status).toBe(65);
254
+ expect(result.stderr).toContain(
255
+ 'unsupported: Formal AI did not understand the request; no tool ran'
256
+ );
257
+ expect(result.after).toEqual(result.before);
258
+ });
259
+
260
+ it('ends with the signal status and evidence when cancelled', async () => {
261
+ const root = tempDir('cancel');
262
+ const cwd = receiptFixture();
263
+ const bin = fakeBin({ cat: 'exec sleep 30' });
264
+ dirs.push(root, cwd, bin);
265
+ const env = cliEnv(root, {
266
+ ...withRuntime,
267
+ PATH: `${bin}:${process.env.PATH}`,
268
+ });
269
+ const child = spawn(
270
+ process.execPath,
271
+ [CLI, '--use-formal-ai', '--', 'synthetic: read slowly'],
272
+ { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }
273
+ );
274
+ let stderr = '';
275
+ const status = await new Promise((resolve) => {
276
+ const timer = setTimeout(() => child.kill('SIGKILL'), 20000);
277
+ child.stderr.on('data', (chunk) => {
278
+ stderr += chunk;
279
+ if (stderr.includes('$ cat slow.txt') && !child.signalled) {
280
+ child.signalled = true;
281
+ setTimeout(() => child.kill('SIGTERM'), 200);
282
+ }
283
+ });
284
+ child.on('close', (code) => {
285
+ clearTimeout(timer);
286
+ resolve(code);
287
+ });
288
+ });
289
+ expect(status).toBe(143);
290
+ const evidence = readEvidence(readLog(root));
291
+ expect(evidence.outcome.status).toBe('cancelled');
292
+ expect(evidence.outcome.exitCode).toBe(143);
293
+ });
294
+ });
@@ -0,0 +1,90 @@
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. Use START_FORMAL_AI_NODE to pick the
11
+ * Node.js that runs the Formal AI server.
12
+ */
13
+ const { describe, it, expect, afterAll } = require('bun:test');
14
+ const { spawnSync } = require('child_process');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const {
18
+ CLI,
19
+ cleanup,
20
+ cliEnv,
21
+ populate,
22
+ readEvidence,
23
+ readLog,
24
+ snapshot,
25
+ tempDir,
26
+ } = require('./fixtures/formal-ai/helpers.cjs');
27
+
28
+ const enabled =
29
+ process.env.START_FORMAL_AI_E2E === '1' &&
30
+ Boolean(process.env.START_FORMAL_AI_ROOT) &&
31
+ process.platform !== 'win32';
32
+ const dirs = [];
33
+ afterAll(() => cleanup(...dirs));
34
+
35
+ function run(request, files) {
36
+ const root = tempDir('e2e');
37
+ const cwd = populate(tempDir('e2e-fixture'), files);
38
+ dirs.push(root, cwd);
39
+ const before = snapshot(cwd);
40
+ const env = cliEnv(root, {
41
+ START_FORMAL_AI_ROOT: process.env.START_FORMAL_AI_ROOT,
42
+ START_FORMAL_AI_NODE: process.env.START_FORMAL_AI_NODE || 'node',
43
+ });
44
+ delete env.START_FORMAL_AI_MEMORY_PATH;
45
+ const result = spawnSync(
46
+ process.execPath,
47
+ [CLI, '--use-formal-ai', '--', ...request.split(' ')],
48
+ { cwd, env, encoding: 'utf8', timeout: 120000 }
49
+ );
50
+ return { ...result, root, cwd, before, after: snapshot(cwd) };
51
+ }
52
+
53
+ describe.skipIf(!enabled)('--use-formal-ai with a real runtime (#255)', () => {
54
+ it(
55
+ 'prints the list of files in the current directory',
56
+ () => {
57
+ const result = run('print list of files in current directory', {
58
+ 'a b.txt': 'one\n',
59
+ 'ünï cødé.md': '# two\n',
60
+ 'sub dir': null,
61
+ plain: '',
62
+ });
63
+ expect(result.status).toBe(0);
64
+ const listed = result.stdout
65
+ .split('\n')
66
+ .filter((line) => line && fs.existsSync(path.join(result.cwd, line)));
67
+ expect(listed.sort()).toEqual(fs.readdirSync(result.cwd).sort());
68
+ expect(result.after).toEqual(result.before);
69
+ const evidence = readEvidence(readLog(result.root));
70
+ expect(evidence.runtime.source).toBe('checkout');
71
+ expect(evidence.outcome.status).toBe('completed');
72
+ expect(evidence.effects).toMatchObject({
73
+ added: [],
74
+ removed: [],
75
+ changed: [],
76
+ });
77
+ },
78
+ { timeout: 150000 }
79
+ );
80
+
81
+ it(
82
+ 'fails explicitly on an unsupported request',
83
+ () => {
84
+ const result = run('book me a flight to Paris', { 'a.txt': 'a' });
85
+ expect(result.status).toBe(65);
86
+ expect(result.after).toEqual(result.before);
87
+ },
88
+ { timeout: 150000 }
89
+ );
90
+ });