genexus-mcp 3.8.0 → 3.9.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.
@@ -813,6 +813,57 @@ function buildLegacyIdeLockCheck(gxMajor) {
813
813
  return { id: 'legacy_ide_lock', status: 'pass', detail: 'No conflicting GeneXus IDE process (gx.exe) detected.' };
814
814
  }
815
815
 
816
+ function buildRuntimeStagingCheck() {
817
+ try {
818
+ const {
819
+ resolveDefaultRuntimeRoot,
820
+ listStagedRuntimes,
821
+ getRunningGxMcpProcesses,
822
+ classifyProcessRuntime,
823
+ shouldStage
824
+ } = require('../lib/runtime-stager');
825
+
826
+ const runtimeRoot = resolveDefaultRuntimeRoot();
827
+ const stagedList = listStagedRuntimes(runtimeRoot);
828
+ const runningProcs = getRunningGxMcpProcesses();
829
+ const npxProcs = runningProcs.filter((p) => classifyProcessRuntime(p.exePath, runtimeRoot) === 'npx-cache');
830
+ const stagedProcs = runningProcs.filter((p) => classifyProcessRuntime(p.exePath, runtimeRoot) === 'staged');
831
+
832
+ if (npxProcs.length > 0) {
833
+ return {
834
+ id: 'runtime_staging',
835
+ status: 'warn',
836
+ detail: `${npxProcs.length} process(es) running directly from npx cache (PIDs: ${npxProcs.map((p) => p.pid).join(', ')}). Terminate them to prevent EBUSY during upgrades.`
837
+ };
838
+ }
839
+ if (shouldStage()) {
840
+ return {
841
+ id: 'runtime_staging',
842
+ status: 'pass',
843
+ detail: `Staging active at ${runtimeRoot}. ${stagedList.length} staged version(s) found, ${stagedProcs.length} active process(es).`
844
+ };
845
+ }
846
+ if (process.env.GENEXUS_MCP_GATEWAY_EXE) {
847
+ return {
848
+ id: 'runtime_staging',
849
+ status: 'pass',
850
+ detail: `Fixed-path runtime override active via GENEXUS_MCP_GATEWAY_EXE (${process.env.GENEXUS_MCP_GATEWAY_EXE}). Staging bypassed.`
851
+ };
852
+ }
853
+ return {
854
+ id: 'runtime_staging',
855
+ status: 'pass',
856
+ detail: 'Development checkout active. Runtime staging bypassed.'
857
+ };
858
+ } catch {
859
+ return {
860
+ id: 'runtime_staging',
861
+ status: 'not_applicable',
862
+ detail: 'Runtime staging inspection unavailable.'
863
+ };
864
+ }
865
+ }
866
+
816
867
  function redactConfig(cfg) {
817
868
  // Replace absolute paths with `<redacted:hash8>` so the structure is preserved
818
869
  // but filesystem layout, usernames, and KB names are not leaked. Hash is stable
@@ -1124,6 +1175,7 @@ async function handleDoctor(options, ctx) {
1124
1175
  checks.push({ id: 'gxpublic_com_registration', status: comCheck.status, detail: comCheck.detail });
1125
1176
  const ideLockCheck = buildLegacyIdeLockCheck(gxMajor);
1126
1177
  checks.push({ id: 'legacy_ide_lock', status: ideLockCheck.status, detail: ideLockCheck.detail });
1178
+ checks.push(buildRuntimeStagingCheck());
1127
1179
 
1128
1180
  // Client registration summary — one line answering "are my AI agents wired up?".
1129
1181
  const installedRows = clientRows.filter((r) => r.installed);
package/cli/index.js CHANGED
@@ -450,7 +450,16 @@ async function launchGateway(passthroughArgs, options) {
450
450
  return EXIT_CODES.ERROR;
451
451
  }
452
452
 
453
- const gatewayExePath = getGatewayExePath();
453
+ let gatewayExePath = getGatewayExePath();
454
+ try {
455
+ const { ensureStagedGateway } = require('./lib/runtime-stager');
456
+ const staged = ensureStagedGateway();
457
+ gatewayExePath = staged.gatewayExePath;
458
+ } catch (stageErr) {
459
+ if (!options.quiet) {
460
+ launcherStderr.write(`[genexus-mcp] Warning: Runtime staging failed (${stageErr.message}), falling back to direct binary.\n`);
461
+ }
462
+ }
454
463
  if (!require('fs').existsSync(gatewayExePath)) {
455
464
  const message = `[genexus-mcp] ERROR: Gateway executable not found at ${gatewayExePath}`;
456
465
  launcherStderr.write(`${message}\n`);
@@ -151,6 +151,7 @@ test('generateNeutralConfig produces valid shape without KB fields', () => {
151
151
  assert.equal(cfg.GeneXus.InstallationPath, 'C:\\GeneXus18');
152
152
  assert.ok(cfg.GeneXus.WorkerExecutable.endsWith(path.join('worker', 'GxMcp.Worker.exe')));
153
153
  assert.equal(cfg.Server.McpStdio, true);
154
+ assert.equal(cfg.Server.ToolProfile, 'standard');
154
155
  assert.equal(cfg.Server.TransportMode, undefined);
155
156
  assert.equal(cfg.Server.HttpPort, 0);
156
157
  assert.equal(cfg.Environment.ResolutionPolicy, 'strict');
package/cli/lib/config.js CHANGED
@@ -8,6 +8,7 @@ function generateConfig(gxPath, kbPath) {
8
8
  Server: {
9
9
  HttpPort: 5000,
10
10
  McpStdio: true,
11
+ ToolProfile: 'standard',
11
12
  SessionIdleTimeoutMinutes: 10,
12
13
  WorkerIdleTimeoutMinutes: 5,
13
14
  // Lean + terse defaults: the MCP client is an LLM agent that reads
@@ -33,6 +34,7 @@ function generateNeutralConfig(gxPath, { workerPath, gatewayMode = 'stdio-isolat
33
34
  Server: {
34
35
  HttpPort: 0,
35
36
  McpStdio: true,
37
+ ToolProfile: 'standard',
36
38
  WorkerSharingMode: 'isolated',
37
39
  SessionIdleTimeoutMinutes: 10,
38
40
  WorkerIdleTimeoutMinutes: 5,
@@ -657,9 +659,18 @@ function createConfigFile(kbPath, gxPath) {
657
659
  }
658
660
  const nextConfig = {
659
661
  ...baseConfig,
662
+ Server: { ...baseConfig.Server },
660
663
  Environment: { ...baseConfig.Environment, ...preservedEnv }
661
664
  };
662
665
 
666
+ if (existing) {
667
+ if (existing.Server && Object.prototype.hasOwnProperty.call(existing.Server, 'ToolProfile')) {
668
+ nextConfig.Server.ToolProfile = existing.Server.ToolProfile;
669
+ } else {
670
+ delete nextConfig.Server.ToolProfile;
671
+ }
672
+ }
673
+
663
674
  const changed = !existing || JSON.stringify(existing) !== JSON.stringify(nextConfig);
664
675
  if (changed) {
665
676
  writeFileAtomic(targetConfigPath, JSON.stringify(nextConfig, null, 2));
@@ -0,0 +1,332 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+ const { execFileSync } = require('child_process');
8
+
9
+ function resolveDefaultRuntimeRoot() {
10
+ if (process.env.GENEXUS_MCP_RUNTIME_DIR) {
11
+ return process.env.GENEXUS_MCP_RUNTIME_DIR;
12
+ }
13
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
14
+ return path.join(localAppData, 'GenexusMCP', 'runtime');
15
+ }
16
+
17
+ function isCheckoutMode(packageRoot) {
18
+ try {
19
+ const gitMarker = fs.statSync(path.join(packageRoot, '.git'));
20
+ return gitMarker.isDirectory() || gitMarker.isFile();
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function shouldStage(packageRoot = path.resolve(__dirname, '..', '..')) {
27
+ if (process.env.GENEXUS_MCP_GATEWAY_EXE) {
28
+ return false;
29
+ }
30
+ if (process.env.GENEXUS_MCP_NO_STAGING === '1') {
31
+ return false;
32
+ }
33
+ if (process.env.GENEXUS_MCP_FORCE_STAGING === '1') {
34
+ return true;
35
+ }
36
+ return !isCheckoutMode(packageRoot);
37
+ }
38
+
39
+ function computeFileSha256(filePath) {
40
+ const hash = crypto.createHash('sha256');
41
+ const buffer = fs.readFileSync(filePath);
42
+ hash.update(buffer);
43
+ return hash.digest('hex');
44
+ }
45
+
46
+ function computePublishFingerprint(publishDir) {
47
+ const manifestPath = path.join(publishDir, 'gxmcp-manifest.json');
48
+ if (fs.existsSync(manifestPath)) {
49
+ try {
50
+ return computeFileSha256(manifestPath).slice(0, 8);
51
+ } catch {
52
+ // fallback
53
+ }
54
+ }
55
+ const gatewayExe = path.join(publishDir, 'GxMcp.Gateway.exe');
56
+ if (fs.existsSync(gatewayExe)) {
57
+ try {
58
+ return computeFileSha256(gatewayExe).slice(0, 8);
59
+ } catch {
60
+ // fallback
61
+ }
62
+ }
63
+ return '00000000';
64
+ }
65
+
66
+ function verifyManifest(stagedDir, manifestPath) {
67
+ if (!fs.existsSync(manifestPath)) {
68
+ return;
69
+ }
70
+ let manifest;
71
+ try {
72
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
73
+ } catch (err) {
74
+ throw new Error(`Failed to parse manifest at ${manifestPath}: ${err.message}`);
75
+ }
76
+
77
+ const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
78
+ for (const artifact of artifacts) {
79
+ if (!artifact || !artifact.path) continue;
80
+ const fullPath = path.join(stagedDir, artifact.path);
81
+ if (!fs.existsSync(fullPath)) {
82
+ throw new Error(`Manifest verification failed: missing artifact '${artifact.path}'`);
83
+ }
84
+ if (typeof artifact.size === 'number') {
85
+ const stat = fs.statSync(fullPath);
86
+ if (stat.size !== artifact.size) {
87
+ throw new Error(`Manifest verification failed: size mismatch for '${artifact.path}' (expected ${artifact.size}, got ${stat.size})`);
88
+ }
89
+ }
90
+ if (artifact.sha256) {
91
+ const actualSha = computeFileSha256(fullPath);
92
+ if (actualSha.toLowerCase() !== artifact.sha256.toLowerCase()) {
93
+ throw new Error(`Manifest verification failed: sha256 mismatch for '${artifact.path}'`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ function cleanOldRuntimes(runtimeRoot, currentTargetDir, keepCount = 2) {
100
+ try {
101
+ if (!fs.existsSync(runtimeRoot)) return;
102
+ const entries = fs.readdirSync(runtimeRoot, { withFileTypes: true });
103
+
104
+ // Clean up stale tmp directories older than 10 minutes
105
+ const now = Date.now();
106
+ for (const entry of entries) {
107
+ if (entry.isDirectory() && entry.name.includes('.tmp-')) {
108
+ const fullTmpPath = path.join(runtimeRoot, entry.name);
109
+ try {
110
+ const stat = fs.statSync(fullTmpPath);
111
+ if (now - stat.mtimeMs > 10 * 60 * 1000) {
112
+ fs.rmSync(fullTmpPath, { recursive: true, force: true });
113
+ }
114
+ } catch {
115
+ // Ignore removal failures on locked tmp dirs
116
+ }
117
+ }
118
+ }
119
+
120
+ // Gather valid version directories
121
+ const versionDirs = [];
122
+ for (const entry of entries) {
123
+ if (!entry.isDirectory() || entry.name.includes('.tmp-')) continue;
124
+ const fullPath = path.join(runtimeRoot, entry.name);
125
+ try {
126
+ const stat = fs.statSync(fullPath);
127
+ versionDirs.push({
128
+ name: entry.name,
129
+ path: fullPath,
130
+ mtimeMs: stat.mtimeMs
131
+ });
132
+ } catch {
133
+ // Ignore stat errors
134
+ }
135
+ }
136
+
137
+ // Sort newest first
138
+ versionDirs.sort((a, b) => b.mtimeMs - a.mtimeMs);
139
+
140
+ // Keep current targetDir always, plus up to keepCount newest
141
+ const normalizedCurrent = path.resolve(currentTargetDir).toLowerCase();
142
+ let kept = 0;
143
+ const toDelete = [];
144
+
145
+ for (const vdir of versionDirs) {
146
+ const normalized = path.resolve(vdir.path).toLowerCase();
147
+ if (normalized === normalizedCurrent) {
148
+ continue;
149
+ }
150
+ if (kept < keepCount) {
151
+ kept++;
152
+ } else {
153
+ toDelete.push(vdir.path);
154
+ }
155
+ }
156
+
157
+ for (const dirToDelete of toDelete) {
158
+ try {
159
+ fs.rmSync(dirToDelete, { recursive: true, force: true });
160
+ } catch {
161
+ // EBUSY or EPERM means another running process has loaded DLLs from this runtime.
162
+ // Keep it for later cleanup.
163
+ }
164
+ }
165
+ } catch {
166
+ // Garbage collection is best-effort
167
+ }
168
+ }
169
+
170
+ function ensureStagedGateway(options = {}) {
171
+ const packageRoot = options.packageRoot || path.resolve(__dirname, '..', '..');
172
+ const publishDir = options.publishDir || path.join(packageRoot, 'publish');
173
+ const defaultGatewayExe = options.defaultGatewayExe || path.join(publishDir, 'GxMcp.Gateway.exe');
174
+
175
+ if (!options.forceStaging && !shouldStage(packageRoot)) {
176
+ return {
177
+ staged: false,
178
+ gatewayExePath: process.env.GENEXUS_MCP_GATEWAY_EXE || defaultGatewayExe,
179
+ runtimeDir: path.dirname(process.env.GENEXUS_MCP_GATEWAY_EXE || defaultGatewayExe),
180
+ reason: process.env.GENEXUS_MCP_GATEWAY_EXE ? 'GENEXUS_MCP_GATEWAY_EXE' : (process.env.GENEXUS_MCP_NO_STAGING === '1' ? 'NO_STAGING' : 'checkout')
181
+ };
182
+ }
183
+
184
+ let version = '0.0.0';
185
+ try {
186
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
187
+ if (pkg.version) version = pkg.version;
188
+ } catch {
189
+ // Fallback
190
+ }
191
+
192
+ const sha8 = computePublishFingerprint(publishDir);
193
+ const runtimeRoot = options.runtimeRoot || resolveDefaultRuntimeRoot();
194
+ const targetDir = path.join(runtimeRoot, `${version}-${sha8}`);
195
+ const stagedGatewayExe = path.join(targetDir, 'GxMcp.Gateway.exe');
196
+
197
+ if (fs.existsSync(stagedGatewayExe)) {
198
+ cleanOldRuntimes(runtimeRoot, targetDir);
199
+ return {
200
+ staged: true,
201
+ gatewayExePath: stagedGatewayExe,
202
+ runtimeDir: targetDir,
203
+ fresh: false
204
+ };
205
+ }
206
+
207
+ if (!fs.existsSync(publishDir)) {
208
+ throw new Error(`Cannot stage runtime: publish directory not found at ${publishDir}`);
209
+ }
210
+
211
+ fs.mkdirSync(runtimeRoot, { recursive: true });
212
+ const tmpDir = `${targetDir}.tmp-${process.pid}-${Date.now()}`;
213
+ fs.mkdirSync(tmpDir, { recursive: true });
214
+
215
+ try {
216
+ fs.cpSync(publishDir, tmpDir, { recursive: true });
217
+ const manifestPath = path.join(tmpDir, 'gxmcp-manifest.json');
218
+ verifyManifest(tmpDir, manifestPath);
219
+
220
+ try {
221
+ fs.renameSync(tmpDir, targetDir);
222
+ } catch (renameErr) {
223
+ // Concurrent launcher might have completed the rename first
224
+ if (fs.existsSync(stagedGatewayExe)) {
225
+ try {
226
+ fs.rmSync(tmpDir, { recursive: true, force: true });
227
+ } catch {
228
+ // Ignore tmp cleanup error
229
+ }
230
+ } else {
231
+ throw renameErr;
232
+ }
233
+ }
234
+ } catch (err) {
235
+ try {
236
+ fs.rmSync(tmpDir, { recursive: true, force: true });
237
+ } catch {
238
+ // Ignore tmp cleanup error
239
+ }
240
+ throw err;
241
+ }
242
+
243
+ cleanOldRuntimes(runtimeRoot, targetDir);
244
+
245
+ return {
246
+ staged: true,
247
+ gatewayExePath: stagedGatewayExe,
248
+ runtimeDir: targetDir,
249
+ fresh: true
250
+ };
251
+ }
252
+
253
+ function listStagedRuntimes(runtimeRoot = resolveDefaultRuntimeRoot()) {
254
+ if (!fs.existsSync(runtimeRoot)) return [];
255
+ try {
256
+ const entries = fs.readdirSync(runtimeRoot, { withFileTypes: true });
257
+ return entries
258
+ .filter((e) => e.isDirectory() && !e.name.includes('.tmp-'))
259
+ .map((e) => {
260
+ const dirPath = path.join(runtimeRoot, e.name);
261
+ let stat = null;
262
+ try {
263
+ stat = fs.statSync(dirPath);
264
+ } catch {
265
+ // Ignore
266
+ }
267
+ return {
268
+ name: e.name,
269
+ path: dirPath,
270
+ hasGateway: fs.existsSync(path.join(dirPath, 'GxMcp.Gateway.exe')),
271
+ mtime: stat ? stat.mtime : null
272
+ };
273
+ });
274
+ } catch {
275
+ return [];
276
+ }
277
+ }
278
+
279
+ function getRunningGxMcpProcesses() {
280
+ if (process.platform !== 'win32') return [];
281
+ try {
282
+ // Query CIM Win32_Process for GxMcp processes
283
+ const output = execFileSync('powershell.exe', [
284
+ '-NoProfile',
285
+ '-NonInteractive',
286
+ '-Command',
287
+ `Get-CimInstance Win32_Process -Filter "name like 'GxMcp%'" | Select-Object ProcessId, ExecutablePath, CommandLine | ConvertTo-Json -Compress`
288
+ ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
289
+
290
+ if (!output) return [];
291
+ let data;
292
+ try {
293
+ data = JSON.parse(output);
294
+ } catch {
295
+ return [];
296
+ }
297
+ const rows = Array.isArray(data) ? data : [data];
298
+ return rows.map((r) => ({
299
+ pid: r.ProcessId,
300
+ exePath: r.ExecutablePath || '',
301
+ commandLine: r.CommandLine || ''
302
+ }));
303
+ } catch {
304
+ return [];
305
+ }
306
+ }
307
+
308
+ function classifyProcessRuntime(exePath, runtimeRoot = resolveDefaultRuntimeRoot()) {
309
+ if (!exePath) return 'unknown';
310
+ const norm = path.resolve(exePath).toLowerCase();
311
+ const normRuntime = path.resolve(runtimeRoot).toLowerCase();
312
+ if (norm.startsWith(normRuntime)) {
313
+ return 'staged';
314
+ }
315
+ if (norm.includes('npm-cache') || norm.includes('_npx') || norm.includes('node_modules')) {
316
+ return 'npx-cache';
317
+ }
318
+ return 'other';
319
+ }
320
+
321
+ module.exports = {
322
+ resolveDefaultRuntimeRoot,
323
+ isCheckoutMode,
324
+ shouldStage,
325
+ computePublishFingerprint,
326
+ verifyManifest,
327
+ cleanOldRuntimes,
328
+ ensureStagedGateway,
329
+ listStagedRuntimes,
330
+ getRunningGxMcpProcesses,
331
+ classifyProcessRuntime
332
+ };
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const crypto = require('crypto');
9
+
10
+ const {
11
+ resolveDefaultRuntimeRoot,
12
+ shouldStage,
13
+ ensureStagedGateway,
14
+ cleanOldRuntimes,
15
+ verifyManifest,
16
+ classifyProcessRuntime
17
+ } = require('./runtime-stager');
18
+
19
+ function sha256(content) {
20
+ return crypto.createHash('sha256').update(content).digest('hex');
21
+ }
22
+
23
+ test('runtime-stager: resolveDefaultRuntimeRoot uses GENEXUS_MCP_RUNTIME_DIR or LocalAppData', (t) => {
24
+ const orig = process.env.GENEXUS_MCP_RUNTIME_DIR;
25
+ t.after(() => {
26
+ if (orig !== undefined) process.env.GENEXUS_MCP_RUNTIME_DIR = orig;
27
+ else delete process.env.GENEXUS_MCP_RUNTIME_DIR;
28
+ });
29
+ process.env.GENEXUS_MCP_RUNTIME_DIR = 'C:\\custom\\runtime';
30
+ assert.equal(resolveDefaultRuntimeRoot(), 'C:\\custom\\runtime');
31
+ });
32
+
33
+ test('runtime-stager: verifyManifest succeeds on valid files', () => {
34
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-verify-'));
35
+ try {
36
+ const file = path.join(tmp, 'test.txt');
37
+ fs.writeFileSync(file, 'hello');
38
+ const manifest = path.join(tmp, 'manifest.json');
39
+ fs.writeFileSync(manifest, JSON.stringify({
40
+ artifacts: [{ path: 'test.txt', size: 5, sha256: sha256('hello') }]
41
+ }));
42
+ assert.doesNotThrow(() => verifyManifest(tmp, manifest));
43
+ } finally {
44
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
45
+ }
46
+ });
47
+
48
+ test('runtime-stager: shouldStage respects env overrides and checkout markers', (t) => {
49
+ const origEnv = { ...process.env };
50
+ t.after(() => {
51
+ process.env = origEnv;
52
+ });
53
+
54
+ const tmpPkg = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-pkg-'));
55
+ t.after(() => {
56
+ try { fs.rmSync(tmpPkg, { recursive: true, force: true }); } catch { /* ignore */ }
57
+ });
58
+
59
+ // Case 1: GENEXUS_MCP_GATEWAY_EXE set -> false
60
+ process.env.GENEXUS_MCP_GATEWAY_EXE = 'C:\\custom\\GxMcp.Gateway.exe';
61
+ delete process.env.GENEXUS_MCP_NO_STAGING;
62
+ delete process.env.GENEXUS_MCP_FORCE_STAGING;
63
+ assert.equal(shouldStage(tmpPkg), false);
64
+
65
+ // Case 2: GENEXUS_MCP_NO_STAGING === '1' -> false
66
+ delete process.env.GENEXUS_MCP_GATEWAY_EXE;
67
+ process.env.GENEXUS_MCP_NO_STAGING = '1';
68
+ assert.equal(shouldStage(tmpPkg), false);
69
+
70
+ // Case 3: In packaged mode (no .git) -> true
71
+ delete process.env.GENEXUS_MCP_NO_STAGING;
72
+ assert.equal(shouldStage(tmpPkg), true);
73
+
74
+ // Case 4: With .git marker -> false
75
+ fs.mkdirSync(path.join(tmpPkg, '.git'));
76
+ assert.equal(shouldStage(tmpPkg), false);
77
+
78
+ // Case 5: With .git marker but GENEXUS_MCP_FORCE_STAGING === '1' -> true
79
+ process.env.GENEXUS_MCP_FORCE_STAGING = '1';
80
+ assert.equal(shouldStage(tmpPkg), true);
81
+ });
82
+
83
+ test('runtime-stager: ensureStagedGateway stages publish folder, verifies manifest and reuses staged runtime', (t) => {
84
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-stage-test-'));
85
+ t.after(() => {
86
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
87
+ });
88
+
89
+ const fakePkg = path.join(tmpRoot, 'package');
90
+ const fakePublish = path.join(fakePkg, 'publish');
91
+ fs.mkdirSync(fakePublish, { recursive: true });
92
+
93
+ fs.writeFileSync(path.join(fakePkg, 'package.json'), JSON.stringify({ version: '3.9.0' }));
94
+ const fakeExeContent = 'fake gateway binary';
95
+ fs.writeFileSync(path.join(fakePublish, 'GxMcp.Gateway.exe'), fakeExeContent);
96
+
97
+ const manifestContent = JSON.stringify({
98
+ schemaVersion: 'gxmcp-release-manifest/1',
99
+ artifacts: [
100
+ {
101
+ path: 'GxMcp.Gateway.exe',
102
+ size: Buffer.byteLength(fakeExeContent),
103
+ sha256: sha256(fakeExeContent)
104
+ }
105
+ ]
106
+ });
107
+ fs.writeFileSync(path.join(fakePublish, 'gxmcp-manifest.json'), manifestContent);
108
+
109
+ const runtimeRoot = path.join(tmpRoot, 'runtime');
110
+
111
+ // First run: stages fresh
112
+ const result1 = ensureStagedGateway({
113
+ packageRoot: fakePkg,
114
+ publishDir: fakePublish,
115
+ runtimeRoot,
116
+ forceStaging: true
117
+ });
118
+
119
+ assert.equal(result1.staged, true);
120
+ assert.equal(result1.fresh, true);
121
+ assert.equal(fs.existsSync(result1.gatewayExePath), true);
122
+ assert.equal(fs.readFileSync(result1.gatewayExePath, 'utf8'), fakeExeContent);
123
+
124
+ // Second run: reuses already staged
125
+ const result2 = ensureStagedGateway({
126
+ packageRoot: fakePkg,
127
+ publishDir: fakePublish,
128
+ runtimeRoot,
129
+ forceStaging: true
130
+ });
131
+
132
+ assert.equal(result2.staged, true);
133
+ assert.equal(result2.fresh, false);
134
+ assert.equal(result2.gatewayExePath, result1.gatewayExePath);
135
+ });
136
+
137
+ test('runtime-stager: ensureStagedGateway fails closed and cleans tmp on manifest corruption', (t) => {
138
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-corrupt-test-'));
139
+ t.after(() => {
140
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
141
+ });
142
+
143
+ const fakePkg = path.join(tmpRoot, 'package');
144
+ const fakePublish = path.join(fakePkg, 'publish');
145
+ fs.mkdirSync(fakePublish, { recursive: true });
146
+
147
+ fs.writeFileSync(path.join(fakePkg, 'package.json'), JSON.stringify({ version: '3.9.0' }));
148
+ const fakeExeContent = 'tampered gateway binary';
149
+ fs.writeFileSync(path.join(fakePublish, 'GxMcp.Gateway.exe'), fakeExeContent);
150
+
151
+ const manifestContent = JSON.stringify({
152
+ schemaVersion: 'gxmcp-release-manifest/1',
153
+ artifacts: [
154
+ {
155
+ path: 'GxMcp.Gateway.exe',
156
+ size: 9999, // mismatched size
157
+ sha256: 'deadbeef'
158
+ }
159
+ ]
160
+ });
161
+ fs.writeFileSync(path.join(fakePublish, 'gxmcp-manifest.json'), manifestContent);
162
+
163
+ const runtimeRoot = path.join(tmpRoot, 'runtime');
164
+
165
+ assert.throws(() => {
166
+ ensureStagedGateway({
167
+ packageRoot: fakePkg,
168
+ publishDir: fakePublish,
169
+ runtimeRoot,
170
+ forceStaging: true
171
+ });
172
+ }, /Manifest verification failed/);
173
+
174
+ // Assert that target dir was not created and no tmp dir left behind
175
+ assert.equal(fs.readdirSync(runtimeRoot).length, 0);
176
+ });
177
+
178
+ test('runtime-stager: cleanOldRuntimes retains current version plus keepCount newest versions', (t) => {
179
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-gc-test-'));
180
+ t.after(() => {
181
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
182
+ });
183
+
184
+ const v1 = path.join(tmpRoot, '3.6.0-aaaa1111');
185
+ const v2 = path.join(tmpRoot, '3.7.0-bbbb2222');
186
+ const v3 = path.join(tmpRoot, '3.8.0-cccc3333');
187
+ const v4 = path.join(tmpRoot, '3.9.0-dddd4444');
188
+
189
+ fs.mkdirSync(v1);
190
+ fs.utimesSync(v1, 1000, 1000);
191
+ fs.mkdirSync(v2);
192
+ fs.utimesSync(v2, 2000, 2000);
193
+ fs.mkdirSync(v3);
194
+ fs.utimesSync(v3, 3000, 3000);
195
+ fs.mkdirSync(v4);
196
+ fs.utimesSync(v4, 4000, 4000);
197
+
198
+ // Current is v4. Keep count = 2 -> keep v4, v3, v2. v1 should be deleted.
199
+ cleanOldRuntimes(tmpRoot, v4, 2);
200
+
201
+ assert.equal(fs.existsSync(v4), true, 'current version must be kept');
202
+ assert.equal(fs.existsSync(v3), true, 'newest previous version must be kept');
203
+ assert.equal(fs.existsSync(v2), true, 'second previous version must be kept');
204
+ assert.equal(fs.existsSync(v1), false, 'oldest version beyond keepCount must be deleted');
205
+ });
206
+
207
+ test('runtime-stager: classifyProcessRuntime identifies staged vs npx-cache', () => {
208
+ const runtimeRoot = 'C:\\Users\\user\\AppData\\Local\\GenexusMCP\\runtime';
209
+ assert.equal(
210
+ classifyProcessRuntime('C:\\Users\\user\\AppData\\Local\\GenexusMCP\\runtime\\3.8.0-abcd\\GxMcp.Gateway.exe', runtimeRoot),
211
+ 'staged'
212
+ );
213
+ assert.equal(
214
+ classifyProcessRuntime('C:\\Users\\user\\AppData\\Local\\npm-cache\\_npx\\12345\\node_modules\\genexus-mcp\\publish\\GxMcp.Gateway.exe', runtimeRoot),
215
+ 'npx-cache'
216
+ );
217
+ assert.equal(
218
+ classifyProcessRuntime('C:\\Projetos\\Genexus18MCP\\publish\\GxMcp.Gateway.exe', runtimeRoot),
219
+ 'other'
220
+ );
221
+ });