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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Resolution and identity of the Formal AI runtime used by `--use-formal-ai`
3
+ * (issue #255).
4
+ *
5
+ * Formal AI is not published to npm, so start never installs or guesses a
6
+ * runtime. It uses exactly one of:
7
+ * - START_FORMAL_AI_ROOT: a link-assistant/formal-ai checkout whose
8
+ * JavaScript server (`js/server/main.mjs --agent-mode`) start spawns on a
9
+ * private loopback port with a private memory directory;
10
+ * - START_FORMAL_AI_URL: an already running Formal AI server, which reports
11
+ * no version, so it is used only with START_FORMAL_AI_ALLOW_UNVERIFIED=1.
12
+ * Without either, or with a runtime older than the qualified version, the
13
+ * mode fails; it never falls back to an LLM, the alias table or a shell.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const net = require('net');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const { spawn, spawnSync } = require('child_process');
21
+ const { EXIT } = require('./formal-ai-policy');
22
+
23
+ const { AbortSignal, setTimeout } = globalThis;
24
+
25
+ /** The Formal AI release this mode was qualified against. */
26
+ const MIN_FORMAL_AI_VERSION = '0.355.27';
27
+ const SERVER_ENTRY = path.join('js', 'server', 'main.mjs');
28
+ const API_PREFIX = '/api/openai/v1';
29
+ const INSTALL_HINT =
30
+ 'Install Formal AI from https://github.com/link-assistant/formal-ai ' +
31
+ '(git clone https://github.com/link-assistant/formal-ai.git) and set ' +
32
+ 'START_FORMAL_AI_ROOT to the checkout, or set START_FORMAL_AI_URL to a ' +
33
+ 'running server. See docs/formal-ai.md.';
34
+
35
+ class RuntimeUnavailableError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.exitCode = EXIT.UNAVAILABLE;
39
+ }
40
+ }
41
+
42
+ /** Compare dotted numeric versions; null when either is not one. */
43
+ function compareVersions(left, right) {
44
+ const parse = (v) =>
45
+ /^\d+(\.\d+)*$/.test(String(v || '')) ? v.split('.').map(Number) : null;
46
+ const a = parse(left);
47
+ const b = parse(right);
48
+ if (!a || !b) {
49
+ return null;
50
+ }
51
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
52
+ const diff = (a[i] || 0) - (b[i] || 0);
53
+ if (diff) {
54
+ return Math.sign(diff);
55
+ }
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ function readCargoVersion(root) {
61
+ try {
62
+ const text = fs.readFileSync(path.join(root, 'rust', 'Cargo.toml'), 'utf8');
63
+ const pkg = text.split(/^\[/m).find((s) => s.startsWith('package]'));
64
+ return (pkg && pkg.match(/^version\s*=\s*"([^"]+)"/m)?.[1]) || null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function readJsonVersion(file) {
71
+ try {
72
+ return JSON.parse(fs.readFileSync(file, 'utf8')).version || null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function git(root, args) {
79
+ const result = spawnSync('git', ['-C', root, ...args], {
80
+ encoding: 'utf8',
81
+ timeout: 10000,
82
+ });
83
+ return result.status === 0 ? result.stdout.trim() : null;
84
+ }
85
+
86
+ /**
87
+ * Source identity of a Formal AI checkout.
88
+ * @param {string} root - Checkout directory
89
+ * @param {string} node - Node executable used to run its server
90
+ * @returns {object} Identity
91
+ */
92
+ function checkoutIdentity(root, node) {
93
+ const commit = git(root, ['rev-parse', 'HEAD']);
94
+ const status = commit === null ? null : git(root, ['status', '--porcelain']);
95
+ const nodeVersion = spawnSync(node, ['--version'], {
96
+ encoding: 'utf8',
97
+ timeout: 10000,
98
+ });
99
+ return {
100
+ source: 'checkout',
101
+ root,
102
+ entry: SERVER_ENTRY,
103
+ version: readCargoVersion(root),
104
+ engineVersion: readJsonVersion(
105
+ path.join(root, 'packages', 'formal-ai-engine', 'package.json')
106
+ ),
107
+ commit,
108
+ dirty: status === null ? null : status.length > 0,
109
+ node,
110
+ nodeVersion: nodeVersion.status === 0 ? nodeVersion.stdout.trim() : null,
111
+ };
112
+ }
113
+
114
+ /** One-line rendering of an identity for terminals and logs. */
115
+ function describeIdentity(identity) {
116
+ if (identity.source === 'url') {
117
+ return `formal-ai at ${identity.url} (version unverified)`;
118
+ }
119
+ const commit = identity.commit
120
+ ? `${identity.commit.slice(0, 12)}${identity.dirty ? '+dirty' : ''}`
121
+ : 'no git commit';
122
+ return `formal-ai ${identity.version || 'unknown'} (${commit}, js server ${identity.entry}, node ${identity.nodeVersion || 'unknown'})`;
123
+ }
124
+
125
+ /**
126
+ * Check a resolved identity against the qualified version and an optional
127
+ * START_FORMAL_AI_EXPECTED_REVISION pin.
128
+ * @throws {RuntimeUnavailableError} Synchronously, or by rejecting
129
+ */
130
+ function qualifyIdentity(identity, env) {
131
+ const expected = (env.START_FORMAL_AI_EXPECTED_REVISION || '').trim();
132
+ if (identity.source === 'url') {
133
+ if (env.START_FORMAL_AI_ALLOW_UNVERIFIED !== '1') {
134
+ throw new RuntimeUnavailableError(
135
+ `START_FORMAL_AI_URL=${identity.url} does not report its Formal AI version, so it cannot be qualified. ` +
136
+ 'Set START_FORMAL_AI_ALLOW_UNVERIFIED=1 to use it anyway, or use START_FORMAL_AI_ROOT.'
137
+ );
138
+ }
139
+ return;
140
+ }
141
+ if (!identity.nodeVersion) {
142
+ throw new RuntimeUnavailableError(
143
+ `Cannot run "${identity.node} --version"; set START_FORMAL_AI_NODE to a Node.js executable.`
144
+ );
145
+ }
146
+ const order = compareVersions(identity.version, MIN_FORMAL_AI_VERSION);
147
+ if (order === null || order < 0) {
148
+ throw new RuntimeUnavailableError(
149
+ `Formal AI at ${identity.root} is version ${identity.version || 'unknown'}; ` +
150
+ `--use-formal-ai needs ${MIN_FORMAL_AI_VERSION} or newer. Update the checkout (git pull).`
151
+ );
152
+ }
153
+ if (
154
+ expected &&
155
+ !(identity.commit && identity.commit.startsWith(expected.toLowerCase()))
156
+ ) {
157
+ throw new RuntimeUnavailableError(
158
+ `Formal AI at ${identity.root} is at commit ${identity.commit || 'unknown'}, not START_FORMAL_AI_EXPECTED_REVISION=${expected}.`
159
+ );
160
+ }
161
+ }
162
+
163
+ function freePort() {
164
+ return new Promise((resolve, reject) => {
165
+ const server = net.createServer();
166
+ server.unref();
167
+ server.on('error', reject);
168
+ server.listen(0, '127.0.0.1', () => {
169
+ const { port } = server.address();
170
+ server.close(() => resolve(port));
171
+ });
172
+ });
173
+ }
174
+
175
+ async function waitReady(baseUrl, child, deadline) {
176
+ let lastError = 'no response';
177
+ while (Date.now() < deadline) {
178
+ if (child.exitCode !== null || child.signalCode !== null) {
179
+ throw new RuntimeUnavailableError(
180
+ `Formal AI server exited during startup (${child.exitCode ?? child.signalCode})`
181
+ );
182
+ }
183
+ try {
184
+ const response = await fetch(`${baseUrl}${API_PREFIX}/models`, {
185
+ signal: AbortSignal.timeout(2000),
186
+ });
187
+ if (response.ok) {
188
+ return;
189
+ }
190
+ lastError = `HTTP ${response.status}`;
191
+ } catch (error) {
192
+ lastError = error.message;
193
+ }
194
+ await new Promise((resolve) => setTimeout(resolve, 100));
195
+ }
196
+ throw new RuntimeUnavailableError(
197
+ `Formal AI server did not become ready in time (${lastError})`
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Resolve, qualify and (for a checkout) start the runtime.
203
+ * @param {object} options - {env, bounds}
204
+ * @returns {Promise<{baseUrl: string, identity: object, stop: Function}>}
205
+ * @throws {RuntimeUnavailableError} Synchronously, or by rejecting
206
+ */
207
+ function startRuntime({ env = process.env, bounds }) {
208
+ const url = (env.START_FORMAL_AI_URL || '').trim();
209
+ const root = (env.START_FORMAL_AI_ROOT || '').trim();
210
+ if (url && root) {
211
+ throw new RuntimeUnavailableError(
212
+ 'Set only one of START_FORMAL_AI_URL and START_FORMAL_AI_ROOT.'
213
+ );
214
+ }
215
+ if (url) {
216
+ // Plain http:// only, like the Rust edition: the runtime is a local
217
+ // loopback service and start ships no TLS client for it.
218
+ if (!/^http:\/\//.test(url)) {
219
+ throw new RuntimeUnavailableError(
220
+ `START_FORMAL_AI_URL must be an http:// URL, got "${url}"`
221
+ );
222
+ }
223
+ const identity = { source: 'url', url: url.replace(/\/+$/, '') };
224
+ qualifyIdentity(identity, env);
225
+ return Promise.resolve({ baseUrl: identity.url, identity, stop: () => {} });
226
+ }
227
+ if (!root) {
228
+ throw new RuntimeUnavailableError(
229
+ `--use-formal-ai needs a Formal AI runtime and has no fallback. ${INSTALL_HINT}`
230
+ );
231
+ }
232
+ const absoluteRoot = path.resolve(root);
233
+ if (!fs.existsSync(path.join(absoluteRoot, SERVER_ENTRY))) {
234
+ throw new RuntimeUnavailableError(
235
+ `START_FORMAL_AI_ROOT=${root} has no ${SERVER_ENTRY}. ${INSTALL_HINT}`
236
+ );
237
+ }
238
+ const node = (env.START_FORMAL_AI_NODE || 'node').trim();
239
+ const identity = checkoutIdentity(absoluteRoot, node);
240
+ qualifyIdentity(identity, env);
241
+ return spawnServer(absoluteRoot, node, identity, env, bounds);
242
+ }
243
+
244
+ async function spawnServer(root, node, identity, env, bounds) {
245
+ const port = await freePort();
246
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'start-formal-ai-'));
247
+ const memory = (env.START_FORMAL_AI_MEMORY_PATH || '').trim();
248
+ const childEnv = {
249
+ PATH: env.PATH || '',
250
+ HOME: home,
251
+ FORMAL_AI_MEMORY_PATH: memory || path.join(home, 'memory.lino'),
252
+ ...(env.LANG ? { LANG: env.LANG } : {}),
253
+ ...(env.SYSTEMROOT ? { SYSTEMROOT: env.SYSTEMROOT } : {}),
254
+ };
255
+ identity.memory = memory ? 'START_FORMAL_AI_MEMORY_PATH' : 'private';
256
+ const child = spawn(
257
+ node,
258
+ [
259
+ `--max-old-space-size=${bounds.maxMemoryMb}`,
260
+ path.join(root, SERVER_ENTRY),
261
+ '--agent-mode',
262
+ '--host',
263
+ '127.0.0.1',
264
+ '--port',
265
+ String(port),
266
+ '--silent',
267
+ ],
268
+ { cwd: root, env: childEnv, stdio: ['ignore', 'ignore', 'pipe'] }
269
+ );
270
+ let stderr = '';
271
+ child.stderr.on('data', (chunk) => {
272
+ stderr = (stderr + chunk).slice(-4096);
273
+ });
274
+ child.on('error', (error) => {
275
+ stderr += error.message;
276
+ });
277
+ const stop = () => {
278
+ if (child.exitCode === null && child.signalCode === null) {
279
+ child.kill('SIGKILL');
280
+ }
281
+ fs.rmSync(home, { recursive: true, force: true });
282
+ };
283
+ const baseUrl = `http://127.0.0.1:${port}`;
284
+ try {
285
+ await waitReady(baseUrl, child, Date.now() + bounds.startupTimeoutMs);
286
+ } catch (error) {
287
+ stop();
288
+ error.message += stderr.trim() ? `: ${stderr.trim().slice(-500)}` : '';
289
+ throw error;
290
+ }
291
+ identity.port = port;
292
+ return { baseUrl, identity, stop };
293
+ }
294
+
295
+ module.exports = {
296
+ API_PREFIX,
297
+ INSTALL_HINT,
298
+ MIN_FORMAL_AI_VERSION,
299
+ RuntimeUnavailableError,
300
+ compareVersions,
301
+ describeIdentity,
302
+ qualifyIdentity,
303
+ startRuntime,
304
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Runs one Formal AI tool call for `--use-formal-ai` (issue #255).
3
+ *
4
+ * The call is the argv vector the effect policy approved, spawned without a
5
+ * shell in the caller's working directory and with the execution's START_*
6
+ * environment. It gets no stdin, its own process group, a time limit and an
7
+ * output cap; stdout and stderr are mirrored live and the exit status is the
8
+ * real one (a signal maps to 128+N like a direct run).
9
+ */
10
+
11
+ const { spawn } = require('child_process');
12
+ const { resolveChildExitCode } = require('./exit-reason');
13
+
14
+ const { setTimeout, clearTimeout } = globalThis;
15
+
16
+ /**
17
+ * @param {object} options
18
+ * @param {string[]} options.argv - Approved argv
19
+ * @param {string} options.cwd - Working directory
20
+ * @param {object} options.env - Child environment
21
+ * @param {number} options.timeoutMs - Time limit of the call
22
+ * @param {number} options.maxOutputBytes - Captured/mirrored byte cap
23
+ * @param {object} [options.stdout] - Mirror for stdout (process.stdout)
24
+ * @param {object} [options.stderr] - Mirror for stderr (process.stderr)
25
+ * @param {Function} [options.onSpawn] - Receives a kill(signal) function
26
+ * @returns {Promise<object>} Receipt {argv, exitCode, signal, timedOut, stdout, stderr, truncated, durationMs, spawnError}
27
+ */
28
+ function runTool(options) {
29
+ const {
30
+ argv,
31
+ cwd,
32
+ env,
33
+ timeoutMs,
34
+ maxOutputBytes,
35
+ stdout: outMirror = null,
36
+ stderr: errMirror = null,
37
+ onSpawn = () => {},
38
+ } = options;
39
+ const started = Date.now();
40
+ return new Promise((resolve) => {
41
+ const groups = process.platform !== 'win32';
42
+ let child;
43
+ try {
44
+ child = spawn(argv[0], argv.slice(1), {
45
+ cwd,
46
+ env,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ detached: groups,
49
+ shell: false,
50
+ });
51
+ } catch (error) {
52
+ resolve(spawnFailure(argv, error, started));
53
+ return;
54
+ }
55
+ const captured = { stdout: [], stderr: [] };
56
+ let bytes = 0;
57
+ let truncated = false;
58
+ let timedOut = false;
59
+ let finished = false;
60
+ const kill = (signal = 'SIGTERM') => {
61
+ if (finished) {
62
+ return;
63
+ }
64
+ try {
65
+ if (groups && child.pid) {
66
+ process.kill(-child.pid, signal);
67
+ } else {
68
+ child.kill(signal);
69
+ }
70
+ } catch {
71
+ // already gone
72
+ }
73
+ };
74
+ onSpawn(kill);
75
+ const collect = (name, mirror) => (chunk) => {
76
+ if (bytes >= maxOutputBytes) {
77
+ truncated = true;
78
+ return;
79
+ }
80
+ const part = chunk.subarray(0, maxOutputBytes - bytes);
81
+ truncated = truncated || part.length < chunk.length;
82
+ bytes += part.length;
83
+ captured[name].push(part);
84
+ if (mirror) {
85
+ mirror.write(part);
86
+ }
87
+ };
88
+ child.stdout.on('data', collect('stdout', outMirror));
89
+ child.stderr.on('data', collect('stderr', errMirror));
90
+ const timer = setTimeout(() => {
91
+ timedOut = true;
92
+ kill('SIGTERM');
93
+ setTimeout(() => kill('SIGKILL'), 1000).unref();
94
+ }, timeoutMs);
95
+ child.on('error', (error) => {
96
+ clearTimeout(timer);
97
+ finished = true;
98
+ resolve(spawnFailure(argv, error, started));
99
+ });
100
+ child.on('close', (code, signal) => {
101
+ clearTimeout(timer);
102
+ if (finished) {
103
+ return;
104
+ }
105
+ finished = true;
106
+ resolve({
107
+ argv,
108
+ exitCode: resolveChildExitCode(code, signal),
109
+ signal: signal || null,
110
+ timedOut,
111
+ stdout: Buffer.concat(captured.stdout).toString('utf8'),
112
+ stderr: Buffer.concat(captured.stderr).toString('utf8'),
113
+ truncated,
114
+ durationMs: Date.now() - started,
115
+ spawnError: null,
116
+ });
117
+ });
118
+ });
119
+ }
120
+
121
+ function spawnFailure(argv, error, started) {
122
+ const missing = error.code === 'ENOENT';
123
+ return {
124
+ argv,
125
+ exitCode: missing ? 127 : 126,
126
+ signal: null,
127
+ timedOut: false,
128
+ stdout: '',
129
+ stderr: `${argv[0]}: ${missing ? 'command not found' : error.message}\n`,
130
+ truncated: false,
131
+ durationMs: Date.now() - started,
132
+ spawnError: error.code || error.message,
133
+ };
134
+ }
135
+
136
+ const UNTRUSTED = /<\/?untrusted_context>/g;
137
+
138
+ /**
139
+ * The result text sent back to Formal AI: the shell envelope its tool-result
140
+ * reader parses (Command/Directory/Output/Error/Exit Code), wrapped as
141
+ * untrusted context because the output is file data, not instructions.
142
+ * @param {object} receipt - runTool() receipt
143
+ * @param {string} command - Command line Formal AI asked for
144
+ * @param {string} cwd - Working directory
145
+ * @returns {string}
146
+ */
147
+ function formatToolResult(receipt, command, cwd) {
148
+ const clean = (text) => text.replace(UNTRUSTED, '[tag removed]');
149
+ const lines = [
150
+ `Command: ${command}`,
151
+ `Directory: ${cwd}`,
152
+ `Output: ${clean(receipt.stdout) || '(empty)'}${receipt.truncated ? '\n[output truncated by start]' : ''}`,
153
+ ];
154
+ // Always present: Formal AI reads the status fields backwards up to the
155
+ // last `Error:` line, so output lines cannot pose as an exit status.
156
+ lines.push(`Error: ${clean(receipt.stderr) || '(none)'}`);
157
+ if (receipt.timedOut) {
158
+ lines.push(`Signal: ${receipt.signal || 'SIGTERM'}`, 'Timeout: true');
159
+ } else {
160
+ lines.push(`Exit Code: ${receipt.exitCode}`);
161
+ }
162
+ return `<untrusted_context>\n${lines.join('\n')}\n</untrusted_context>`;
163
+ }
164
+
165
+ /**
166
+ * Result text for a call start refused before running it.
167
+ * @param {string} command - Command line Formal AI asked for
168
+ * @param {string} reason - Policy reason
169
+ * @param {string} cwd - Working directory
170
+ * @returns {string}
171
+ */
172
+ function formatRefusal(command, reason, cwd) {
173
+ return [
174
+ `Command: ${command}`,
175
+ `Directory: ${cwd}`,
176
+ 'Output: ',
177
+ `Error: refused by start: ${reason}`,
178
+ 'Exit Code: 126',
179
+ ].join('\n');
180
+ }
181
+
182
+ module.exports = { formatRefusal, formatToolResult, runTool };