fraim 2.0.289 → 2.0.291
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,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MANAGED_NODE_VERSION = void 0;
|
|
7
|
+
exports.npmExecutableName = npmExecutableName;
|
|
8
|
+
exports.nodeExecutableName = nodeExecutableName;
|
|
9
|
+
exports.findExecutableOnPath = findExecutableOnPath;
|
|
10
|
+
exports.hasWorkingNodeAndNpm = hasWorkingNodeAndNpm;
|
|
11
|
+
exports.isManagedNodeVersionComplete = isManagedNodeVersionComplete;
|
|
12
|
+
exports.nodeVersionedDirName = nodeVersionedDirName;
|
|
13
|
+
exports.ensureManagedNpm = ensureManagedNpm;
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const os_1 = __importDefault(require("os"));
|
|
16
|
+
const path_1 = __importDefault(require("path"));
|
|
17
|
+
const https_1 = __importDefault(require("https"));
|
|
18
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
19
|
+
const managed_agent_paths_1 = require("./managed-agent-paths");
|
|
20
|
+
// Issue #1415 (extended): a genuinely GUI-only FRAIM Hub install — the native installer
|
|
21
|
+
// downloaded from the website, never a CLI — has no working `npm`/`npx` anywhere. First-run's
|
|
22
|
+
// `embeddedDesktop` branch (session-service.ts) deliberately skips installing one (it only needs
|
|
23
|
+
// Electron's own bundled Node for FRAIM's own CLI/MCP shim, via ELECTRON_RUN_AS_NODE), and the
|
|
24
|
+
// separate CLI installer's portable-Node bootstrap (scripts/installer/fraim-install-win.template.cmd)
|
|
25
|
+
// never runs for a desktop-only user. That left two features silently unable to do their real job
|
|
26
|
+
// on such a machine: `hub-app-materializer.ts`'s npm install (this issue), and agent-CLI
|
|
27
|
+
// installation (`managed-agent-install.ts`), which also shells out to a bare `npm`.
|
|
28
|
+
//
|
|
29
|
+
// This module is the one place that guarantees a real, usable `npm`/`node` exists, for both
|
|
30
|
+
// consumers: prefer whatever is already on system PATH (common for technical users), then an
|
|
31
|
+
// already-downloaded managed runtime (shared with the CLI installer's `~/.fraim/node/` convention
|
|
32
|
+
// via `managed-agent-paths.ts` — reused, not duplicated, so a user who has ever run the `fraim` CLI
|
|
33
|
+
// never pays for a second download), and only download a portable Node.js distribution from
|
|
34
|
+
// nodejs.org as a last resort.
|
|
35
|
+
//
|
|
36
|
+
// `~/.fraim/node/` is a *shared* directory — it already holds agent-CLI shims and their own
|
|
37
|
+
// `node_modules/` (managed-agent-install.ts) — not a directory this module exclusively owns like
|
|
38
|
+
// `electron-dist.ts` owns its versioned Electron dist. So the extract-once-reuse-forever,
|
|
39
|
+
// staging-then-atomic-rename transaction that pattern established is applied here to just the one
|
|
40
|
+
// versioned subfolder a Node.js archive naturally extracts as (`node-v<version>-<platform>-<arch>/`,
|
|
41
|
+
// exactly what `getPortableNodeBinPath()` already knows how to find), never to the shared root
|
|
42
|
+
// itself — so a concurrent or failed download can never disturb sibling content already there.
|
|
43
|
+
exports.MANAGED_NODE_VERSION = '20.11.1'; // matches NODE_VERSION in fraim-install-win.template.cmd
|
|
44
|
+
function npmExecutableName(platform = process.platform) {
|
|
45
|
+
return platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
46
|
+
}
|
|
47
|
+
function nodeExecutableName(platform = process.platform) {
|
|
48
|
+
return platform === 'win32' ? 'node.exe' : 'node';
|
|
49
|
+
}
|
|
50
|
+
/** First executable named `name` found by walking `pathValue`'s directories, or null. */
|
|
51
|
+
function findExecutableOnPath(name, pathValue = process.env.PATH) {
|
|
52
|
+
for (const dir of (pathValue ?? '').split(path_1.default.delimiter).filter(Boolean)) {
|
|
53
|
+
const candidate = path_1.default.join(dir, name);
|
|
54
|
+
try {
|
|
55
|
+
if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isFile())
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Unreadable entry — treat as absent, not fatal.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** Whether `dir` already has a real, runnable node+npm — any version; this function's job is "a working npm", not "this exact one". */
|
|
65
|
+
function hasWorkingNodeAndNpm(dir, platform = process.platform) {
|
|
66
|
+
return fs_1.default.existsSync(path_1.default.join(dir, npmExecutableName(platform))) && fs_1.default.existsSync(path_1.default.join(dir, nodeExecutableName(platform)));
|
|
67
|
+
}
|
|
68
|
+
/** Whether `dir` (a specific versioned bin dir) holds a complete extraction for exactly `version`. */
|
|
69
|
+
function isManagedNodeVersionComplete(dir, version, platform = process.platform) {
|
|
70
|
+
try {
|
|
71
|
+
const recorded = fs_1.default.readFileSync(path_1.default.join(dir, 'fraim-managed-node-version'), 'utf8').trim();
|
|
72
|
+
return recorded === version && hasWorkingNodeAndNpm(dir, platform);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function nodePlatformName(platform) {
|
|
79
|
+
if (platform === 'darwin')
|
|
80
|
+
return 'darwin';
|
|
81
|
+
if (platform === 'win32')
|
|
82
|
+
return 'win';
|
|
83
|
+
return 'linux';
|
|
84
|
+
}
|
|
85
|
+
function nodeArchiveExtension(platform) {
|
|
86
|
+
return platform === 'win32' ? 'zip' : 'tar.gz';
|
|
87
|
+
}
|
|
88
|
+
// `request.version` is always `MANAGED_NODE_VERSION` today, but it is a public option on
|
|
89
|
+
// `ensureManagedNpm`, and this string is joined into a filesystem path below (`nodeRoot/<this>`)
|
|
90
|
+
// that a failed extraction's cleanup recursively deletes. Anchoring on an exact-release shape
|
|
91
|
+
// before it ever reaches a path, same guard shape as `hub-app-materializer.ts`'s
|
|
92
|
+
// `assertPathSafeVersion`, so a hostile or malformed override can never escape `nodeRoot`.
|
|
93
|
+
const SAFE_NODE_VERSION = /^\d+\.\d+\.\d+$/;
|
|
94
|
+
function assertPathSafeNodeVersion(version) {
|
|
95
|
+
if (!SAFE_NODE_VERSION.test(version)) {
|
|
96
|
+
throw new Error(`Refusing to use "${version}" as a managed Node.js version: expected an exact release such as 20.11.1.`);
|
|
97
|
+
}
|
|
98
|
+
return version;
|
|
99
|
+
}
|
|
100
|
+
/** The exact folder name Node's own official archive extracts as — also this module's versioned subfolder name under the shared `~/.fraim/node/` root. */
|
|
101
|
+
function nodeVersionedDirName(request) {
|
|
102
|
+
return `node-v${assertPathSafeNodeVersion(request.version)}-${nodePlatformName(request.platform)}-${request.arch}`;
|
|
103
|
+
}
|
|
104
|
+
function nodeArchiveFileName(request) {
|
|
105
|
+
return `${nodeVersionedDirName(request)}.${nodeArchiveExtension(request.platform)}`;
|
|
106
|
+
}
|
|
107
|
+
function nodeDistBaseUrl(version) {
|
|
108
|
+
return `https://nodejs.org/dist/v${version}`;
|
|
109
|
+
}
|
|
110
|
+
function httpsGetBuffer(url) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
https_1.default.get(url, { headers: { 'user-agent': 'fraim-hub-managed-node-runtime' } }, (res) => {
|
|
113
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
114
|
+
httpsGetBuffer(res.headers.location).then(resolve, reject);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (res.statusCode !== 200) {
|
|
118
|
+
res.resume();
|
|
119
|
+
reject(new Error(`GET ${url} failed: HTTP ${res.statusCode}`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const chunks = [];
|
|
123
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
124
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
125
|
+
res.on('error', reject);
|
|
126
|
+
}).on('error', reject);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function httpsDownloadToFile(url, destPath) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const file = fs_1.default.createWriteStream(destPath);
|
|
132
|
+
https_1.default.get(url, { headers: { 'user-agent': 'fraim-hub-managed-node-runtime' } }, (res) => {
|
|
133
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
134
|
+
file.close();
|
|
135
|
+
httpsDownloadToFile(res.headers.location, destPath).then(resolve, reject);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (res.statusCode !== 200) {
|
|
139
|
+
res.resume();
|
|
140
|
+
file.close();
|
|
141
|
+
reject(new Error(`GET ${url} failed: HTTP ${res.statusCode}`));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
res.pipe(file);
|
|
145
|
+
file.on('finish', () => file.close(() => resolve()));
|
|
146
|
+
}).on('error', (err) => {
|
|
147
|
+
file.close();
|
|
148
|
+
reject(err);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Verify `filePath` against the sha256 line naming `fileName` in the release's own
|
|
154
|
+
* `SHASUMS256.txt` — the same integrity check `@electron/get` performs for Electron zips
|
|
155
|
+
* (`electron-dist.ts`), applied to Node's own equivalent publishing convention.
|
|
156
|
+
*/
|
|
157
|
+
async function verifyNodeArchiveChecksum(filePath, fileName, version) {
|
|
158
|
+
const shasums = (await httpsGetBuffer(`${nodeDistBaseUrl(version)}/SHASUMS256.txt`)).toString('utf8');
|
|
159
|
+
const line = shasums.split('\n').find((entry) => entry.trim().endsWith(fileName));
|
|
160
|
+
if (!line) {
|
|
161
|
+
throw new Error(`SHASUMS256.txt for Node v${version} has no entry for ${fileName}; refusing to trust an unverified download`);
|
|
162
|
+
}
|
|
163
|
+
const expected = line.trim().split(/\s+/)[0];
|
|
164
|
+
const actual = crypto_1.default.createHash('sha256').update(fs_1.default.readFileSync(filePath)).digest('hex');
|
|
165
|
+
if (actual !== expected) {
|
|
166
|
+
throw new Error(`checksum mismatch for ${fileName}: expected ${expected}, got ${actual}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const defaultDownloadNodeArchive = async (request) => {
|
|
170
|
+
const fileName = nodeArchiveFileName(request);
|
|
171
|
+
const url = `${nodeDistBaseUrl(request.version)}/${fileName}`;
|
|
172
|
+
const tempPath = path_1.default.join(os_1.default.tmpdir(), `fraim-managed-node-${process.pid}-${Date.now()}-${fileName}`);
|
|
173
|
+
await httpsDownloadToFile(url, tempPath);
|
|
174
|
+
try {
|
|
175
|
+
await verifyNodeArchiveChecksum(tempPath, fileName, request.version);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
fs_1.default.rmSync(tempPath, { force: true });
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
return tempPath;
|
|
182
|
+
};
|
|
183
|
+
const defaultExtractNodeArchive = async (archivePath, destDir, request) => {
|
|
184
|
+
if (request.platform === 'win32') {
|
|
185
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
186
|
+
const extract = require('extract-zip');
|
|
187
|
+
await extract(archivePath, { dir: destDir });
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
191
|
+
const tar = require('tar');
|
|
192
|
+
await tar.x({ file: archivePath, cwd: destDir });
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Download and extract Node v`request.version` into `nodeRoot/<node-vX.Y.Z-platform-arch>/`,
|
|
197
|
+
* touching nothing else in `nodeRoot`. Staging-then-atomic-rename, scoped to just that one
|
|
198
|
+
* versioned subfolder (mirrors `electron-dist.ts`'s transaction, applied to a shared parent
|
|
199
|
+
* directory instead of one this module exclusively owns): a concurrent download that already
|
|
200
|
+
* finished is kept rather than clobbered, and a failed extraction never leaves a versioned
|
|
201
|
+
* subfolder behind that a later launch could mistake for complete.
|
|
202
|
+
*/
|
|
203
|
+
async function downloadAndPromoteVersionedNodeDir(args) {
|
|
204
|
+
const { nodeRoot, request, downloadArchive, extractArchive } = args;
|
|
205
|
+
const destDir = path_1.default.join(nodeRoot, nodeVersionedDirName(request));
|
|
206
|
+
const stagingDir = `${destDir}.staging-${process.pid}`;
|
|
207
|
+
fs_1.default.mkdirSync(nodeRoot, { recursive: true });
|
|
208
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
209
|
+
const archivePath = await downloadArchive(request);
|
|
210
|
+
try {
|
|
211
|
+
await extractArchive(archivePath, stagingDir, request);
|
|
212
|
+
// The archive's own top-level folder (matching nodeVersionedDirName) may land either as the
|
|
213
|
+
// extraction root itself (tar) or as one nested level inside it (some zip extractors preserve
|
|
214
|
+
// the archive's internal folder) — handle both without assuming either.
|
|
215
|
+
const nestedCandidate = path_1.default.join(stagingDir, nodeVersionedDirName(request));
|
|
216
|
+
const extractedRoot = fs_1.default.existsSync(nestedCandidate) ? nestedCandidate : stagingDir;
|
|
217
|
+
fs_1.default.writeFileSync(path_1.default.join(extractedRoot, 'fraim-managed-node-version'), request.version, 'utf8');
|
|
218
|
+
if (!isManagedNodeVersionComplete(extractedRoot, request.version, request.platform)) {
|
|
219
|
+
throw new Error(`extracted Node v${request.version} is missing ${npmExecutableName(request.platform)}/${nodeExecutableName(request.platform)}; refusing to promote an incomplete install`);
|
|
220
|
+
}
|
|
221
|
+
// A concurrent launch may have finished downloading the same version already; the winner
|
|
222
|
+
// keeps its copy rather than being clobbered by this one.
|
|
223
|
+
if (!isManagedNodeVersionComplete(destDir, request.version, request.platform)) {
|
|
224
|
+
fs_1.default.rmSync(destDir, { recursive: true, force: true });
|
|
225
|
+
try {
|
|
226
|
+
fs_1.default.renameSync(extractedRoot, destDir);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (!isManagedNodeVersionComplete(destDir, request.version, request.platform))
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
fs_1.default.rmSync(archivePath, { force: true });
|
|
236
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
return destDir;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Resolve a working `npm` executable, preferring (in order): system PATH, an already-downloaded
|
|
242
|
+
* managed runtime (shared with the CLI installer's convention, any version — this function's job
|
|
243
|
+
* is "a working npm", not "this exact one"), or a freshly downloaded one. Only throws for a
|
|
244
|
+
* genuine download/extraction failure; callers should treat that as "materialization unavailable
|
|
245
|
+
* this launch," not a hard failure — never for "nothing found," which falls through to download.
|
|
246
|
+
*/
|
|
247
|
+
async function ensureManagedNpm(options = {}) {
|
|
248
|
+
const version = options.version ?? exports.MANAGED_NODE_VERSION;
|
|
249
|
+
const platform = options.platform ?? process.platform;
|
|
250
|
+
const arch = options.arch ?? process.arch;
|
|
251
|
+
const systemNpm = findExecutableOnPath(npmExecutableName(platform), options.systemPath ?? process.env.PATH);
|
|
252
|
+
if (systemNpm) {
|
|
253
|
+
return { npmPath: systemNpm, binDir: path_1.default.dirname(systemNpm), source: 'system' };
|
|
254
|
+
}
|
|
255
|
+
const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)();
|
|
256
|
+
if (hasWorkingNodeAndNpm(existingBinDir, platform)) {
|
|
257
|
+
return { npmPath: path_1.default.join(existingBinDir, npmExecutableName(platform)), binDir: existingBinDir, source: 'managed-existing' };
|
|
258
|
+
}
|
|
259
|
+
const request = { version, platform, arch };
|
|
260
|
+
options.onProgress?.('Preparing FRAIM\'s bundled Node.js runtime (one-time, ~30MB)...');
|
|
261
|
+
const binDir = await downloadAndPromoteVersionedNodeDir({
|
|
262
|
+
nodeRoot: (0, managed_agent_paths_1.getManagedNodeRoot)(),
|
|
263
|
+
request,
|
|
264
|
+
downloadArchive: options.downloadArchive ?? defaultDownloadNodeArchive,
|
|
265
|
+
extractArchive: options.extractArchive ?? defaultExtractNodeArchive,
|
|
266
|
+
});
|
|
267
|
+
options.onProgress?.('FRAIM Node.js runtime ready.');
|
|
268
|
+
return { npmPath: path_1.default.join(binDir, npmExecutableName(platform)), binDir, source: 'managed-downloaded' };
|
|
269
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Test Evidence Contract Validation (Issue #1419, Change 4)
|
|
4
|
+
*
|
|
5
|
+
* The #1419 spike found that a written "never report a result you have not
|
|
6
|
+
* read" instruction is not reliably obeyed even by an agent that has just
|
|
7
|
+
* stated, in its own text, that the work is outstanding
|
|
8
|
+
* (docs/evidence/1419-spike-findings.md, Round 2). This adds a mechanical
|
|
9
|
+
* backstop that does not depend on the model remembering the instruction: a
|
|
10
|
+
* phase whose job-frontmatter declares `requiresTestEvidence: true` must
|
|
11
|
+
* supply `findings.testEvidence` — the actual observed result (exit code,
|
|
12
|
+
* duration, and either a timestamp or the log path read) — before that phase
|
|
13
|
+
* can be marked `complete`.
|
|
14
|
+
*
|
|
15
|
+
* This is a schema-shape check only. It cannot verify the numbers are
|
|
16
|
+
* truthful, only that the agent committed to a concrete, falsifiable claim
|
|
17
|
+
* instead of a bare "passed" — the same "operationalize the existing
|
|
18
|
+
* Supervision Contract line as a resume prompt rather than leave it to the
|
|
19
|
+
* agent to remember" pattern the RFC uses for Change 3's continuation message.
|
|
20
|
+
*
|
|
21
|
+
* Lives in src/core so both the local MCP proxy and evals can import the same
|
|
22
|
+
* contract without cross-layer dependencies — same pattern as
|
|
23
|
+
* quality-evidence.ts and handoff-contracts.ts.
|
|
24
|
+
*
|
|
25
|
+
* Primary call site: src/local-mcp-server/stdio-server.ts seekMentoring
|
|
26
|
+
* handler, after the existing handoff-contract enforcement block.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.isTestEvidencePhase = isTestEvidencePhase;
|
|
30
|
+
exports.validateTestEvidence = validateTestEvidence;
|
|
31
|
+
exports.validateTestEvidenceContract = validateTestEvidenceContract;
|
|
32
|
+
exports.buildTestEvidenceRejectionMessage = buildTestEvidenceRejectionMessage;
|
|
33
|
+
/**
|
|
34
|
+
* Returns true when `currentPhase`'s job-frontmatter declares
|
|
35
|
+
* `requiresTestEvidence: true`. No hardcoded phase-name list: any job/phase
|
|
36
|
+
* combination opts in by declaring the flag on its own `phases` entry.
|
|
37
|
+
*/
|
|
38
|
+
function isTestEvidencePhase(currentPhase, phases) {
|
|
39
|
+
return phases?.[currentPhase]?.requiresTestEvidence === true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validates findings.testEvidence. Returns null if valid, or an array of
|
|
43
|
+
* human-readable error strings describing what is wrong.
|
|
44
|
+
*
|
|
45
|
+
* Required minimum shape:
|
|
46
|
+
* {
|
|
47
|
+
* exitCode: number,
|
|
48
|
+
* durationMs: number,
|
|
49
|
+
* timestamp: string, // either this...
|
|
50
|
+
* logPath: string, // ...or this
|
|
51
|
+
* }
|
|
52
|
+
*/
|
|
53
|
+
function validateTestEvidence(value) {
|
|
54
|
+
if (value === undefined || value === null) {
|
|
55
|
+
return ['findings.testEvidence is missing'];
|
|
56
|
+
}
|
|
57
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
58
|
+
return ['findings.testEvidence must be an object'];
|
|
59
|
+
}
|
|
60
|
+
const obj = value;
|
|
61
|
+
const errors = [];
|
|
62
|
+
if (typeof obj.exitCode !== 'number' || !Number.isFinite(obj.exitCode)) {
|
|
63
|
+
errors.push(`findings.testEvidence.exitCode must be a number (got ${obj.exitCode === undefined ? 'missing' : typeof obj.exitCode})`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof obj.durationMs !== 'number' || !Number.isFinite(obj.durationMs) || obj.durationMs < 0) {
|
|
66
|
+
errors.push(`findings.testEvidence.durationMs must be a non-negative number (got ${obj.durationMs === undefined ? 'missing' : typeof obj.durationMs})`);
|
|
67
|
+
}
|
|
68
|
+
const hasTimestamp = typeof obj.timestamp === 'string' && obj.timestamp.trim().length > 0;
|
|
69
|
+
const hasLogPath = typeof obj.logPath === 'string' && obj.logPath.trim().length > 0;
|
|
70
|
+
if (!hasTimestamp && !hasLogPath) {
|
|
71
|
+
errors.push('findings.testEvidence must include a non-empty timestamp or logPath — the point of this evidence is proving the result was actually observed, not merely narrated');
|
|
72
|
+
}
|
|
73
|
+
return errors.length > 0 ? errors : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Orchestrator: called from the seekMentoring handler. Returns an empty
|
|
77
|
+
* array when no contract is violated for this call (phase doesn't require
|
|
78
|
+
* test evidence, or the call is not a completion).
|
|
79
|
+
*/
|
|
80
|
+
function validateTestEvidenceContract(args) {
|
|
81
|
+
const phase = args.currentPhase ?? '';
|
|
82
|
+
if (args.status !== 'complete')
|
|
83
|
+
return [];
|
|
84
|
+
if (!isTestEvidencePhase(phase, args.phases))
|
|
85
|
+
return [];
|
|
86
|
+
return validateTestEvidence((args.findings ?? {}).testEvidence) ?? [];
|
|
87
|
+
}
|
|
88
|
+
const TEST_EVIDENCE_SCHEMA = `\`\`\`javascript
|
|
89
|
+
findings: {
|
|
90
|
+
testEvidence: {
|
|
91
|
+
exitCode: 0, // the real exit code you read, not assumed
|
|
92
|
+
durationMs: 12345, // how long the run actually took
|
|
93
|
+
timestamp: "2026-08-29T18:00:00Z", // when you observed it...
|
|
94
|
+
logPath: "path/to/run.log" // ...and/or where the log lives
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
\`\`\``;
|
|
98
|
+
/**
|
|
99
|
+
* Builds the rejection message returned to the agent when the test evidence
|
|
100
|
+
* contract is violated.
|
|
101
|
+
*/
|
|
102
|
+
function buildTestEvidenceRejectionMessage(currentPhase, errors) {
|
|
103
|
+
const errorBullets = errors.map((e) => `- ${e}`).join('\n');
|
|
104
|
+
return [
|
|
105
|
+
`❌ **seekMentoring rejected** at phase \`${currentPhase}\`.`,
|
|
106
|
+
'',
|
|
107
|
+
`This phase's Outcome depends on a test/validation run. Report the actual observed result, ` +
|
|
108
|
+
`not a narrated success, before it can complete. The following problems were found:`,
|
|
109
|
+
'',
|
|
110
|
+
errorBullets,
|
|
111
|
+
'',
|
|
112
|
+
'Required minimum schema:',
|
|
113
|
+
'',
|
|
114
|
+
TEST_EVIDENCE_SCHEMA,
|
|
115
|
+
'',
|
|
116
|
+
'The job is **not** marked complete. Run the validation, read its real exit code and duration, ' +
|
|
117
|
+
'and resubmit with `findings.testEvidence` populated.',
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
@@ -52,6 +52,7 @@ const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launche
|
|
|
52
52
|
const script_sync_utils_1 = require("../cli/utils/script-sync-utils");
|
|
53
53
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
54
54
|
const managed_agent_install_1 = require("../cli/utils/managed-agent-install");
|
|
55
|
+
const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
|
|
55
56
|
const types_1 = require("./types");
|
|
56
57
|
Object.defineProperty(exports, "FIRST_RUN_ROW_IDS", { enumerable: true, get: function () { return types_1.FIRST_RUN_ROW_IDS; } });
|
|
57
58
|
const install_state_1 = require("./install-state");
|
|
@@ -856,9 +857,18 @@ class FirstRunSessionService {
|
|
|
856
857
|
loginHint: `Sign in to ${option.label} to activate it. A terminal window will open with the sign-in command — complete sign-in there, then return here and click "Check if Ready".`,
|
|
857
858
|
};
|
|
858
859
|
}
|
|
859
|
-
|
|
860
|
+
// Issue #1415 (extended): a genuinely GUI-only install (native installer only, no CLI ever
|
|
861
|
+
// run) has no working npm on `systemPath` at all — `installManagedAgent`'s "standard" attempt
|
|
862
|
+
// needs one to run `npm install -g <agent>` in the first place. Ensure one exists (system,
|
|
863
|
+
// already-managed, or freshly downloaded) and fold its directory into the PATH passed down,
|
|
864
|
+
// so the standard attempt can actually succeed instead of silently falling through.
|
|
865
|
+
const npm = await (0, managed_node_runtime_1.ensureManagedNpm)({
|
|
866
|
+
onProgress: (message) => appendInstallLog(`managed-node: ${message}`),
|
|
867
|
+
});
|
|
868
|
+
const pathWithNpm = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, [npm.binDir]);
|
|
869
|
+
const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, pathWithNpm, { runProcess, commandVersion });
|
|
860
870
|
if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
|
|
861
|
-
process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(
|
|
871
|
+
process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(pathWithNpm, outcome.npmGlobalBinDirs);
|
|
862
872
|
}
|
|
863
873
|
this.setAgentInstallStatus(agentId, 'needs-sign-in', `Sign in to ${option.label} to activate it.`);
|
|
864
874
|
appendInstallLog(outcome.outcome === 'standard' ? `agent-installed-standard ${agentId}` : `agent-installed-managed ${agentId}`);
|
|
@@ -66,6 +66,7 @@ const local_registry_resolver_1 = require("../core/utils/local-registry-resolver
|
|
|
66
66
|
const ai_mentor_1 = require("../core/ai-mentor");
|
|
67
67
|
const quality_evidence_1 = require("../core/quality-evidence");
|
|
68
68
|
const handoff_contracts_1 = require("../core/handoff-contracts");
|
|
69
|
+
const test_evidence_contract_1 = require("../core/test-evidence-contract");
|
|
69
70
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
70
71
|
const usage_collector_js_1 = require("./usage-collector.js");
|
|
71
72
|
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
@@ -2298,6 +2299,27 @@ class FraimLocalMCPServer {
|
|
|
2298
2299
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
|
|
2299
2300
|
}
|
|
2300
2301
|
}
|
|
2302
|
+
// Test evidence contract enforcement (Issue #1419, Change 4).
|
|
2303
|
+
//
|
|
2304
|
+
// A phase whose job-frontmatter declares requiresTestEvidence: true
|
|
2305
|
+
// (e.g. implement-validate, implement-regression) must supply the
|
|
2306
|
+
// actual observed test/validation result — not a narrated success —
|
|
2307
|
+
// before it can be marked complete. This is the mechanical backstop
|
|
2308
|
+
// the #1419 spike found necessary: a written policy note alone was
|
|
2309
|
+
// not obeyed even by an agent that had just stated the work was
|
|
2310
|
+
// outstanding. Reuses handoffPhaseMap (same mentor.getJobPhaseMap
|
|
2311
|
+
// call already made above) rather than re-fetching the job's phases.
|
|
2312
|
+
const testEvidenceErrors = (0, test_evidence_contract_1.validateTestEvidenceContract)({
|
|
2313
|
+
currentPhase: args.currentPhase,
|
|
2314
|
+
status: args.status,
|
|
2315
|
+
findings: args.findings,
|
|
2316
|
+
phases: handoffPhaseMap,
|
|
2317
|
+
});
|
|
2318
|
+
if (testEvidenceErrors.length > 0) {
|
|
2319
|
+
this.log(`⚠️ Test evidence contract rejected seekMentoring for ${args.jobName}:${args.currentPhase}: ${testEvidenceErrors.join('; ')}`);
|
|
2320
|
+
const rejection = (0, test_evidence_contract_1.buildTestEvidenceRejectionMessage)(args.currentPhase, testEvidenceErrors);
|
|
2321
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
|
|
2322
|
+
}
|
|
2301
2323
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
2302
2324
|
}
|
|
2303
2325
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.291",
|
|
4
4
|
"description": "FRAIM core CLI and MCP package.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"cors": "^2.8.5",
|
|
47
47
|
"dotenv": "^16.4.7",
|
|
48
48
|
"express": "^5.2.1",
|
|
49
|
+
"extract-zip": "^2.0.1",
|
|
49
50
|
"mongodb": "^7.0.0",
|
|
50
51
|
"node-cron": "4.2.1",
|
|
51
52
|
"node-edge-tts": "^1.2.10",
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
"selfsigned": "^5.5.0",
|
|
56
57
|
"semver": "^7.7.4",
|
|
57
58
|
"stripe": "^20.3.1",
|
|
59
|
+
"tar": "^7.4.3",
|
|
58
60
|
"toml": "^3.0.0",
|
|
59
61
|
"tree-kill": "^1.2.2",
|
|
60
62
|
"xml2js": "^0.6.2"
|