mcp-for-i 0.1.13 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/config/store.js +229 -109
- package/dist/controlplane/auth.js +128 -0
- package/dist/controlplane/clientIntegrationService.js +83 -4
- package/dist/controlplane/commands.js +52 -11
- package/dist/controlplane/connectionService.js +110 -5
- package/dist/controlplane/connectionTest.js +329 -0
- package/dist/controlplane/errors.js +12 -0
- package/dist/controlplane/runtimeService.js +98 -26
- package/dist/controlplane/server.js +178 -11
- package/dist/controlplane/serverShared.js +25 -6
- package/dist/controlplane/serviceManager.js +147 -100
- package/dist/controlplane/ui.js +1783 -920
- package/dist/ibmi/CompileTools.js +29 -1
- package/dist/ibmi/IBMiContent.js +435 -63
- package/dist/ibmi/IbmDocs.js +685 -0
- package/dist/ibmi/LocalLanguageActions.js +129 -3
- package/dist/ibmi/Search.js +158 -52
- package/dist/ibmi/Tools.js +7 -10
- package/dist/ibmi/client.js +220 -31
- package/dist/ibmi/evfevent.js +256 -0
- package/dist/ibmi/knownHosts.js +190 -0
- package/dist/ibmi/libraryList.js +93 -0
- package/dist/ibmi/sourceInspect.js +815 -0
- package/dist/ibmi/sqlJob.js +888 -0
- package/dist/ibmi/sqlParse.js +317 -0
- package/dist/ibmi/tn5250/certTrust.js +59 -0
- package/dist/ibmi/tn5250/client.js +334 -0
- package/dist/ibmi/tn5250/datastream.js +109 -0
- package/dist/ibmi/tn5250/ebcdic.js +93 -0
- package/dist/ibmi/tn5250/ebcdicTables.js +27 -0
- package/dist/ibmi/tn5250/index.js +6 -0
- package/dist/ibmi/tn5250/screen.js +1033 -0
- package/dist/ibmi/tn5250/telnet.js +186 -0
- package/dist/ibmi/variables.js +18 -7
- package/dist/index.js +79 -24
- package/dist/mcp/annotations.js +155 -0
- package/dist/mcp/audit.js +490 -133
- package/dist/mcp/confirm.js +48 -0
- package/dist/mcp/context.js +43 -5
- package/dist/mcp/deployMethods.js +192 -0
- package/dist/mcp/logger.js +5 -10
- package/dist/mcp/modules/bulkMembers.js +476 -0
- package/dist/mcp/modules/changedLines.js +189 -0
- package/dist/mcp/modules/compare.js +198 -0
- package/dist/mcp/modules/compile.js +515 -0
- package/dist/mcp/modules/ifsOps.js +123 -0
- package/dist/mcp/modules/index.js +38 -0
- package/dist/mcp/modules/inspect.js +811 -0
- package/dist/mcp/modules/libl.js +246 -0
- package/dist/mcp/modules/members.js +171 -0
- package/dist/mcp/modules/objects.js +339 -0
- package/dist/mcp/modules/system.js +266 -0
- package/dist/mcp/modules/tn5250.js +800 -0
- package/dist/mcp/modules/tn5250App.js +194 -0
- package/dist/mcp/modules/tn5250Font.js +8 -0
- package/dist/mcp/modules/tn5250Live.js +221 -0
- package/dist/mcp/modules/tn5250LivePage.js +480 -0
- package/dist/mcp/policy.js +412 -0
- package/dist/mcp/tools.js +1719 -698
- package/dist/security/credentialStore.js +62 -13
- package/dist/security/localPaths.js +138 -0
- package/dist/security/redact.js +79 -0
- package/dist/util/atomicWrite.js +101 -0
- package/dist/util/fileLock.js +206 -0
- package/docs/capabilities.md +264 -220
- package/docs/configuration.md +193 -60
- package/docs/examples.md +109 -73
- package/docs/overview.md +15 -15
- package/docs/security.md +101 -16
- package/docs/tools.md +367 -188
- package/package.json +5 -2
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import dns from "dns/promises";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import net from "net";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { Tools } from "../ibmi/Tools.js";
|
|
7
|
+
import { ACCEPT_NEW_HOSTKEY_ENV, getKnownHostsPath, hostKeyFingerprint, isNewHostKeyAccepted, knownHostId, readKnownHosts } from "../ibmi/knownHosts.js";
|
|
8
|
+
const DNS_TIMEOUT_MS = 5000;
|
|
9
|
+
const PORT_TIMEOUT_MS = 5000;
|
|
10
|
+
const SSH_TIMEOUT_MS = 20000;
|
|
11
|
+
const PROFILE_TIMEOUT_MS = 20000;
|
|
12
|
+
const REJECTED_TTL_MS = 15 * 60 * 1000;
|
|
13
|
+
const PASSWORD_WARNING_DAYS = 14;
|
|
14
|
+
/** Passwords the IBM i rejected recently, by hash of host, port, user and password. */
|
|
15
|
+
const rejected = new Map();
|
|
16
|
+
function rejectionKey(target, port) {
|
|
17
|
+
return crypto.createHash("sha256")
|
|
18
|
+
.update([target.host.trim().toLowerCase(), port, target.username.trim().toUpperCase(), target.password || ""].join("\0"))
|
|
19
|
+
.digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function wasRejected(key, now) {
|
|
22
|
+
const at = rejected.get(key);
|
|
23
|
+
if (at === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
if (now - at > REJECTED_TTL_MS) {
|
|
26
|
+
rejected.delete(key);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/** Forgets every remembered rejection (for tests). */
|
|
32
|
+
export function clearRejectedPasswords() {
|
|
33
|
+
rejected.clear();
|
|
34
|
+
}
|
|
35
|
+
export async function testConnection(target, deps = {}) {
|
|
36
|
+
const d = { ...defaultDeps, ...deps };
|
|
37
|
+
const started = d.now();
|
|
38
|
+
const steps = [];
|
|
39
|
+
const warnings = [];
|
|
40
|
+
let hostKey;
|
|
41
|
+
let profile;
|
|
42
|
+
const finish = (code, message) => ({
|
|
43
|
+
ok: code === "ok",
|
|
44
|
+
code,
|
|
45
|
+
message,
|
|
46
|
+
steps,
|
|
47
|
+
warnings,
|
|
48
|
+
...(hostKey ? { hostKey } : {}),
|
|
49
|
+
...(profile ? { profile } : {}),
|
|
50
|
+
testedAt: new Date(d.now()).toISOString(),
|
|
51
|
+
durationMs: Math.max(0, d.now() - started)
|
|
52
|
+
});
|
|
53
|
+
const host = String(target?.host ?? "").trim();
|
|
54
|
+
const username = String(target?.username ?? "").trim();
|
|
55
|
+
const port = Number(target?.port || 22);
|
|
56
|
+
if (!host || !username)
|
|
57
|
+
return finish("invalid-input", "Enter a host and a username to test.");
|
|
58
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
59
|
+
return finish("invalid-input", "The SSH port must be between 1 and 65535.");
|
|
60
|
+
// 1. Name lookup
|
|
61
|
+
let address;
|
|
62
|
+
try {
|
|
63
|
+
address = await withTimeout(d.lookup(host), DNS_TIMEOUT_MS, "lookup");
|
|
64
|
+
steps.push({ name: "Host lookup", ok: true, detail: address === host ? host : `${host} is ${address}` });
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
steps.push({ name: "Host lookup", ok: false, detail: err?.code || err?.message || String(err) });
|
|
68
|
+
return finish("host-not-found", `Host ${host} was not found. Check the spelling, or that this computer can resolve it (VPN or DNS).`);
|
|
69
|
+
}
|
|
70
|
+
// 2. SSH port
|
|
71
|
+
try {
|
|
72
|
+
const banner = await d.probePort(address, port, PORT_TIMEOUT_MS);
|
|
73
|
+
if (banner && !banner.startsWith("SSH-")) {
|
|
74
|
+
steps.push({ name: "SSH port", ok: false, detail: `Port ${port} answered, but not with SSH` });
|
|
75
|
+
return finish("not-ssh", `Port ${port} on ${host} is open but is not an SSH server. Check the port number.`);
|
|
76
|
+
}
|
|
77
|
+
steps.push({ name: "SSH port", ok: true, detail: banner ? banner.trim() : `Port ${port} accepted the connection` });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const code = err?.code;
|
|
81
|
+
steps.push({ name: "SSH port", ok: false, detail: code || err?.message || String(err) });
|
|
82
|
+
if (code === "ECONNREFUSED") {
|
|
83
|
+
return finish("port-closed", `${host} refused a connection on port ${port}. SSH may not be running there (STRTCPSVR SERVER(*SSHD)), or the port is wrong.`);
|
|
84
|
+
}
|
|
85
|
+
if (code === "ETIMEDOUT" || code === "TIMEOUT") {
|
|
86
|
+
return finish("port-timeout", `No answer from ${host} on port ${port}. The system may be down, or a firewall or VPN is blocking the port.`);
|
|
87
|
+
}
|
|
88
|
+
if (code === "EHOSTUNREACH" || code === "ENETUNREACH") {
|
|
89
|
+
return finish("host-unreachable", `${host} cannot be reached from this computer. Check the network or VPN.`);
|
|
90
|
+
}
|
|
91
|
+
return finish("error", `Could not open port ${port} on ${host}: ${err?.message || err}`);
|
|
92
|
+
}
|
|
93
|
+
// 3. Credentials available?
|
|
94
|
+
let privateKey;
|
|
95
|
+
if (target.privateKeyPath) {
|
|
96
|
+
try {
|
|
97
|
+
privateKey = await d.readKey(target.privateKeyPath);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
steps.push({ name: "Private key", ok: false, detail: err?.code || err?.message || String(err) });
|
|
101
|
+
return finish("key-unreadable", `The private key ${target.privateKeyPath} could not be read on this computer.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!target.password && !privateKey) {
|
|
105
|
+
return finish("no-credentials", "The host and SSH port work. Enter a password or a private key path to test the sign-on.");
|
|
106
|
+
}
|
|
107
|
+
const key = rejectionKey(target, port);
|
|
108
|
+
if (target.password && !privateKey && wasRejected(key, d.now())) {
|
|
109
|
+
return finish("password-already-rejected", `${host} already rejected this password for ${username.toUpperCase()}. It was not sent again, because every failed sign-on counts toward disabling the profile. Enter a different password.`);
|
|
110
|
+
}
|
|
111
|
+
// 4. Host key and sign-on
|
|
112
|
+
const outcome = await d.ssh({
|
|
113
|
+
host,
|
|
114
|
+
port,
|
|
115
|
+
username,
|
|
116
|
+
password: target.password,
|
|
117
|
+
privateKey,
|
|
118
|
+
timeoutMs: SSH_TIMEOUT_MS,
|
|
119
|
+
checkHostKey: async (raw) => {
|
|
120
|
+
const check = await d.checkHostKey(host, port, raw);
|
|
121
|
+
hostKey = check;
|
|
122
|
+
return check;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
if (hostKey && hostKey.status !== "changed") {
|
|
126
|
+
steps.push({
|
|
127
|
+
name: "Host key",
|
|
128
|
+
ok: true,
|
|
129
|
+
detail: hostKey.status === "trusted" ? "Matches the saved key" : `New key ${hostKey.fingerprint}, saved on the first connection`
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (outcome.kind === "host-key-changed") {
|
|
133
|
+
hostKey = outcome.hostKey;
|
|
134
|
+
steps.push({ name: "Host key", ok: false, detail: `Saved ${outcome.hostKey.stored}, server sent ${outcome.hostKey.fingerprint}` });
|
|
135
|
+
return finish("host-key-changed", `The SSH host key of ${host} has changed, so the password was not sent. If the system was rebuilt or its keys were regenerated, remove its entry from ${getKnownHostsPath()} or set ${ACCEPT_NEW_HOSTKEY_ENV}=${knownHostId(host, port)}.`);
|
|
136
|
+
}
|
|
137
|
+
if (outcome.kind === "password-expired") {
|
|
138
|
+
steps.push({ name: "Sign-on", ok: false, detail: outcome.prompt || "Password change required" });
|
|
139
|
+
return finish("password-expired", `The password for ${username.toUpperCase()} has expired. Change it on the IBM i (for example with a 5250 sign-on), then save the new one here.`);
|
|
140
|
+
}
|
|
141
|
+
if (outcome.kind === "error") {
|
|
142
|
+
const err = outcome.error;
|
|
143
|
+
const level = err?.level;
|
|
144
|
+
const text = String(err?.message || err);
|
|
145
|
+
steps.push({ name: "Sign-on", ok: false, detail: text });
|
|
146
|
+
if (level === "client-authentication" || /authentication methods failed/i.test(text)) {
|
|
147
|
+
if (target.password && !privateKey)
|
|
148
|
+
rejected.set(key, d.now());
|
|
149
|
+
return finish("auth-failed", privateKey && !target.password
|
|
150
|
+
? `${host} did not accept the private key for ${username.toUpperCase()}. Check that its public key is in the user's ~/.ssh/authorized_keys on the IBM i.`
|
|
151
|
+
: `${host} rejected the username or password for ${username.toUpperCase()}. A disabled profile or an expired password gets the same answer over SSH, so check those too if the password is right.`);
|
|
152
|
+
}
|
|
153
|
+
if (level === "client-timeout" || /timed out/i.test(text)) {
|
|
154
|
+
return finish("timeout", `${host} did not finish the SSH sign-on in time. The system may be busy; try again.`);
|
|
155
|
+
}
|
|
156
|
+
if (/privateKey|private key|Unsupported key format|Cannot parse/i.test(text)) {
|
|
157
|
+
return finish("key-unreadable", `The private key ${target.privateKeyPath} could not be used: ${text}`);
|
|
158
|
+
}
|
|
159
|
+
return finish("error", `The SSH sign-on failed: ${text}`);
|
|
160
|
+
}
|
|
161
|
+
rejected.delete(key);
|
|
162
|
+
steps.push({ name: "Sign-on", ok: true, detail: `Signed on as ${username.toUpperCase()} and signed off` });
|
|
163
|
+
const days = outcome.profile?.daysUntilPasswordExpires;
|
|
164
|
+
if (typeof days === "number") {
|
|
165
|
+
profile = { daysUntilPasswordExpires: days };
|
|
166
|
+
if (days >= 0 && days <= PASSWORD_WARNING_DAYS) {
|
|
167
|
+
warnings.push(days === 0 ? "The password expires today." : `The password expires in ${days} day${days === 1 ? "" : "s"}.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return finish("ok", `Connected to ${host} as ${username.toUpperCase()}.`);
|
|
171
|
+
}
|
|
172
|
+
function withTimeout(promise, ms, what) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const timer = setTimeout(() => reject(Object.assign(new Error(`${what} timed out`), { code: "TIMEOUT" })), ms);
|
|
175
|
+
promise.then(value => { clearTimeout(timer); resolve(value); }, err => { clearTimeout(timer); reject(err); });
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function lookupHost(host) {
|
|
179
|
+
if (net.isIP(host))
|
|
180
|
+
return host;
|
|
181
|
+
const result = await dns.lookup(host);
|
|
182
|
+
return result.address;
|
|
183
|
+
}
|
|
184
|
+
function probePort(address, port, timeoutMs) {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
const socket = net.connect({ host: address, port });
|
|
187
|
+
let connected = false;
|
|
188
|
+
let banner = "";
|
|
189
|
+
const done = (err) => {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
socket.destroy();
|
|
192
|
+
if (err)
|
|
193
|
+
reject(err);
|
|
194
|
+
else
|
|
195
|
+
resolve(banner);
|
|
196
|
+
};
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
// Connected but silent: still a working port, just no banner yet.
|
|
199
|
+
if (connected)
|
|
200
|
+
done();
|
|
201
|
+
else
|
|
202
|
+
done(Object.assign(new Error("Connection timed out"), { code: "ETIMEDOUT" }));
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
socket.once("connect", () => { connected = true; });
|
|
205
|
+
socket.on("data", chunk => {
|
|
206
|
+
banner += chunk.toString("latin1");
|
|
207
|
+
if (banner.includes("\n") || banner.length >= 255) {
|
|
208
|
+
banner = banner.split(/\r?\n/)[0];
|
|
209
|
+
done();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
socket.once("error", err => done(err));
|
|
213
|
+
socket.once("end", () => done());
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async function checkStoredHostKey(host, port, key) {
|
|
217
|
+
const fingerprint = hostKeyFingerprint(key);
|
|
218
|
+
const data = await readKnownHosts(getKnownHostsPath());
|
|
219
|
+
const existing = data.hosts[knownHostId(host, port)];
|
|
220
|
+
if (!existing)
|
|
221
|
+
return { status: "new", fingerprint };
|
|
222
|
+
if (existing.fingerprint === fingerprint)
|
|
223
|
+
return { status: "trusted", fingerprint };
|
|
224
|
+
if (isNewHostKeyAccepted(process.env[ACCEPT_NEW_HOSTKEY_ENV], host, port, fingerprint)) {
|
|
225
|
+
return { status: "new", fingerprint, stored: existing.fingerprint };
|
|
226
|
+
}
|
|
227
|
+
return { status: "changed", fingerprint, stored: existing.fingerprint };
|
|
228
|
+
}
|
|
229
|
+
/** Days until the password expires (null when it never does). */
|
|
230
|
+
const PROFILE_SQL = "SELECT DAYS_UNTIL_PASSWORD_EXPIRES AS DAYS FROM QSYS2.USER_INFO WHERE AUTHORIZATION_NAME = USER";
|
|
231
|
+
const PROFILE_COMMAND = "LC_ALL=EN_US.UTF-8 /QOpenSys/usr/bin/qsh -c " +
|
|
232
|
+
Tools.shellQuote(`system "call QSYS/QZDFMDB2 PARM('-d' '-i' '-t')"`);
|
|
233
|
+
function sshAttempt(options) {
|
|
234
|
+
const require = createRequire(import.meta.url);
|
|
235
|
+
const { Client } = require("ssh2");
|
|
236
|
+
return new Promise(resolve => {
|
|
237
|
+
const client = new Client();
|
|
238
|
+
let settled = false;
|
|
239
|
+
let hostKeyChanged;
|
|
240
|
+
const settle = (outcome) => {
|
|
241
|
+
if (settled)
|
|
242
|
+
return;
|
|
243
|
+
settled = true;
|
|
244
|
+
try {
|
|
245
|
+
client.end();
|
|
246
|
+
}
|
|
247
|
+
catch { /* already closed */ }
|
|
248
|
+
resolve(outcome);
|
|
249
|
+
};
|
|
250
|
+
client.on("change password", (prompt) => {
|
|
251
|
+
// Never change the password from a test; just report it.
|
|
252
|
+
settle({ kind: "password-expired", prompt });
|
|
253
|
+
});
|
|
254
|
+
client.on("error", (error) => {
|
|
255
|
+
if (hostKeyChanged)
|
|
256
|
+
settle({ kind: "host-key-changed", hostKey: hostKeyChanged });
|
|
257
|
+
else
|
|
258
|
+
settle({ kind: "error", error });
|
|
259
|
+
});
|
|
260
|
+
client.on("close", () => {
|
|
261
|
+
if (hostKeyChanged)
|
|
262
|
+
settle({ kind: "host-key-changed", hostKey: hostKeyChanged });
|
|
263
|
+
else
|
|
264
|
+
settle({ kind: "error", error: new Error("The server closed the connection") });
|
|
265
|
+
});
|
|
266
|
+
client.on("ready", async () => {
|
|
267
|
+
const profile = await readProfile(client).catch(() => undefined);
|
|
268
|
+
settle({ kind: "ready", profile });
|
|
269
|
+
});
|
|
270
|
+
client.connect({
|
|
271
|
+
host: options.host,
|
|
272
|
+
port: options.port,
|
|
273
|
+
username: options.username,
|
|
274
|
+
password: options.password,
|
|
275
|
+
privateKey: options.privateKey,
|
|
276
|
+
tryKeyboard: false,
|
|
277
|
+
readyTimeout: options.timeoutMs,
|
|
278
|
+
hostVerifier: (key, verify) => {
|
|
279
|
+
options.checkHostKey(key)
|
|
280
|
+
.then(check => {
|
|
281
|
+
if (check.status === "changed")
|
|
282
|
+
hostKeyChanged = check;
|
|
283
|
+
verify(check.status !== "changed");
|
|
284
|
+
})
|
|
285
|
+
.catch(() => verify(false));
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function readProfile(client) {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
const timer = setTimeout(() => reject(new Error("profile query timed out")), PROFILE_TIMEOUT_MS);
|
|
293
|
+
client.exec(PROFILE_COMMAND, (err, stream) => {
|
|
294
|
+
if (err) {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
reject(err);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
let stdout = "";
|
|
300
|
+
stream.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
|
|
301
|
+
stream.stderr.on("data", () => undefined);
|
|
302
|
+
stream.on("close", () => {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
try {
|
|
305
|
+
const rows = Tools.db2Parse(stdout, PROFILE_SQL);
|
|
306
|
+
const row = rows[0];
|
|
307
|
+
if (!row)
|
|
308
|
+
return resolve(undefined);
|
|
309
|
+
const days = row.DAYS === null || row.DAYS === undefined || row.DAYS === "" ? undefined : Number(row.DAYS);
|
|
310
|
+
resolve({
|
|
311
|
+
daysUntilPasswordExpires: Number.isFinite(days) ? days : undefined
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch (parseErr) {
|
|
315
|
+
reject(parseErr);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
stream.end(`${PROFILE_SQL}\n`);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const defaultDeps = {
|
|
323
|
+
lookup: lookupHost,
|
|
324
|
+
probePort,
|
|
325
|
+
readKey: path => fs.readFile(path, "utf8"),
|
|
326
|
+
checkHostKey: checkStoredHostKey,
|
|
327
|
+
ssh: sshAttempt,
|
|
328
|
+
now: () => Date.now()
|
|
329
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error carrying an HTTP status code. The control-plane server maps it to the
|
|
3
|
+
* response status; any other error becomes a 500.
|
|
4
|
+
*/
|
|
5
|
+
export class ControlPlaneError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "ControlPlaneError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -4,6 +4,7 @@ import os from "os";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { getPlatformDescriptor } from "./platform.js";
|
|
7
|
+
import { ControlPlaneError } from "./errors.js";
|
|
7
8
|
import { disableBackgroundService, enableBackgroundService, getServiceStatus } from "./serviceManager.js";
|
|
8
9
|
export class RuntimeService {
|
|
9
10
|
rootDir;
|
|
@@ -13,6 +14,7 @@ export class RuntimeService {
|
|
|
13
14
|
enableService: makeJob("enableService"),
|
|
14
15
|
disableService: makeJob("disableService")
|
|
15
16
|
};
|
|
17
|
+
pending = new Map();
|
|
16
18
|
constructor(rootDir) {
|
|
17
19
|
this.rootDir = rootDir;
|
|
18
20
|
}
|
|
@@ -20,6 +22,7 @@ export class RuntimeService {
|
|
|
20
22
|
return this.state;
|
|
21
23
|
}
|
|
22
24
|
async getVersions(options) {
|
|
25
|
+
const source = resolveSkillsSource(options);
|
|
23
26
|
const mode = await this.detectExecutionModeQuiet();
|
|
24
27
|
const checkedAt = new Date().toISOString();
|
|
25
28
|
const mcpInstalled = await this.readLocalPackageVersion(this.rootDir);
|
|
@@ -27,8 +30,7 @@ export class RuntimeService {
|
|
|
27
30
|
const mcpStatus = mcpInstalled && mcpLatest
|
|
28
31
|
? (mcpInstalled === mcpLatest ? "latest" : "update-available")
|
|
29
32
|
: "unknown";
|
|
30
|
-
const configuredRepo
|
|
31
|
-
const configuredBranch = sanitizeBranch(options?.branch) || process.env.MCP_FOR_I_SKILLS_BRANCH || "main";
|
|
33
|
+
const { repoUrl: configuredRepo, branch: configuredBranch } = source;
|
|
32
34
|
const skillsDir = path.join(this.rootDir, "skills");
|
|
33
35
|
const skillsGit = path.join(skillsDir, ".git");
|
|
34
36
|
const skills = {
|
|
@@ -89,14 +91,14 @@ export class RuntimeService {
|
|
|
89
91
|
});
|
|
90
92
|
}
|
|
91
93
|
async updateSkills(options) {
|
|
94
|
+
// Validate before starting the job so bad input is a 400, not a failed job.
|
|
95
|
+
const { repoUrl, branch } = resolveSkillsSource(options);
|
|
92
96
|
return this.runJob("updateSkills", async (push) => {
|
|
93
|
-
const repoUrl = sanitizeRepoUrl(options?.repoUrl) || process.env.MCP_FOR_I_SKILLS_REPO || DEFAULT_SKILLS_REPO;
|
|
94
|
-
const branch = sanitizeBranch(options?.branch) || process.env.MCP_FOR_I_SKILLS_BRANCH || "main";
|
|
95
97
|
const skillsDir = path.join(this.rootDir, "skills");
|
|
96
98
|
const hasSkills = await pathExists(skillsDir);
|
|
97
99
|
push(`Using skills repository: ${repoUrl} (${branch})`);
|
|
98
100
|
if (!hasSkills) {
|
|
99
|
-
await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, repoUrl, skillsDir], this.rootDir, push);
|
|
101
|
+
await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, "--", repoUrl, skillsDir], this.rootDir, push);
|
|
100
102
|
return;
|
|
101
103
|
}
|
|
102
104
|
const skillsGit = path.join(skillsDir, ".git");
|
|
@@ -104,7 +106,7 @@ export class RuntimeService {
|
|
|
104
106
|
const currentRemote = await this.getGitRemoteUrl(skillsDir, push);
|
|
105
107
|
if (currentRemote && currentRemote !== repoUrl) {
|
|
106
108
|
push(`Switching skills remote from ${currentRemote} to ${repoUrl}`);
|
|
107
|
-
await this.runCommand("git", ["remote", "set-url", "origin", repoUrl], skillsDir, push);
|
|
109
|
+
await this.runCommand("git", ["remote", "set-url", "--", "origin", repoUrl], skillsDir, push);
|
|
108
110
|
}
|
|
109
111
|
await this.runCommand("git", ["fetch", "origin", branch], skillsDir, push);
|
|
110
112
|
await this.runCommand("git", ["checkout", branch], skillsDir, push);
|
|
@@ -115,7 +117,7 @@ export class RuntimeService {
|
|
|
115
117
|
push(`Existing skills directory is not a git repository. Backing it up to ${backupDir}`);
|
|
116
118
|
await fs.rename(skillsDir, backupDir);
|
|
117
119
|
try {
|
|
118
|
-
await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, repoUrl, skillsDir], this.rootDir, push);
|
|
120
|
+
await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, "--", repoUrl, skillsDir], this.rootDir, push);
|
|
119
121
|
}
|
|
120
122
|
catch (error) {
|
|
121
123
|
push("Clone failed. Restoring previous skills directory.");
|
|
@@ -152,6 +154,15 @@ export class RuntimeService {
|
|
|
152
154
|
push(`Target: ${status.target}`);
|
|
153
155
|
});
|
|
154
156
|
}
|
|
157
|
+
/** Resolves when the given job has finished (immediately if it is not running). */
|
|
158
|
+
async waitForJob(kind) {
|
|
159
|
+
await this.pending.get(kind);
|
|
160
|
+
return this.state[kind];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Starts a job in the background and returns it right away with status
|
|
164
|
+
* "running". Progress is read through getStatus() (GET /api/runtime/status).
|
|
165
|
+
*/
|
|
155
166
|
async runJob(kind, fn) {
|
|
156
167
|
const current = this.state[kind];
|
|
157
168
|
if (current.status === "running") {
|
|
@@ -167,19 +178,23 @@ export class RuntimeService {
|
|
|
167
178
|
job.output.splice(0, job.output.length - 300);
|
|
168
179
|
}
|
|
169
180
|
};
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
const done = (async () => {
|
|
182
|
+
try {
|
|
183
|
+
await fn(push);
|
|
184
|
+
job.status = "success";
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
job.status = "failed";
|
|
188
|
+
job.error = err?.message || String(err);
|
|
189
|
+
push(`ERROR: ${job.error}`);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
job.finishedAt = new Date().toISOString();
|
|
193
|
+
this.pending.delete(kind);
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
this.pending.set(kind, done);
|
|
197
|
+
return { ...job, output: [...job.output] };
|
|
183
198
|
}
|
|
184
199
|
runCommand(command, args, cwd, push) {
|
|
185
200
|
return new Promise((resolve, reject) => {
|
|
@@ -379,7 +394,7 @@ export class RuntimeService {
|
|
|
379
394
|
}
|
|
380
395
|
async getRemoteBranchCommitQuiet(repoUrl, branch, short = false) {
|
|
381
396
|
try {
|
|
382
|
-
const out = await this.runCommandCaptureSilent("git", ["ls-remote", repoUrl, branch], this.rootDir);
|
|
397
|
+
const out = await this.runCommandCaptureSilent("git", ["ls-remote", "--", repoUrl, branch], this.rootDir);
|
|
383
398
|
const firstLine = out.split(/\r?\n/).find(Boolean);
|
|
384
399
|
if (!firstLine)
|
|
385
400
|
return undefined;
|
|
@@ -453,22 +468,79 @@ async function resolveNpmLaunch() {
|
|
|
453
468
|
}
|
|
454
469
|
return { mode: "command", path: process.platform === "win32" ? "npm.cmd" : "npm" };
|
|
455
470
|
}
|
|
456
|
-
|
|
457
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Picks the skills repository and branch (request, then environment, then
|
|
473
|
+
* defaults) and validates them. Throws a 400 error for unsafe values.
|
|
474
|
+
*/
|
|
475
|
+
export function resolveSkillsSource(options) {
|
|
476
|
+
const repoUrl = sanitizeRepoUrl(options?.repoUrl)
|
|
477
|
+
|| sanitizeRepoUrl(process.env.MCP_FOR_I_SKILLS_REPO)
|
|
478
|
+
|| DEFAULT_SKILLS_REPO;
|
|
479
|
+
const branch = sanitizeBranch(options?.branch)
|
|
480
|
+
|| sanitizeBranch(process.env.MCP_FOR_I_SKILLS_BRANCH)
|
|
481
|
+
|| "main";
|
|
482
|
+
return { repoUrl, branch };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Accepts only https://, ssh:// and scp-style (user@host:path) git URLs.
|
|
486
|
+
* Anything that git could read as an option (leading "-") or that uses another
|
|
487
|
+
* transport (ext::, file://, local paths) is rejected.
|
|
488
|
+
*/
|
|
489
|
+
export function sanitizeRepoUrl(value) {
|
|
490
|
+
if (typeof value !== "string")
|
|
458
491
|
return undefined;
|
|
459
492
|
const trimmed = value.trim();
|
|
460
493
|
if (!trimmed)
|
|
461
494
|
return undefined;
|
|
462
|
-
|
|
495
|
+
const invalid = () => new ControlPlaneError(400, "repoUrl must be an https://, ssh:// or user@host:path git URL");
|
|
496
|
+
if (trimmed.length > 2048 || trimmed.startsWith("-") || /[\s\x00-\x1f\x7f]/.test(trimmed)) {
|
|
497
|
+
throw invalid();
|
|
498
|
+
}
|
|
499
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = new URL(trimmed);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
throw invalid();
|
|
506
|
+
}
|
|
507
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:")
|
|
508
|
+
throw invalid();
|
|
509
|
+
if (!parsed.hostname || parsed.hostname.startsWith("-") || parsed.username.startsWith("-"))
|
|
510
|
+
throw invalid();
|
|
511
|
+
return trimmed;
|
|
512
|
+
}
|
|
513
|
+
// scp-like syntax: git@github.com:owner/repo.git
|
|
514
|
+
if (/^[A-Za-z0-9._-]+@[A-Za-z0-9][A-Za-z0-9.-]*:[A-Za-z0-9._~/-]+$/.test(trimmed) && !trimmed.includes("::")) {
|
|
515
|
+
return trimmed;
|
|
516
|
+
}
|
|
517
|
+
throw invalid();
|
|
463
518
|
}
|
|
464
|
-
|
|
465
|
-
|
|
519
|
+
/** Validates a branch name using the git check-ref-format rules. */
|
|
520
|
+
export function sanitizeBranch(value) {
|
|
521
|
+
if (typeof value !== "string")
|
|
466
522
|
return undefined;
|
|
467
523
|
const trimmed = value.trim();
|
|
468
524
|
if (!trimmed)
|
|
469
525
|
return undefined;
|
|
526
|
+
if (!isSafeBranchName(trimmed)) {
|
|
527
|
+
throw new ControlPlaneError(400, `Invalid branch name: ${JSON.stringify(trimmed.slice(0, 100))}`);
|
|
528
|
+
}
|
|
470
529
|
return trimmed;
|
|
471
530
|
}
|
|
531
|
+
function isSafeBranchName(name) {
|
|
532
|
+
if (name.length > 255)
|
|
533
|
+
return false;
|
|
534
|
+
if (name.startsWith("-") || name === "@" || name === "HEAD")
|
|
535
|
+
return false;
|
|
536
|
+
if (/[\x00-\x20\x7f~^:?*[\\]/.test(name))
|
|
537
|
+
return false;
|
|
538
|
+
if (name.includes("..") || name.includes("@{") || name.includes("//"))
|
|
539
|
+
return false;
|
|
540
|
+
if (name.startsWith("/") || name.endsWith("/") || name.endsWith("."))
|
|
541
|
+
return false;
|
|
542
|
+
return name.split("/").every(part => part.length > 0 && !part.startsWith(".") && !part.endsWith(".lock"));
|
|
543
|
+
}
|
|
472
544
|
async function safeRename(from, to) {
|
|
473
545
|
try {
|
|
474
546
|
await fs.rename(from, to);
|