@creeperhost/modlens-mcp 1.6.23 → 1.6.26
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/README.md +14 -6
- package/RUNTIME.md +30 -25
- package/dist/hosted-oauth.d.ts +5 -0
- package/dist/hosted-oauth.d.ts.map +1 -1
- package/dist/hosted-oauth.js +156 -50
- package/dist/hosted-oauth.js.map +1 -1
- package/dist/hosted-tool-catalog.d.ts +11 -0
- package/dist/hosted-tool-catalog.d.ts.map +1 -0
- package/dist/hosted-tool-catalog.js +48 -0
- package/dist/hosted-tool-catalog.js.map +1 -0
- package/dist/private-network.d.ts +4 -0
- package/dist/private-network.d.ts.map +1 -0
- package/dist/private-network.js +17 -0
- package/dist/private-network.js.map +1 -0
- package/dist/runtime/companion.d.ts +3 -3
- package/dist/runtime/guidance.d.ts +1 -1
- package/dist/runtime/guidance.js +1 -1
- package/dist/runtime/guidance.js.map +1 -1
- package/dist/runtime/hub.d.ts +2 -1
- package/dist/runtime/hub.d.ts.map +1 -1
- package/dist/runtime/hub.js +22 -12
- package/dist/runtime/hub.js.map +1 -1
- package/dist/runtime/modlens-agent.jar +0 -0
- package/dist/runtime/requests.d.ts +4 -4
- package/dist/runtime/requests.d.ts.map +1 -1
- package/dist/runtime/requests.js +5 -4
- package/dist/runtime/requests.js.map +1 -1
- package/dist/server.js +29 -10
- package/dist/server.js.map +1 -1
- package/package.json +4 -1
- package/scripts/build-runtime-agent.mjs +63 -3
- package/scripts/gradle/modlens-runtime.init.gradle +1 -1
- package/scripts/test-hosted-oauth.mjs +75 -12
- package/scripts/test-hosted-policy.mjs +26 -0
- package/scripts/test-package.mjs +4 -0
- package/scripts/test-runtime-glfw.mjs +81 -0
- package/scripts/test-runtime-legacy.mjs +71 -0
- package/scripts/test-runtime-lwjgl2-real.mjs +59 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, readdir, writeFile, rm } from "node:fs/promises";
|
|
1
|
+
import { mkdir, mkdtemp, readdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import AdmZip from "adm-zip";
|
|
5
7
|
|
|
6
8
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
7
9
|
const scratch = join(root, ".scratch");
|
|
@@ -9,10 +11,29 @@ await mkdir(scratch, { recursive: true });
|
|
|
9
11
|
// A clean output directory prevents removed/renamed classes leaking into the JAR.
|
|
10
12
|
const build = await mkdtemp(join(scratch, "runtime-build-"));
|
|
11
13
|
const output = join(root, "dist/runtime");
|
|
14
|
+
const asmJar = join(root, "runtime-agent/vendor/asm-9.9.1.jar");
|
|
15
|
+
const asmSha256 = "6f3828a215c920059a5efa2fb55c233d6c54ec5cadca99ce1b1bdd10077c7ddd";
|
|
16
|
+
const relocate = (bytes) => {
|
|
17
|
+
const source = Buffer.from("org/objectweb/asm");
|
|
18
|
+
const target = Buffer.from("io/modlens/asm/v1");
|
|
19
|
+
if (source.length !== target.length) throw new Error("ASM relocation must preserve class-file lengths");
|
|
20
|
+
const output = Buffer.from(bytes);
|
|
21
|
+
for (let at = output.indexOf(source); at !== -1; at = output.indexOf(source, at + target.length))
|
|
22
|
+
target.copy(output, at);
|
|
23
|
+
const dottedSource = Buffer.from("org.objectweb.asm");
|
|
24
|
+
const dottedTarget = Buffer.from("io.modlens.asm.v1");
|
|
25
|
+
for (let at = output.indexOf(dottedSource); at !== -1; at = output.indexOf(dottedSource, at + dottedTarget.length))
|
|
26
|
+
dottedTarget.copy(output, at);
|
|
27
|
+
return output;
|
|
28
|
+
};
|
|
12
29
|
const javaTool = (name) =>
|
|
13
30
|
process.env.JAVA_HOME
|
|
14
31
|
? join(process.env.JAVA_HOME, "bin", name + (process.platform === "win32" ? ".exe" : ""))
|
|
15
32
|
: name;
|
|
33
|
+
const legacyJavaTool = (name) => {
|
|
34
|
+
const home = process.env.MODLENS_LEGACY_JAVA_HOME || process.env.JAVA_HOME_17_X64;
|
|
35
|
+
return home ? join(home, "bin", name + (process.platform === "win32" ? ".exe" : "")) : javaTool(name);
|
|
36
|
+
};
|
|
16
37
|
async function sources(dir) {
|
|
17
38
|
return (
|
|
18
39
|
await Promise.all(
|
|
@@ -31,9 +52,45 @@ try {
|
|
|
31
52
|
const version = execFileSync(javaTool("javac"), ["-version"], { encoding: "utf8" });
|
|
32
53
|
if (Number(/javac (\d+)/.exec(version)?.[1]) < 25)
|
|
33
54
|
throw new Error("Building the runtime agent requires JDK 25+. Set JAVA_HOME.");
|
|
55
|
+
const legacyFiles = await sources(join(root, "runtime-agent/legacy"));
|
|
34
56
|
const files = await sources(join(root, "runtime-agent/src"));
|
|
35
57
|
// javac argument files avoid Windows command length limits and handle spaces.
|
|
36
|
-
const
|
|
58
|
+
const vendor = await readFile(asmJar);
|
|
59
|
+
if (createHash("sha256").update(vendor).digest("hex") !== asmSha256)
|
|
60
|
+
throw new Error("Vendored ASM checksum mismatch");
|
|
61
|
+
const legacyArgs = ["--release", "8", "-cp", asmJar, "-d", build, ...legacyFiles];
|
|
62
|
+
await writeFile(
|
|
63
|
+
join(build, "legacy.args"),
|
|
64
|
+
legacyArgs.map((a) => `"${a.replaceAll("\\", "/").replaceAll('"', '\\"')}"`).join("\n"),
|
|
65
|
+
);
|
|
66
|
+
execFileSync(legacyJavaTool("javac"), ["@" + join(build, "legacy.args")], { stdio: "inherit" });
|
|
67
|
+
const jfrFiles = await sources(join(root, "runtime-agent/jfr11"));
|
|
68
|
+
const jfrArgs = ["--release", "11", "-cp", build, "-d", build, ...jfrFiles];
|
|
69
|
+
await writeFile(
|
|
70
|
+
join(build, "jfr.args"),
|
|
71
|
+
jfrArgs.map((a) => `"${a.replaceAll("\\", "/").replaceAll('"', '\\"')}"`).join("\n"),
|
|
72
|
+
);
|
|
73
|
+
execFileSync(legacyJavaTool("javac"), ["@" + join(build, "jfr.args")], { stdio: "inherit" });
|
|
74
|
+
const classes = async (dir) => (await readdir(dir, { withFileTypes: true })).flatMap((entry) =>
|
|
75
|
+
entry.isDirectory() ? [join(dir, entry.name)] : entry.name.endsWith(".class") ? [join(dir, entry.name)] : []);
|
|
76
|
+
const relocateTree = async (dir) => {
|
|
77
|
+
for (const entry of await classes(dir)) {
|
|
78
|
+
if (entry.endsWith(".class")) await writeFile(entry, relocate(await readFile(entry)));
|
|
79
|
+
else await relocateTree(entry);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await relocateTree(join(build, "io"));
|
|
83
|
+
const asm = new AdmZip(vendor);
|
|
84
|
+
for (const entry of asm.getEntries()) {
|
|
85
|
+
if (!entry.entryName.startsWith("org/objectweb/asm/") || !entry.entryName.endsWith(".class")) continue;
|
|
86
|
+
const destination = join(build, entry.entryName.replace("org/objectweb/asm/", "io/modlens/asm/v1/"));
|
|
87
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
88
|
+
await writeFile(destination, relocate(entry.getData()));
|
|
89
|
+
}
|
|
90
|
+
const license = join(build, "META-INF/licenses/ASM.txt");
|
|
91
|
+
await mkdir(dirname(license), { recursive: true });
|
|
92
|
+
await writeFile(license, await readFile(join(root, "runtime-agent/vendor/ASM-LICENSE.txt")));
|
|
93
|
+
const args = ["-cp", build, "-d", build, ...files];
|
|
37
94
|
await writeFile(
|
|
38
95
|
join(build, "javac.args"),
|
|
39
96
|
args.map((a) => `"${a.replaceAll("\\", "/").replaceAll('"', '\\"')}"`).join("\n"),
|
|
@@ -44,7 +101,7 @@ try {
|
|
|
44
101
|
"Manifest-Version: 1.0\nPremain-Class: io.modlens.runtime.Agent\nCan-Redefine-Classes: false\nCan-Retransform-Classes: false\n\n",
|
|
45
102
|
);
|
|
46
103
|
execFileSync(
|
|
47
|
-
|
|
104
|
+
legacyJavaTool("jar"),
|
|
48
105
|
[
|
|
49
106
|
"--create",
|
|
50
107
|
"--file",
|
|
@@ -54,6 +111,9 @@ try {
|
|
|
54
111
|
"-C",
|
|
55
112
|
build,
|
|
56
113
|
"io",
|
|
114
|
+
"-C",
|
|
115
|
+
build,
|
|
116
|
+
"META-INF/licenses",
|
|
57
117
|
],
|
|
58
118
|
{ stdio: "inherit" },
|
|
59
119
|
);
|
|
@@ -12,6 +12,6 @@ gradle.projectsEvaluated {
|
|
|
12
12
|
}
|
|
13
13
|
target.jvmArgs("-javaagent:${config.getProperty('agent')}=${config.getProperty('connection')}")
|
|
14
14
|
// Minecraft 26.3 client startup needs this even without the agent.
|
|
15
|
-
target.jvmArgs('-XX:StackShadowPages=32')
|
|
15
|
+
if (config.getProperty('mcVersion', '26.3') == '26.3') target.jvmArgs('-XX:StackShadowPages=32')
|
|
16
16
|
target.jvmArgs("-XX:ErrorFile=${new File(configFile.parentFile, 'hs_err_pid%p.log').absolutePath}")
|
|
17
17
|
}
|
|
@@ -7,6 +7,8 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
11
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
10
12
|
|
|
11
13
|
const root = await mkdtemp(join(tmpdir(), 'modlens-oauth-test-'));
|
|
12
14
|
const entry = fileURLToPath(new URL('../dist/server.js', import.meta.url));
|
|
@@ -26,13 +28,15 @@ const profiles = new Map();
|
|
|
26
28
|
let partner = true;
|
|
27
29
|
let subject = 'customer:42';
|
|
28
30
|
let serial = 0;
|
|
31
|
+
let metadataRequests = 0;
|
|
29
32
|
const provider = createServer(async (req, res) => {
|
|
30
33
|
const url = new URL(req.url, issuer);
|
|
31
34
|
const write = (status, data) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); };
|
|
32
|
-
if (url.pathname === '/.well-known/oauth-authorization-server')
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
if (url.pathname === '/.well-known/oauth-authorization-server') {
|
|
36
|
+
if (++metadataRequests === 1) return write(503, { error: 'temporarily_unavailable', access_token: 'metadata-secret' });
|
|
37
|
+
return write(200, { issuer, authorization_endpoint: issuer + '/authorize', token_endpoint: issuer + '/token',
|
|
38
|
+
authorization_response_iss_parameter_supported: true });
|
|
39
|
+
}
|
|
36
40
|
if (url.pathname === '/authorize') {
|
|
37
41
|
const callback = new URL(url.searchParams.get('redirect_uri'));
|
|
38
42
|
callback.searchParams.set('code', 'fake-code');
|
|
@@ -46,6 +50,8 @@ const provider = createServer(async (req, res) => {
|
|
|
46
50
|
const data = new URLSearchParams(raw);
|
|
47
51
|
if (data.get('grant_type') === 'refresh_token' && !data.get('refresh_token')?.startsWith('upstream-refresh-'))
|
|
48
52
|
return write(400, { error: 'invalid_grant' });
|
|
53
|
+
if (data.get('grant_type') === 'refresh_token')
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
49
55
|
if (data.get('grant_type') === 'authorization_code' && data.get('code') !== 'fake-code')
|
|
50
56
|
return write(400, { error: 'invalid_grant' });
|
|
51
57
|
const access = `upstream-${++serial}`;
|
|
@@ -96,7 +102,7 @@ const initialize = (access, extra = {}) => fetchManual(resource, { method: 'POST
|
|
|
96
102
|
protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'oauth-test', version: '1' },
|
|
97
103
|
} }) });
|
|
98
104
|
let clientId;
|
|
99
|
-
async function login() {
|
|
105
|
+
async function login(expectedName = 'ChatGPT & Codex <test>') {
|
|
100
106
|
const verifier = randomBytes(32).toString('base64url');
|
|
101
107
|
const pkce = createHash('sha256').update(verifier).digest('base64url');
|
|
102
108
|
const callback = 'http://127.0.0.1:54321/callback';
|
|
@@ -111,9 +117,19 @@ async function login() {
|
|
|
111
117
|
const done = await fetchManual(back.headers.get('location'));
|
|
112
118
|
assert.equal(done.status, 200);
|
|
113
119
|
assert.match(done.headers.get('content-security-policy'), /default-src 'none'/);
|
|
120
|
+
assert.match(done.headers.get('content-security-policy'), /form-action 'self' http:\/\/127\.0\.0\.1:54321;/,
|
|
121
|
+
'consent form permits its registered OAuth callback after the approval redirect');
|
|
114
122
|
const consentPage = await done.text();
|
|
115
123
|
assert.match(consentPage, /CREEPERHOST/);
|
|
116
124
|
assert.match(consentPage, /Allow access\?/);
|
|
125
|
+
if (expectedName) {
|
|
126
|
+
assert.ok(consentPage.includes(expectedName), 'registered application names are displayed and HTML-escaped');
|
|
127
|
+
assert.match(consentPage, /Name supplied by application/);
|
|
128
|
+
} else {
|
|
129
|
+
assert.match(consentPage, /Unnamed application/);
|
|
130
|
+
assert.match(consentPage, /No application name supplied/);
|
|
131
|
+
}
|
|
132
|
+
assert.match(consentPage, /Callback: http:\/\/127\.0\.0\.1:54321/);
|
|
117
133
|
assert.match(consentPage, /Only continue if you started this request/);
|
|
118
134
|
const approval = consentPage.match(/name="approval" value="([^"]+)"/)?.[1];
|
|
119
135
|
assert.ok(approval);
|
|
@@ -152,32 +168,70 @@ async function login() {
|
|
|
152
168
|
}
|
|
153
169
|
try {
|
|
154
170
|
await start();
|
|
171
|
+
assert.match(serverOutput, /"event":"provider_metadata_retry".*"status":503/,
|
|
172
|
+
'transient provider discovery failures are logged and retried');
|
|
173
|
+
assert.match(serverOutput, /"event":"provider_metadata_response".*temporarily_unavailable/,
|
|
174
|
+
'failed provider responses include a useful body excerpt');
|
|
175
|
+
assert.ok(!serverOutput.includes('metadata-secret'), 'provider response logging redacts token fields');
|
|
155
176
|
const absent = await fetchManual(resource);
|
|
156
177
|
assert.equal(absent.status, 401);
|
|
157
178
|
assert.match(absent.headers.get('www-authenticate'), /resource_metadata=/);
|
|
158
179
|
const metadata = await (await fetchManual(`http://127.0.0.1:${mcpPort}/.well-known/oauth-protected-resource/mcp`)).json();
|
|
159
180
|
assert.deepEqual(metadata.authorization_servers, [`http://127.0.0.1:${mcpPort}`]);
|
|
160
181
|
const registration = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/register`, { method: 'POST',
|
|
161
|
-
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
|
182
|
+
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
|
183
|
+
redirect_uris: ['http://127.0.0.1:54321/callback'], client_name: 'ChatGPT & Codex <test>' }) });
|
|
162
184
|
assert.equal(registration.status, 201);
|
|
163
|
-
|
|
185
|
+
const registered = await registration.json();
|
|
186
|
+
assert.equal(registered.client_name, 'ChatGPT & Codex <test>');
|
|
187
|
+
clientId = registered.client_id;
|
|
164
188
|
const tokens = await login();
|
|
165
189
|
assert.match(tokens.access_token, /^mla_/);
|
|
166
190
|
assert.match(tokens.refresh_token, /^mlr_/);
|
|
167
191
|
assert.equal((await initialize(tokens.access_token)).status, 200);
|
|
192
|
+
const runtimeClient = new Client({ name: 'hosted-runtime-test', version: '1' });
|
|
193
|
+
await runtimeClient.connect(new StreamableHTTPClientTransport(new URL(resource), {
|
|
194
|
+
requestInit: { headers: { Authorization: `Bearer ${tokens.access_token}` } },
|
|
195
|
+
}));
|
|
196
|
+
try {
|
|
197
|
+
const runtime = await runtimeClient.callTool({ name: 'runtime', arguments: {
|
|
198
|
+
action: 'setup', projectDir: '/local/mod-project', mcVersion: '1.7.10', mode: 'interactive',
|
|
199
|
+
} });
|
|
200
|
+
assert.equal(runtime.isError, undefined);
|
|
201
|
+
const plan = JSON.parse(runtime.content[0].text);
|
|
202
|
+
assert.equal(plan.executed, false);
|
|
203
|
+
assert.equal(plan.request.mcVersion, '1.7.10');
|
|
204
|
+
assert.ok(plan.invocation.arguments.includes('--runtime'));
|
|
205
|
+
} finally { await runtimeClient.close(); }
|
|
168
206
|
assert.equal((await initialize(tokens.access_token, { 'x-modlens-user-id': 'forged' })).status, 401);
|
|
169
207
|
await stop();
|
|
170
208
|
await start();
|
|
171
209
|
assert.equal((await initialize(tokens.access_token)).status, 200, 'grant survives restart');
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
210
|
+
const refreshes = await Promise.all(Array.from({ length: 4 }, () =>
|
|
211
|
+
fetchManual(`http://127.0.0.1:${mcpPort}/oauth/token`, { method: 'POST',
|
|
212
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form({ grant_type: 'refresh_token',
|
|
213
|
+
client_id: clientId, refresh_token: tokens.refresh_token, resource }) })));
|
|
214
|
+
for (const refresh of refreshes) assert.equal(refresh.status, 200, 'overlapping refresh requests share one rotation');
|
|
215
|
+
const rotations = await Promise.all(refreshes.map(response => response.json()));
|
|
216
|
+
assert.equal(new Set(rotations.map(response => response.refresh_token)).size, 1);
|
|
217
|
+
assert.equal((serverOutput.match(/"event":"refresh_issued"/g) ?? []).length, 1);
|
|
218
|
+
assert.equal((serverOutput.match(/"event":"refresh_joined"/g) ?? []).length, 3);
|
|
219
|
+
const rotated = rotations[0];
|
|
177
220
|
const replay = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/token`, { method: 'POST',
|
|
178
221
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form({ grant_type: 'refresh_token',
|
|
179
222
|
client_id: clientId, refresh_token: tokens.refresh_token }) });
|
|
180
223
|
assert.equal(replay.status, 400);
|
|
224
|
+
const oldRefreshRef = createHash('sha256').update(tokens.refresh_token).digest('hex').slice(0, 12);
|
|
225
|
+
const refreshEvents = serverOutput.split('\n').filter(line => line.includes('[modlens] oauth '))
|
|
226
|
+
.map(line => JSON.parse(line.slice(line.indexOf('[modlens] oauth ') + '[modlens] oauth '.length)));
|
|
227
|
+
const issued = refreshEvents.find(event => event.event === 'refresh_issued');
|
|
228
|
+
const rejected = refreshEvents.find(event => event.event === 'refresh_rejected');
|
|
229
|
+
assert.equal(issued.previousRefreshRef, oldRefreshRef);
|
|
230
|
+
assert.equal(rejected.refreshRef, oldRefreshRef);
|
|
231
|
+
assert.equal(rejected.reason, 'not_current_or_missing');
|
|
232
|
+
assert.equal(issued.grant.length, 12);
|
|
233
|
+
assert.ok(refreshEvents.filter(event => event.event.startsWith('refresh_'))
|
|
234
|
+
.every(event => /^[a-f0-9]{12}$/.test(event.instance)));
|
|
181
235
|
const firstSession = await initialize(rotated.access_token);
|
|
182
236
|
assert.equal(firstSession.status, 200);
|
|
183
237
|
const sessionId = firstSession.headers.get('mcp-session-id');
|
|
@@ -191,6 +245,10 @@ try {
|
|
|
191
245
|
partner = false;
|
|
192
246
|
assert.equal((await initialize(rotated.access_token)).status, 403);
|
|
193
247
|
assert.equal((await initialize(rotated.access_token)).status, 401);
|
|
248
|
+
assert.match(serverOutput, /"event":"grant_revoked".*"reason":"profile_unauthorized"/);
|
|
249
|
+
assert.match(serverOutput, /"event":"grant_revoked".*"reason":"profile_access_denied"/);
|
|
250
|
+
for (const secret of [tokens.refresh_token, rotated.refresh_token, rotated.access_token])
|
|
251
|
+
assert.ok(!serverOutput.includes(secret), 'refresh diagnostics must not log bearer material');
|
|
194
252
|
const denied = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/authorize?client_id=bad`);
|
|
195
253
|
assert.equal(denied.status, 400);
|
|
196
254
|
await stop();
|
|
@@ -200,6 +258,11 @@ try {
|
|
|
200
258
|
const generalAccount = await login();
|
|
201
259
|
assert.equal((await initialize(generalAccount.access_token)).status, 200,
|
|
202
260
|
'self-hosted mode accepts a valid account without a claim rule');
|
|
261
|
+
const unnamedRegistration = await fetchManual(`http://127.0.0.1:${mcpPort}/oauth/register`, { method: 'POST',
|
|
262
|
+
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ redirect_uris: ['http://127.0.0.1:54321/callback'] }) });
|
|
263
|
+
assert.equal(unnamedRegistration.status, 201);
|
|
264
|
+
clientId = (await unnamedRegistration.json()).client_id;
|
|
265
|
+
await login(null);
|
|
203
266
|
console.log('Hosted OAuth discovery, registration, sign-in, persistence, refresh, replay, session, revocation, partner, gateway-header and self-hosted checks passed.');
|
|
204
267
|
} finally {
|
|
205
268
|
await stop();
|
|
@@ -29,6 +29,7 @@ const env = { ...process.env, MCP_PORT: String(port), MCP_HOST: "127.0.0.1", MOD
|
|
|
29
29
|
MODLENS_HOSTED_DAILY_BYTES: "15000", MODLENS_HOSTED_PERIOD_BYTES: "100000",
|
|
30
30
|
MODLENS_HOSTED_DAILY_REQUESTS: "1000", MODLENS_HOSTED_MINUTE_REQUESTS: "120",
|
|
31
31
|
MODLENS_HOSTED_RESPONSE_BYTES: "131072", MODLENS_AUTO_EMBED: "0", MODLENS_AUTO_GRAPH: "0" };
|
|
32
|
+
env.MODLENS_OPENAI_DOMAIN_CHALLENGE = "test-openai-domain-token";
|
|
32
33
|
const source = Array.from({ length: 600 }, (_, i) => `// Synthetic fixture line ${i + 1}`).join("\n");
|
|
33
34
|
const fixtureDir = join(env.MODLENS_CACHE_ROOT, "mc-decompiled-named", "1.21.1", "example");
|
|
34
35
|
await mkdir(fixtureDir, { recursive: true });
|
|
@@ -67,10 +68,35 @@ const read = (client, startLine = 1) => client.callTool({ name: "mc_source", arg
|
|
|
67
68
|
let passed = false;
|
|
68
69
|
try {
|
|
69
70
|
await start();
|
|
71
|
+
const domainChallenge = await fetch(new URL("/.well-known/openai-apps-challenge", endpoint));
|
|
72
|
+
assert.equal(domainChallenge.status, 200);
|
|
73
|
+
assert.equal(domainChallenge.headers.get("content-type"), "text/plain; charset=utf-8");
|
|
74
|
+
assert.equal(await domainChallenge.text(), env.MODLENS_OPENAI_DOMAIN_CHALLENGE);
|
|
70
75
|
const unauthorized = await fetch(endpoint, { method: "POST", headers: { "x-modlens-user-id": "spoof" }, body: "{}" });
|
|
71
76
|
assert.equal(unauthorized.status, 401);
|
|
72
77
|
const publicUser = await connect("public-developer");
|
|
73
78
|
const publicTools = await publicUser.client.listTools();
|
|
79
|
+
const expectedTools = ["report_issue", "runtime", "mod_license", "mod", "mod_bytecode", "mod_mixins",
|
|
80
|
+
"platform", "modpacks_ch", "mc_versions", "mc_source", "mappings", "docs", "primers",
|
|
81
|
+
"mc_registry", "mc_data", "mc_files", "mod_jar", "mod_data", "mod_tags", "mixin_scan",
|
|
82
|
+
"gradle", "project", "reports", "pack_tools", "analyze_crash_log", "find_missing_deps"];
|
|
83
|
+
assert.deepEqual(publicTools.tools.map(t => t.name).sort(), expectedTools.sort());
|
|
84
|
+
for (const tool of publicTools.tools) {
|
|
85
|
+
assert.ok(tool.title, `${tool.name} has no title`);
|
|
86
|
+
assert.ok(tool.description, `${tool.name} has no description`);
|
|
87
|
+
for (const hint of ["readOnlyHint", "openWorldHint", "destructiveHint"])
|
|
88
|
+
assert.equal(typeof tool.annotations?.[hint], "boolean", `${tool.name} has no ${hint}`);
|
|
89
|
+
}
|
|
90
|
+
const tool = name => publicTools.tools.find(t => t.name === name);
|
|
91
|
+
for (const [name, fields] of Object.entries({
|
|
92
|
+
mod: ["jarPath", "directory", "outputDir", "bundlePath"],
|
|
93
|
+
mappings: ["inputJar", "outputJar"], project: ["bundlePath"],
|
|
94
|
+
reports: ["savePath"], pack_tools: ["scriptsDir", "configDir"],
|
|
95
|
+
})) for (const field of fields)
|
|
96
|
+
assert.ok(!(field in tool(name).inputSchema.properties), `${name} advertises host-only ${field}`);
|
|
97
|
+
assert.equal(tool("project").annotations.destructiveHint, true);
|
|
98
|
+
assert.equal(tool("mc_source").annotations.readOnlyHint, false);
|
|
99
|
+
assert.equal(tool("report_issue").annotations.readOnlyHint, true);
|
|
74
100
|
const publicMc = publicTools.tools.find(t => t.name === "mc_source");
|
|
75
101
|
assert.ok(publicMc.inputSchema.properties.action.enum.includes("source_info"));
|
|
76
102
|
assert.ok(publicMc.inputSchema.properties.action.enum.includes("index_status"));
|
package/scripts/test-package.mjs
CHANGED
|
@@ -137,6 +137,10 @@ try {
|
|
|
137
137
|
const configuredRuntime = runtime("--request-file",runtimeRequest);
|
|
138
138
|
assert.equal(configuredRuntime.state,"configured");
|
|
139
139
|
assert.deepEqual(configuredRuntime.vmOptions,[configuredRuntime.vmOption,"-XX:StackShadowPages=32"]);
|
|
140
|
+
writeFileSync(runtimeRequest,JSON.stringify({action:"setup",projectDir:runtimeProject,mcVersion:"1.7.10",mode:"interactive"}));
|
|
141
|
+
const legacyRuntime = runtime("--request-file",runtimeRequest);
|
|
142
|
+
assert.deepEqual(legacyRuntime.vmOptions,[legacyRuntime.vmOption]);
|
|
143
|
+
assert.equal(legacyRuntime.mcVersion,"1.7.10");
|
|
140
144
|
assert.equal(runtime("status").projects.length,1);
|
|
141
145
|
assert.deepEqual(runtime("sessions"),[]);
|
|
142
146
|
assert.ok(!existsSync(join(runtimeHome,"data")),"Runtime helper must not create a source database");
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { mkdtemp, mkdir, readdir, writeFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, delimiter } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { RuntimeHub } from '../dist/runtime/hub.js';
|
|
9
|
+
|
|
10
|
+
const repo = fileURLToPath(new URL('..', import.meta.url));
|
|
11
|
+
const javaHome = process.env.MODLENS_TEST_JAVA_HOME;
|
|
12
|
+
const native = Boolean(process.env.MODLENS_GLFW_CLASSPATH);
|
|
13
|
+
if (!javaHome) throw new Error('Set MODLENS_TEST_JAVA_HOME to a Java 17 or newer JDK');
|
|
14
|
+
const executable = name => join(javaHome, 'bin', name + (process.platform === 'win32' ? '.exe' : ''));
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), 'modlens-runtime-glfw-'));
|
|
16
|
+
const project = join(root, 'glfw-project');
|
|
17
|
+
const hub = new RuntimeHub(join(root, 'cache'));
|
|
18
|
+
let game;
|
|
19
|
+
let gameDone;
|
|
20
|
+
try {
|
|
21
|
+
await mkdir(join(project, 'gradle', 'wrapper'), { recursive: true });
|
|
22
|
+
await writeFile(join(project, 'gradle', 'wrapper', 'gradle-wrapper.jar'), 'fixture');
|
|
23
|
+
const setup = await hub.setup(project, native ? 'hidden' : 'observe', 'runClient', true, '1.20.1');
|
|
24
|
+
assert.deepEqual(setup.vmOptions, [setup.vmOption]);
|
|
25
|
+
const files = [];
|
|
26
|
+
if (native) files.push(join(repo, 'runtime-agent/test/io/modlens/runtime/NativeGlfwSmoke.java'));
|
|
27
|
+
else {
|
|
28
|
+
const fixture = join(repo, 'runtime-agent/test/legacy-fixture/org/lwjgl/glfw');
|
|
29
|
+
files.push(...(await readdir(fixture)).filter(name => name.endsWith('.java')).map(name => join(fixture, name)));
|
|
30
|
+
files.push(join(repo, 'runtime-agent/test/legacy-fixture/org/lwjgl/opengl/GL11.java'));
|
|
31
|
+
files.push(join(repo, 'runtime-agent/test/io/modlens/runtime/GlfwSmoke.java'));
|
|
32
|
+
}
|
|
33
|
+
const classpath = [root, process.env.MODLENS_GLFW_CLASSPATH].filter(Boolean).join(delimiter);
|
|
34
|
+
execFileSync(executable('javac'), ['-cp', classpath, '-d', root, ...files]);
|
|
35
|
+
game = spawn(executable('java'), [`-Djava.io.tmpdir=${root}`, setup.vmOption, '-cp', classpath,
|
|
36
|
+
native ? 'io.modlens.runtime.NativeGlfwSmoke' : 'io.modlens.runtime.GlfwSmoke', root],
|
|
37
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
38
|
+
let output = '', error = '';
|
|
39
|
+
game.stdout.on('data', bytes => { output += bytes; });
|
|
40
|
+
game.stderr.on('data', bytes => { error += bytes; });
|
|
41
|
+
gameDone = once(game, 'exit');
|
|
42
|
+
const deadline = Date.now() + 20000;
|
|
43
|
+
let session;
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
session = (await hub.list()).find(s => s.capabilities.target === '1.20.1' && s.capabilities.inputBackend === 'glfw');
|
|
46
|
+
if (session) break;
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
48
|
+
}
|
|
49
|
+
assert.ok(session, error || 'GLFW agent did not connect');
|
|
50
|
+
assert.equal(session.capabilities.jfr, true, JSON.stringify(await hub.events()));
|
|
51
|
+
const act = async command => {
|
|
52
|
+
try { return await hub.command(session.sessionId, command); }
|
|
53
|
+
catch (cause) { throw new Error(`${cause}\n${JSON.stringify(await hub.events())}\n${error}`); }
|
|
54
|
+
};
|
|
55
|
+
assert.equal((await act({ type: 'key', key: 'W', down: true, holdMs: 100 })).state, 'event_delivered');
|
|
56
|
+
assert.equal((await act({ type: 'mouse_button', button: 1, down: true, holdMs: 100 })).state, 'event_delivered');
|
|
57
|
+
assert.equal((await act({ type: 'mouse_move', x: 12, y: 5, relative: false })).state, 'event_delivered');
|
|
58
|
+
assert.equal((await act({ type: 'scroll', x: 0, y: 1 })).state, 'event_delivered');
|
|
59
|
+
assert.equal((await act({ type: 'text', text: 'A' })).state, 'event_delivered');
|
|
60
|
+
const frame = await act({ type: 'screenshot' });
|
|
61
|
+
assert.equal((await hub.artifact(session.sessionId, frame.artifact)).mimeType, 'image/png');
|
|
62
|
+
const recording = await act({ type: 'recording' });
|
|
63
|
+
assert.match(recording.artifact, /\.jfr$/);
|
|
64
|
+
const allocations = await act({ type: 'allocations', packagePrefix: 'io.modlens.runtime' });
|
|
65
|
+
const report = await hub.artifact(session.sessionId, allocations.artifact);
|
|
66
|
+
assert.equal(JSON.parse(report.text).kind, 'sampled_allocation_pressure');
|
|
67
|
+
assert.equal((await act({ type: 'mode', mode: 'hidden' })).mode, 'hidden');
|
|
68
|
+
await writeFile(join(root, 'finish'), 'done');
|
|
69
|
+
assert.equal((await gameDone)[0], 0, error);
|
|
70
|
+
for (const marker of ['KEY 87 1', 'BUTTON 0 1', 'MOVE 12.0 5.0', 'SCROLL 0.0 1.0', 'CHAR 65'])
|
|
71
|
+
assert.match(output, new RegExp(marker.replaceAll('.', '\\.')));
|
|
72
|
+
assert.equal((output.match(/KEY 87 1/g) ?? []).length, 1, 'physical key events must be filtered in observe mode');
|
|
73
|
+
console.log(`PASS: one packaged agent hooks ${native ? 'native ' : ''}GLFW callbacks, filters physical input, injects commands, captures a frame, and hides the window`);
|
|
74
|
+
} finally {
|
|
75
|
+
if (game && game.exitCode === null) {
|
|
76
|
+
game.kill();
|
|
77
|
+
await gameDone?.catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
await hub.close();
|
|
80
|
+
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
81
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { RuntimeHub } from '../dist/runtime/hub.js';
|
|
9
|
+
|
|
10
|
+
const repo = fileURLToPath(new URL('..', import.meta.url));
|
|
11
|
+
const javaHome = process.env.MODLENS_TEST_JAVA_HOME || process.env.MODLENS_JAVA8_HOME;
|
|
12
|
+
if (!javaHome) throw new Error('Set MODLENS_TEST_JAVA_HOME to a Java 8 or newer JDK');
|
|
13
|
+
const executable = name => join(javaHome, 'bin', name + (process.platform === 'win32' ? '.exe' : ''));
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), 'modlens-runtime-legacy-'));
|
|
15
|
+
const project = join(root, 'legacy-project');
|
|
16
|
+
const hub = new RuntimeHub(join(root, 'cache'));
|
|
17
|
+
let game;
|
|
18
|
+
try {
|
|
19
|
+
await mkdir(join(project, 'gradle', 'wrapper'), { recursive: true });
|
|
20
|
+
await writeFile(join(project, 'gradle', 'wrapper', 'gradle-wrapper.jar'), 'fixture');
|
|
21
|
+
const setup = await hub.setup(project, 'interactive', 'runClient', true, '1.7.10');
|
|
22
|
+
assert.deepEqual(setup.vmOptions, [setup.vmOption]);
|
|
23
|
+
const fixture = join(repo, 'runtime-agent/test/legacy-fixture');
|
|
24
|
+
execFileSync(executable('javac'), ['-d', root,
|
|
25
|
+
join(fixture, 'org/lwjgl/opengl/Display.java'),
|
|
26
|
+
join(fixture, 'org/lwjgl/opengl/GL11.java'),
|
|
27
|
+
join(fixture, 'org/lwjgl/input/Keyboard.java'),
|
|
28
|
+
join(fixture, 'org/lwjgl/input/Mouse.java'),
|
|
29
|
+
join(fixture, 'net/minecraft/CrashReport.java'),
|
|
30
|
+
join(repo, 'runtime-agent/test/io/modlens/runtime/LegacySmoke.java')]);
|
|
31
|
+
game = spawn(executable('java'), [setup.vmOption, '-cp', root,
|
|
32
|
+
'io.modlens.runtime.LegacySmoke', root], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
33
|
+
let gameError = '', gameOutput = '';
|
|
34
|
+
game.stderr.on('data', bytes => { gameError += bytes; });
|
|
35
|
+
game.stdout.on('data', bytes => { gameOutput += bytes; });
|
|
36
|
+
const gameDone = once(game, 'exit');
|
|
37
|
+
const deadline = Date.now() + 20000;
|
|
38
|
+
let session;
|
|
39
|
+
while (Date.now() < deadline) {
|
|
40
|
+
session = (await hub.list()).find(s => s.capabilities.target === '1.7.10' && s.capabilities.inputBackend === 'lwjgl2');
|
|
41
|
+
if (session) break;
|
|
42
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
43
|
+
}
|
|
44
|
+
assert.ok(session, gameError || 'Legacy agent did not connect');
|
|
45
|
+
assert.equal(session.capabilities.inputBackend, 'lwjgl2');
|
|
46
|
+
assert.equal(session.capabilities.threads, true);
|
|
47
|
+
assert.ok(session.javaVersion);
|
|
48
|
+
assert.ok(session.metrics.heapUsed > 0);
|
|
49
|
+
const threads = await hub.command(session.sessionId, { type: 'threads' });
|
|
50
|
+
const report = await hub.artifact(session.sessionId, threads.artifact);
|
|
51
|
+
assert.match(report.text, /ModLens telemetry/);
|
|
52
|
+
assert.equal((await hub.command(session.sessionId, { type: 'mode', mode: 'observe' })).mode, 'observe');
|
|
53
|
+
assert.equal((await hub.command(session.sessionId, { type: 'key', key: 'W', down: true, holdMs: 100 })).state, 'event_delivered');
|
|
54
|
+
assert.equal((await hub.command(session.sessionId, { type: 'mouse_button', button: 1, down: true, holdMs: 100 })).state, 'event_delivered');
|
|
55
|
+
assert.equal((await hub.command(session.sessionId, { type: 'scroll', y: 1, x: 0 })).state, 'event_delivered');
|
|
56
|
+
const frame = await hub.command(session.sessionId, { type: 'screenshot' });
|
|
57
|
+
assert.equal((await hub.artifact(session.sessionId, frame.artifact)).mimeType, 'image/png');
|
|
58
|
+
assert.match(gameOutput, /KEY 17 true/);
|
|
59
|
+
assert.match(gameOutput, /MOUSE 0 0/);
|
|
60
|
+
assert.match(gameOutput, /MOUSE -1 120/);
|
|
61
|
+
const events = await hub.events();
|
|
62
|
+
assert.ok(events.events.some(e => e.type === 'agent_started'));
|
|
63
|
+
assert.ok(events.events.some(e => e.type === 'minecraft_crash'));
|
|
64
|
+
await writeFile(join(root, 'finish'), 'done');
|
|
65
|
+
assert.equal((await gameDone)[0], 0, gameError);
|
|
66
|
+
console.log(`PASS: one packaged agent runs on Java ${session.javaVersion}, hooks LWJGL2 keyboard/mouse events and screenshots, and reports diagnostics`);
|
|
67
|
+
} finally {
|
|
68
|
+
if (game && game.exitCode === null) game.kill();
|
|
69
|
+
await hub.close();
|
|
70
|
+
await rm(root, { recursive: true, force: true });
|
|
71
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, delimiter } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { RuntimeHub } from '../dist/runtime/hub.js';
|
|
9
|
+
|
|
10
|
+
const repo = fileURLToPath(new URL('..', import.meta.url));
|
|
11
|
+
const javaHome = process.env.MODLENS_TEST_JAVA_HOME;
|
|
12
|
+
const lwjgl = process.env.MODLENS_LWJGL2_CLASSPATH;
|
|
13
|
+
if (!javaHome || !lwjgl) throw new Error('Set MODLENS_TEST_JAVA_HOME and MODLENS_LWJGL2_CLASSPATH');
|
|
14
|
+
const bin = name => join(javaHome, 'bin', name + (process.platform === 'win32' ? '.exe' : ''));
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), 'modlens-real-lwjgl2-'));
|
|
16
|
+
const project = join(root, 'project');
|
|
17
|
+
const agent = join(repo, 'dist/runtime/modlens-agent.jar');
|
|
18
|
+
const classpath = [root, agent, lwjgl].join(delimiter);
|
|
19
|
+
const hub = new RuntimeHub(join(root, 'cache'));
|
|
20
|
+
let game;
|
|
21
|
+
try {
|
|
22
|
+
await mkdir(join(project, 'gradle', 'wrapper'), { recursive: true });
|
|
23
|
+
await writeFile(join(project, 'gradle', 'wrapper', 'gradle-wrapper.jar'), 'fixture');
|
|
24
|
+
const setup = await hub.setup(project, 'observe', 'runClient', true, '1.7.10');
|
|
25
|
+
execFileSync(bin('javac'), ['-cp', classpath, '-d', root,
|
|
26
|
+
join(repo, 'runtime-agent/test/io/modlens/runtime/RealLwjgl2Smoke.java')]);
|
|
27
|
+
game = spawn(bin('java'), [setup.vmOption, '-cp', classpath,
|
|
28
|
+
'io.modlens.runtime.RealLwjgl2Smoke', root],
|
|
29
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
30
|
+
let output = '', error = '';
|
|
31
|
+
game.stdout.on('data', bytes => { output += bytes; });
|
|
32
|
+
game.stderr.on('data', bytes => { error += bytes; });
|
|
33
|
+
const done = once(game, 'exit');
|
|
34
|
+
const deadline = Date.now() + 20000;
|
|
35
|
+
let session;
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
session = (await hub.list()).find(s => s.capabilities.inputBackend === 'lwjgl2');
|
|
38
|
+
if (session) break;
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
40
|
+
}
|
|
41
|
+
assert.ok(session, error || 'LWJGL2 agent did not connect');
|
|
42
|
+
assert.equal((await hub.command(session.sessionId, { type: 'key', key: 'W', down: true, holdMs: 500 })).state, 'event_delivered');
|
|
43
|
+
assert.equal((await hub.command(session.sessionId, { type: 'mouse_button', button: 1, down: true, holdMs: 500 })).state, 'event_delivered');
|
|
44
|
+
assert.equal((await hub.command(session.sessionId, { type: 'scroll', x: 0, y: 1 })).state, 'event_delivered');
|
|
45
|
+
const events = (await hub.events()).events;
|
|
46
|
+
assert.ok(events.some(e => e.type === 'hooks_installed' && e.data.class === 'org/lwjgl/input/Keyboard'));
|
|
47
|
+
assert.ok(events.some(e => e.type === 'hooks_installed' && e.data.class === 'org/lwjgl/input/Mouse'));
|
|
48
|
+
assert.ok(events.some(e => e.type === 'hooks_installed' && e.data.class === 'org/lwjgl/opengl/Display'));
|
|
49
|
+
assert.match(output, /KEY 17 true/);
|
|
50
|
+
assert.match(output, /MOUSE 0 0/);
|
|
51
|
+
assert.match(output, /MOUSE -1 120/);
|
|
52
|
+
await writeFile(join(root, 'finish'), 'done');
|
|
53
|
+
assert.equal((await done)[0], 0, error);
|
|
54
|
+
console.log('PASS: one packaged Java 8 agent instruments actual LWJGL2 keyboard and mouse classes');
|
|
55
|
+
} finally {
|
|
56
|
+
if (game && game.exitCode === null) game.kill();
|
|
57
|
+
await hub.close();
|
|
58
|
+
await rm(root, { recursive: true, force: true });
|
|
59
|
+
}
|