@creeperhost/modlens-mcp 1.6.19 → 1.6.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/ATTRIBUTION.md +8 -1
  2. package/README.md +82 -5
  3. package/RUNTIME.md +262 -0
  4. package/TESTING.md +8 -0
  5. package/dist/cli.js +28 -8
  6. package/dist/cli.js.map +1 -1
  7. package/dist/hosted-mod-license.d.ts +36 -0
  8. package/dist/hosted-mod-license.d.ts.map +1 -0
  9. package/dist/hosted-mod-license.js +65 -0
  10. package/dist/hosted-mod-license.js.map +1 -0
  11. package/dist/hosted-policy.d.ts +32 -0
  12. package/dist/hosted-policy.d.ts.map +1 -0
  13. package/dist/hosted-policy.js +257 -0
  14. package/dist/hosted-policy.js.map +1 -0
  15. package/dist/launcher.js +104 -89
  16. package/dist/launcher.js.map +1 -1
  17. package/dist/license-notices.d.ts +8 -0
  18. package/dist/license-notices.d.ts.map +1 -0
  19. package/dist/license-notices.js +10 -0
  20. package/dist/license-notices.js.map +1 -0
  21. package/dist/license-source-references.d.ts +12 -0
  22. package/dist/license-source-references.d.ts.map +1 -0
  23. package/dist/license-source-references.js +18 -0
  24. package/dist/license-source-references.js.map +1 -0
  25. package/dist/license-templates.d.ts +6 -0
  26. package/dist/license-templates.d.ts.map +1 -0
  27. package/dist/license-templates.js +55 -0
  28. package/dist/license-templates.js.map +1 -0
  29. package/dist/local-mod.d.ts +31 -0
  30. package/dist/local-mod.d.ts.map +1 -0
  31. package/dist/local-mod.js +71 -0
  32. package/dist/local-mod.js.map +1 -0
  33. package/dist/mod-license.d.ts +53 -0
  34. package/dist/mod-license.d.ts.map +1 -0
  35. package/dist/mod-license.js +617 -0
  36. package/dist/mod-license.js.map +1 -0
  37. package/dist/primer-catalog.d.ts +15 -0
  38. package/dist/primer-catalog.d.ts.map +1 -0
  39. package/dist/primer-catalog.js +43 -0
  40. package/dist/primer-catalog.js.map +1 -0
  41. package/dist/primer-content.d.ts +7 -0
  42. package/dist/primer-content.d.ts.map +1 -0
  43. package/dist/primer-content.js +185 -0
  44. package/dist/primer-content.js.map +1 -0
  45. package/dist/runtime/cli.d.ts +2 -0
  46. package/dist/runtime/cli.d.ts.map +1 -0
  47. package/dist/runtime/cli.js +163 -0
  48. package/dist/runtime/cli.js.map +1 -0
  49. package/dist/runtime/companion.d.ts +32 -0
  50. package/dist/runtime/companion.d.ts.map +1 -0
  51. package/dist/runtime/companion.js +135 -0
  52. package/dist/runtime/companion.js.map +1 -0
  53. package/dist/runtime/guidance.d.ts +73 -0
  54. package/dist/runtime/guidance.d.ts.map +1 -0
  55. package/dist/runtime/guidance.js +40 -0
  56. package/dist/runtime/guidance.js.map +1 -0
  57. package/dist/runtime/hub.d.ts +150 -0
  58. package/dist/runtime/hub.d.ts.map +1 -0
  59. package/dist/runtime/hub.js +561 -0
  60. package/dist/runtime/hub.js.map +1 -0
  61. package/dist/runtime/modlens-agent.jar +0 -0
  62. package/dist/runtime/protocol.d.ts +202 -0
  63. package/dist/runtime/protocol.d.ts.map +1 -0
  64. package/dist/runtime/protocol.js +91 -0
  65. package/dist/runtime/protocol.js.map +1 -0
  66. package/dist/runtime/requests.d.ts +367 -0
  67. package/dist/runtime/requests.d.ts.map +1 -0
  68. package/dist/runtime/requests.js +57 -0
  69. package/dist/runtime/requests.js.map +1 -0
  70. package/dist/server.js +141 -45
  71. package/dist/server.js.map +1 -1
  72. package/dist/setup.js +9 -4
  73. package/dist/setup.js.map +1 -1
  74. package/dist/tools/primers.d.ts +133 -22
  75. package/dist/tools/primers.d.ts.map +1 -1
  76. package/dist/tools/primers.js +391 -386
  77. package/dist/tools/primers.js.map +1 -1
  78. package/dist/tools/project.d.ts +11 -7
  79. package/dist/tools/project.d.ts.map +1 -1
  80. package/dist/tools/project.js +45 -3
  81. package/dist/tools/project.js.map +1 -1
  82. package/dist/tools/report-issue.d.ts +77 -0
  83. package/dist/tools/report-issue.d.ts.map +1 -0
  84. package/dist/tools/report-issue.js +92 -0
  85. package/dist/tools/report-issue.js.map +1 -0
  86. package/dist/tools/runtime.d.ts +6 -0
  87. package/dist/tools/runtime.d.ts.map +1 -0
  88. package/dist/tools/runtime.js +18 -0
  89. package/dist/tools/runtime.js.map +1 -0
  90. package/dist/tools/source.d.ts +1 -1
  91. package/dist/tools/source.d.ts.map +1 -1
  92. package/dist/tools/source.js +5 -1
  93. package/dist/tools/source.js.map +1 -1
  94. package/package.json +8 -1
  95. package/scripts/build-runtime-agent.mjs +64 -0
  96. package/scripts/fixtures/mod-license-corpus.json +154 -0
  97. package/scripts/gradle/modlens-runtime.init.gradle +17 -0
  98. package/scripts/runtime-test-client.mjs +36 -0
  99. package/scripts/test-hosted-policy.mjs +123 -0
  100. package/scripts/test-mod-licenses-live.mjs +86 -0
  101. package/scripts/test-package.mjs +141 -5
  102. package/scripts/test-primers-live.mjs +93 -0
  103. package/scripts/test-project-http.mjs +46 -3
  104. package/scripts/test-runtime-gradle.mjs +107 -0
  105. package/scripts/test-runtime-minecraft.mjs +190 -0
  106. package/scripts/test-runtime-native.mjs +120 -0
  107. package/scripts/test-runtime-remote.mjs +209 -0
  108. package/scripts/update-license-templates.mjs +14 -0
@@ -0,0 +1,190 @@
1
+ // Optional real-client validation with an isolated game directory and demo mode.
2
+ // Uses the explicitly supplied 26.3 manifest/client/assets; never reads user accounts/worlds.
3
+ import { readFile, writeFile, mkdir, mkdtemp, stat, copyFile } from "node:fs/promises";
4
+ import { join, delimiter, basename } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { spawn, execFileSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { fileURLToPath } from "node:url";
9
+ import assert from "node:assert/strict";
10
+ import { RuntimeHub } from "../dist/runtime/hub.js";
11
+ import { runtimeTestClient } from "./runtime-test-client.mjs";
12
+
13
+ const env = process.env;
14
+ for (const k of [
15
+ "JAVA_HOME",
16
+ "MODLENS_TEST_MC_MANIFEST",
17
+ "MODLENS_TEST_MC_CLIENT",
18
+ "MODLENS_TEST_MC_ASSETS",
19
+ "MODLENS_TEST_MAVEN_CACHE",
20
+ ])
21
+ if (!env[k]) throw new Error("Set " + k);
22
+ const manifest = JSON.parse(await readFile(env.MODLENS_TEST_MC_MANIFEST, "utf8"));
23
+ assert.equal(manifest.id, "26.3");
24
+ const repo = fileURLToPath(new URL("..", import.meta.url));
25
+ const root = await mkdtemp(join(tmpdir(), "modlens-minecraft-smoke-"));
26
+ console.log("Evidence directory: " + root);
27
+ const project = join(root, "project");
28
+ await mkdir(join(project, "gradle/wrapper"), { recursive: true });
29
+ await writeFile(join(project, "gradle/wrapper/gradle-wrapper.jar"), "fixture");
30
+ const classpath = [env.MODLENS_TEST_MC_CLIENT];
31
+ for (const lib of manifest.libraries) {
32
+ const osName = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "osx" : "linux";
33
+ if (lib.rules) {
34
+ let allowed = false;
35
+ for (const rule of lib.rules)
36
+ if (!rule.os || rule.os.name === osName) allowed = rule.action === "allow";
37
+ if (!allowed) continue;
38
+ }
39
+ const native = lib.name.split(":")[3];
40
+ if (
41
+ native?.includes("natives-") &&
42
+ native !==
43
+ `natives-${process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"}${process.arch === "arm64" ? "-arm64" : ""}`
44
+ )
45
+ continue;
46
+ const [group, artifact, version] = lib.name.split(":");
47
+ const entry = lib.downloads?.artifact;
48
+ if (!entry) continue;
49
+ let path = join(env.MODLENS_TEST_MAVEN_CACHE, group, artifact, version, entry.sha1, basename(entry.path));
50
+ if (
51
+ !(await stat(path).then(
52
+ () => true,
53
+ () => false,
54
+ ))
55
+ ) {
56
+ path = join(root, "libraries", entry.path);
57
+ await mkdir(join(path, ".."), { recursive: true });
58
+ const r = await fetch(entry.url);
59
+ if (!r.ok) throw new Error("Download failed: " + entry.url);
60
+ const data = Buffer.from(await r.arrayBuffer());
61
+ assert.equal(createHash("sha1").update(data).digest("hex"), entry.sha1);
62
+ await writeFile(path, data);
63
+ }
64
+ classpath.push(path);
65
+ }
66
+ const hub = process.argv.includes("--helper")
67
+ ? runtimeTestClient(root)
68
+ : new RuntimeHub(
69
+ join(root, "bridge"),
70
+ env.MODLENS_TEST_AGENT_JAR ?? join(repo, "dist/runtime/modlens-agent.jar"),
71
+ );
72
+ const setup = await hub.setup(project, "hidden");
73
+ const args = [
74
+ "--enable-native-access=ALL-UNNAMED",
75
+ "--add-exports",
76
+ "java.base/jdk.internal.misc=ALL-UNNAMED",
77
+ "-Xmx2G",
78
+ ...(env.MODLENS_TEST_JVM_ARGS ? JSON.parse(env.MODLENS_TEST_JVM_ARGS) : []),
79
+ "-XX:ErrorFile=" + join(root, "hs_err_pid%p.log"),
80
+ ...setup.vmOptions.filter(
81
+ (option) => !process.argv.includes("--baseline") || !option.startsWith("-javaagent:"),
82
+ ),
83
+ "-cp",
84
+ classpath.join(delimiter),
85
+ manifest.mainClass,
86
+ "--username",
87
+ "ModLensTest",
88
+ "--version",
89
+ "26.3",
90
+ "--gameDir",
91
+ join(root, "game"),
92
+ "--assetsDir",
93
+ env.MODLENS_TEST_MC_ASSETS,
94
+ "--assetIndex",
95
+ env.MODLENS_TEST_MC_ASSET_INDEX ?? manifest.assetIndex.id,
96
+ "--uuid",
97
+ "00000000000000000000000000000001",
98
+ "--accessToken",
99
+ "0",
100
+ "--demo",
101
+ "--width",
102
+ "854",
103
+ "--height",
104
+ "480",
105
+ ];
106
+ await mkdir(join(root, "game"), { recursive: true });
107
+ await writeFile(
108
+ join(root, "java.args"),
109
+ args.map((a) => '"' + a.replaceAll("\\", "/").replaceAll('"', '\\"') + '"').join("\n"),
110
+ );
111
+ const child = spawn(
112
+ join(env.JAVA_HOME, "bin", process.platform === "win32" ? "java.exe" : "java"),
113
+ ["@" + join(root, "java.args")],
114
+ { cwd: join(root, "game"), windowsHide: true },
115
+ );
116
+ let log = "";
117
+ child.stdout.on("data", (b) => {
118
+ log = (log + b).slice(-150000);
119
+ });
120
+ child.stderr.on("data", (b) => {
121
+ log = (log + b).slice(-150000);
122
+ });
123
+ const done = new Promise((ok) => child.once("exit", ok));
124
+ async function wait(fn, ms = 90000) {
125
+ const until = Date.now() + ms;
126
+ while (Date.now() < until) {
127
+ const r = await fn();
128
+ if (r) return r;
129
+ if (child.exitCode !== null)
130
+ throw new Error("Minecraft exited " + child.exitCode + "\n" + log.slice(-8000));
131
+ await new Promise((r) => setTimeout(r, 250));
132
+ }
133
+ throw new Error("Timed out waiting for Minecraft\n" + log.slice(-8000));
134
+ }
135
+ try {
136
+ if (process.argv.includes("--baseline")) {
137
+ await wait(() => log.includes("shulker_boxes.png-atlas"));
138
+ await new Promise((r) => setTimeout(r, 5000));
139
+ assert.equal(child.exitCode, null, "Uninstrumented baseline must remain alive after startup");
140
+ console.log("Uninstrumented Minecraft startup passed. " + root);
141
+ } else {
142
+ const session = await wait(async () =>
143
+ (await hub.list()).find(
144
+ (s) =>
145
+ s.capabilities.minecraftHooks && s.capabilities.window && s.state.observation?.gameLoaded,
146
+ ),
147
+ );
148
+ await new Promise((r) => setTimeout(r, 1500)); // allow the startup overlay to fade
149
+ console.log("Connected Minecraft: " + JSON.stringify(session));
150
+ const frame = await hub.command(session.sessionId, { type: "screenshot" });
151
+ const artifact = await hub.artifact(session.sessionId, frame.artifact);
152
+ assert.equal(artifact.mimeType, "image/png");
153
+ await copyFile(artifact.path, join(root, "frame.png"));
154
+ await hub.command(session.sessionId, { type: "key", key: "TAB", down: true, holdMs: 100 });
155
+ await hub.command(session.sessionId, { type: "release_all" });
156
+ assert.equal(session.capabilities.jfr, true);
157
+ const allocations = await hub.command(session.sessionId, {
158
+ type: "allocations",
159
+ packagePrefix: "net.minecraft",
160
+ windowSeconds: 120,
161
+ });
162
+ const allocationArtifact = await hub.artifact(session.sessionId, allocations.artifact);
163
+ const report = JSON.parse(allocationArtifact.text);
164
+ assert.ok(report.matchedSamples > 0, "Expected Minecraft allocation samples with caller stacks");
165
+ await copyFile(allocationArtifact.path, join(root, "allocations.json"));
166
+ console.log("Minecraft 26.3 hidden client, screenshot, input and allocation report passed. " + root);
167
+ }
168
+ } finally {
169
+ if (child.exitCode === null)
170
+ try {
171
+ await writeFile(
172
+ join(root, "threads.txt"),
173
+ execFileSync(
174
+ join(env.JAVA_HOME, "bin", process.platform === "win32" ? "jcmd.exe" : "jcmd"),
175
+ [String(child.pid), "Thread.print"],
176
+ { timeout: 10000 },
177
+ ),
178
+ );
179
+ } catch (e) {
180
+ await writeFile(join(root, "thread-error.txt"), String(e) + "\n" + e.stderr);
181
+ }
182
+ await writeFile(join(root, "client.log"), log);
183
+ await writeFile(
184
+ join(root, "evidence.json"),
185
+ JSON.stringify({ sessions: await hub.list(), events: await hub.events() }, null, 2),
186
+ );
187
+ if (child.exitCode === null) child.kill();
188
+ await done;
189
+ await hub.close();
190
+ }
@@ -0,0 +1,120 @@
1
+ // Real SDL smoke test. Set JAVA_HOME (25+) and MODLENS_SDL_CLASSPATH to the
2
+ // lwjgl + lwjgl-sdl Java JARs and matching native JARs. No game/account needed.
3
+ import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises";
4
+ import { join, delimiter } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { spawn, execFileSync } from "node:child_process";
7
+ import assert from "node:assert/strict";
8
+ import { fileURLToPath } from "node:url";
9
+ import { RuntimeHub } from "../dist/runtime/hub.js";
10
+
11
+ if (!process.env.JAVA_HOME || !process.env.MODLENS_SDL_CLASSPATH)
12
+ throw new Error("Set JAVA_HOME and MODLENS_SDL_CLASSPATH.");
13
+ const repo = fileURLToPath(new URL("..", import.meta.url));
14
+ const root = await mkdtemp(join(tmpdir(), "modlens-sdl-smoke-"));
15
+ const project = join(root, "project");
16
+ await mkdir(join(project, "gradle/wrapper"), { recursive: true });
17
+ await writeFile(join(project, "gradle/wrapper/gradle-wrapper.jar"), "fixture");
18
+ const agent = join(repo, "dist/runtime/modlens-agent.jar");
19
+ const hub = new RuntimeHub(join(root, "bridge"), agent);
20
+ const bin = (n) => join(process.env.JAVA_HOME, "bin", n + (process.platform === "win32" ? ".exe" : ""));
21
+ execFileSync(
22
+ bin("javac"),
23
+ ["-cp", agent, "-d", root, join(repo, "runtime-agent/test/io/modlens/runtime/NativeSmoke.java")],
24
+ { stdio: "inherit" },
25
+ );
26
+ const setup = await hub.setup(project, "hidden");
27
+ const child = spawn(
28
+ bin("java"),
29
+ [
30
+ "--enable-native-access=ALL-UNNAMED",
31
+ setup.vmOption,
32
+ "-cp",
33
+ [root, agent, process.env.MODLENS_SDL_CLASSPATH].join(delimiter),
34
+ "io.modlens.runtime.NativeSmoke",
35
+ root,
36
+ ],
37
+ { windowsHide: true },
38
+ );
39
+ let log = "";
40
+ child.stdout.on("data", (b) => {
41
+ log += b;
42
+ });
43
+ child.stderr.on("data", (b) => {
44
+ log += b;
45
+ });
46
+ const exited = new Promise((ok, fail) => {
47
+ child.on("exit", (code) => ok(code));
48
+ child.on("error", fail);
49
+ });
50
+ async function wait(fn, ms = 10000) {
51
+ const until = Date.now() + ms;
52
+ while (Date.now() < until) {
53
+ const r = await fn();
54
+ if (r) return r;
55
+ await new Promise((r) => setTimeout(r, 100));
56
+ }
57
+ throw new Error("Timed out\n" + log + "\n" + JSON.stringify(await hub.events()));
58
+ }
59
+ try {
60
+ const s = await wait(async () =>
61
+ (await hub.list()).find((s) => s.capabilities.inputBackend === "lwjgl-sdl3"),
62
+ );
63
+ assert.equal(s.state.mode, "hidden");
64
+ assert.equal(s.capabilities.window, true);
65
+ await hub.command(s.sessionId, { type: "key", key: "W", down: true, holdMs: 150 });
66
+ await wait(() => log.includes("RELEASE"));
67
+ await hub.command(s.sessionId, { type: "text", text: "hello 世界" });
68
+ await hub.command(s.sessionId, { type: "mouse_move", x: 12, y: -5, relative: true });
69
+ await hub.command(s.sessionId, { type: "mouse_button", button: 1, down: true, holdMs: 100 });
70
+ await hub.command(s.sessionId, { type: "scroll", x: 0, y: 1 });
71
+ for (const marker of ["KEY 26", "TEXT hello 世界", "MOTION", "BUTTON", "SCROLL"])
72
+ await wait(() => log.includes(marker));
73
+ const threads = await hub.command(s.sessionId, { type: "threads" });
74
+ assert.match(threads.artifact, /\.txt$/);
75
+ const recording = await hub.command(s.sessionId, { type: "recording" });
76
+ assert.match(recording.artifact, /\.jfr$/);
77
+ assert.match((await hub.artifact(s.sessionId, threads.artifact)).text, /ModLens telemetry/);
78
+ const recorded = await hub.artifact(s.sessionId, recording.artifact);
79
+ assert.equal((await readFile(recorded.path)).subarray(0, 3).toString(), "FLR");
80
+ const allocations = await hub.command(s.sessionId, {
81
+ type: "allocations",
82
+ packagePrefix: "io.modlens.runtime.NativeSmoke",
83
+ windowSeconds: 30,
84
+ limit: 20,
85
+ });
86
+ const report = JSON.parse((await hub.artifact(s.sessionId, allocations.artifact)).text);
87
+ assert.ok(report.matchedSamples > 0, "Expected real JFR allocation samples for the workload");
88
+ assert.ok(report.hotspots.some((h) => h.site.includes("NativeSmoke.allocateFixture")));
89
+ assert.ok(report.hotspots.every((h) => h.site.startsWith("io.modlens.runtime.NativeSmoke.")));
90
+ const noMatch = await hub.command(s.sessionId, {
91
+ type: "allocations",
92
+ packagePrefix: "no.such.mod",
93
+ windowSeconds: 30,
94
+ limit: 20,
95
+ });
96
+ const empty = JSON.parse((await hub.artifact(s.sessionId, noMatch.artifact)).text);
97
+ assert.equal(empty.matchedSamples, 0);
98
+ assert.deepEqual(empty.hotspots, []);
99
+ console.log("Allocation hotspot report matched " + report.matchedSamples + " real samples.");
100
+ await hub.command(s.sessionId, { type: "mode", mode: "observe" });
101
+ await hub.command(s.sessionId, { type: "key", key: "TAB", down: true, holdMs: 100 });
102
+ await hub.command(s.sessionId, { type: "mode", mode: "interactive" });
103
+ await assert.rejects(
104
+ hub.command(s.sessionId, { type: "key", key: "W", down: true, holdMs: 100 }),
105
+ /interactive mode/,
106
+ );
107
+ await hub.command(s.sessionId, { type: "mode", mode: "hidden" });
108
+ await hub.command(s.sessionId, { type: "release_all" });
109
+ await writeFile(join(root, "finish"), "done");
110
+ assert.equal(await exited, 0, log);
111
+ await wait(async () => (await hub.events()).events.some((e) => e.type === "uncaught_exception"));
112
+ await writeFile(
113
+ join(root, "evidence.json"),
114
+ JSON.stringify({ sessions: await hub.list(), events: await hub.events(), log }, null, 2),
115
+ );
116
+ console.log("SDL runtime smoke passed. Evidence: " + root);
117
+ } finally {
118
+ if (child.exitCode === null) child.kill();
119
+ await hub.close();
120
+ }
@@ -0,0 +1,209 @@
1
+ // HTTP MCP -> local CLI -> persistent companion -> real instrumented SDL JVM.
2
+ // Set JAVA_HOME (25+) and MODLENS_SDL_CLASSPATH, after building server and agent.
3
+ import assert from "node:assert/strict";
4
+ import { spawn, execFileSync } from "node:child_process";
5
+ import { mkdtemp, mkdir, writeFile, readFile, access } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join, delimiter } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { createServer } from "node:net";
10
+ import { once } from "node:events";
11
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
+
14
+ if (!process.env.JAVA_HOME || !process.env.MODLENS_SDL_CLASSPATH)
15
+ throw new Error("Set JAVA_HOME and MODLENS_SDL_CLASSPATH");
16
+ const repo = fileURLToPath(new URL("..", import.meta.url));
17
+ const root = await mkdtemp(join(tmpdir(), "modlens-runtime-remote-"));
18
+ console.log("Evidence: " + root);
19
+ const listener = createServer();
20
+ listener.listen(0, "127.0.0.1");
21
+ await once(listener, "listening");
22
+ const port = listener.address().port;
23
+ await new Promise((resolve) => listener.close(resolve));
24
+ const remoteEnv = {
25
+ ...process.env,
26
+ MCP_PORT: String(port),
27
+ MCP_HOST: "127.0.0.1",
28
+ MODLENS_AUTO_EMBED: "0",
29
+ MODLENS_CACHE_ROOT: join(root, "remote-cache"),
30
+ MODLENS_HOME: join(root, "remote-home"),
31
+ DATABASE_URL: `file:${join(root, "unused.db")}`,
32
+ };
33
+ const localEnv = {
34
+ ...process.env,
35
+ MCP_PORT: String(port),
36
+ MODLENS_CACHE_ROOT: join(root, "local-cache"),
37
+ MODLENS_HOME: join(root, "local-home"),
38
+ };
39
+ const server = spawn(process.execPath, [join(repo, "dist/server.js")], {
40
+ cwd: root,
41
+ env: remoteEnv,
42
+ stdio: ["ignore", "ignore", "pipe"],
43
+ windowsHide: true,
44
+ });
45
+ let serverLog = "";
46
+ server.stderr.on("data", (b) => (serverLog += b));
47
+ const serverDone = once(server, "exit");
48
+ const client = new Client({ name: "runtime-remote-smoke", version: "1.0.0" });
49
+ const cli = (args) =>
50
+ JSON.parse(
51
+ execFileSync(process.execPath, [join(repo, "dist/launcher.js"), "--runtime", ...args], {
52
+ cwd: root,
53
+ env: localEnv,
54
+ encoding: "utf8",
55
+ timeout: 45000,
56
+ }),
57
+ );
58
+ async function local(request) {
59
+ const file = join(root, "request.json");
60
+ await writeFile(file, JSON.stringify(request));
61
+ return cli(["--request-file", file]);
62
+ }
63
+ async function plan(request) {
64
+ const response = await client.callTool({ name: "runtime", arguments: request });
65
+ assert.ok(!response.isError, JSON.stringify(response));
66
+ const result = JSON.parse(response.content[0].text);
67
+ assert.equal(result.executed, false);
68
+ assert.equal(result.execution, "local_helper_required");
69
+ assert.ok(result.invocation.arguments.includes("--runtime"));
70
+ return result;
71
+ }
72
+ async function throughRemote(request) {
73
+ return local((await plan(request)).request);
74
+ }
75
+ async function wait(fn, ms = 30000) {
76
+ const end = Date.now() + ms;
77
+ while (Date.now() < end) {
78
+ const value = await fn();
79
+ if (value) return value;
80
+ await new Promise((r) => setTimeout(r, 200));
81
+ }
82
+ throw new Error("Timed out\n" + serverLog);
83
+ }
84
+ let game,
85
+ gameDone,
86
+ gameLog = "";
87
+ try {
88
+ await wait(() => serverLog.includes("listening on"));
89
+ await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)));
90
+ assert.match(client.getInstructions(), /local.*helper/);
91
+ const project = join(root, "local project with spaces");
92
+ await mkdir(join(project, "gradle/wrapper"), { recursive: true });
93
+ await writeFile(join(project, "gradle/wrapper/gradle-wrapper.jar"), "fixture");
94
+ const setupPlan = await plan({ action: "setup", projectDir: project, mode: "hidden" });
95
+ await assert.rejects(access(join(project, ".modlens")), "Remote planning must not change local files");
96
+ const setup = await local(setupPlan.request);
97
+ assert.equal(setup.state, "configured");
98
+ await assert.rejects(
99
+ access(join(root, "local-home", "data/modlens.db")),
100
+ "Helper must not bootstrap a source database",
101
+ );
102
+ await assert.rejects(
103
+ access(join(root, "remote-cache/runtime")),
104
+ "Remote server must not start a runtime bridge",
105
+ );
106
+ const agent = join(repo, "dist/runtime/modlens-agent.jar");
107
+ const java = (name) =>
108
+ join(process.env.JAVA_HOME, "bin", name + (process.platform === "win32" ? ".exe" : ""));
109
+ execFileSync(
110
+ java("javac"),
111
+ ["-cp", agent, "-d", root, join(repo, "runtime-agent/test/io/modlens/runtime/NativeSmoke.java")],
112
+ { stdio: "inherit" },
113
+ );
114
+ game = spawn(
115
+ java("java"),
116
+ [
117
+ "--enable-native-access=ALL-UNNAMED",
118
+ setup.vmOption,
119
+ "-cp",
120
+ [root, agent, process.env.MODLENS_SDL_CLASSPATH].join(delimiter),
121
+ "io.modlens.runtime.NativeSmoke",
122
+ root,
123
+ ],
124
+ { windowsHide: true },
125
+ );
126
+ game.stdout.on("data", (b) => (gameLog += b));
127
+ game.stderr.on("data", (b) => (gameLog += b));
128
+ gameDone = once(game, "exit");
129
+ const session = await wait(async () =>
130
+ (await local({ action: "sessions" })).find((s) => s.capabilities.inputBackend === "lwjgl-sdl3"),
131
+ );
132
+ const key = await throughRemote({
133
+ action: "command",
134
+ sessionId: session.sessionId,
135
+ command: { type: "key", key: "W", down: true, holdMs: 150 },
136
+ });
137
+ assert.equal(key.state, "event_delivered");
138
+ const allocation = await throughRemote({
139
+ action: "command",
140
+ sessionId: session.sessionId,
141
+ command: { type: "allocations", packagePrefix: "io.modlens.runtime.NativeSmoke" },
142
+ });
143
+ const report = await throughRemote({
144
+ action: "artifact",
145
+ sessionId: session.sessionId,
146
+ artifactName: allocation.artifact,
147
+ });
148
+ assert.ok(JSON.parse(report.text).hotspots.some((h) => h.site.includes("allocateFixture")));
149
+ assert.ok(
150
+ cli(["sessions"]).some((s) => s.sessionId === session.sessionId),
151
+ "CLI invocations must share the same live session",
152
+ );
153
+ cli(["stop"]);
154
+ await local({ action: "status" });
155
+ await wait(async () =>
156
+ (await local({ action: "sessions" })).find((s) => s.sessionId === session.sessionId),
157
+ );
158
+ const threads = await throughRemote({
159
+ action: "command",
160
+ sessionId: session.sessionId,
161
+ command: { type: "threads" },
162
+ });
163
+ assert.match(
164
+ (
165
+ await throughRemote({
166
+ action: "artifact",
167
+ sessionId: session.sessionId,
168
+ artifactName: threads.artifact,
169
+ })
170
+ ).text,
171
+ /ModLens telemetry/,
172
+ );
173
+ await writeFile(join(root, "finish"), "done");
174
+ assert.equal((await gameDone)[0], 0, gameLog);
175
+ const events = await wait(async () => {
176
+ const result = await local({ action: "events" });
177
+ return result.events.some((e) => e.type === "uncaught_exception") && result;
178
+ });
179
+ assert.match(gameLog, /KEY 26/);
180
+ await writeFile(
181
+ join(root, "evidence.json"),
182
+ JSON.stringify({ session, report: JSON.parse(report.text), events, gameLog }, null, 2),
183
+ );
184
+ cli(["stop"]);
185
+ await wait(async () => {
186
+ try {
187
+ await access(join(root, "local-cache/runtime/bridge.lock"));
188
+ return false;
189
+ } catch {
190
+ return true;
191
+ }
192
+ });
193
+ assert.equal(cli(["stop"]).state, "not_running");
194
+ console.log(
195
+ "PASS: remote MCP guidance, local helper setup, persistent session, real JVM input, allocation report, crash events, and clean stop",
196
+ );
197
+ } finally {
198
+ if (game && game.exitCode === null) {
199
+ game.kill();
200
+ await gameDone;
201
+ }
202
+ try {
203
+ cli(["stop"]);
204
+ } catch {}
205
+ await client.close();
206
+ server.kill();
207
+ await serverDone;
208
+ await writeFile(join(root, "server.log"), serverLog);
209
+ }
@@ -0,0 +1,14 @@
1
+ // Refresh reviewed standard texts from SPDX. Review and commit changes before deploying.
2
+ import { writeFile } from "node:fs/promises";
3
+ const ids = ["0BSD", "Unlicense", "MIT", "ISC", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "LGPL-3.0-only", "GPL-3.0-only", "CC-BY-NC-SA-4.0"];
4
+ const entries = await Promise.all(ids.map(async id => {
5
+ const url = `https://raw.githubusercontent.com/spdx/license-list-data/v3.27.0/json/details/${id}.json`;
6
+ const response = await fetch(url);
7
+ if (!response.ok) throw new Error(`${id}: ${response.status}`);
8
+ const data = await response.json();
9
+ return [id, { name: data.name, text: data.licenseText, source: url }];
10
+ }));
11
+ await writeFile(new URL("../src/license-templates.ts", import.meta.url),
12
+ "// SPDX License List 3.27.0 standard texts; see scripts/update-license-templates.mjs.\n" +
13
+ "// SPDX data: https://github.com/spdx/license-list-data (CC0-1.0).\n" +
14
+ `export const licenseTemplates: Record<string, { name: string; text: string; source: string }> = ${JSON.stringify(Object.fromEntries(entries), null, 2)};\n`);