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,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 };
|