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.
Files changed (72) hide show
  1. package/README.md +27 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +52 -11
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +178 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +147 -100
  13. package/dist/controlplane/ui.js +1783 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +334 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +93 -0
  31. package/dist/ibmi/tn5250/ebcdicTables.js +27 -0
  32. package/dist/ibmi/tn5250/index.js +6 -0
  33. package/dist/ibmi/tn5250/screen.js +1033 -0
  34. package/dist/ibmi/tn5250/telnet.js +186 -0
  35. package/dist/ibmi/variables.js +18 -7
  36. package/dist/index.js +79 -24
  37. package/dist/mcp/annotations.js +155 -0
  38. package/dist/mcp/audit.js +490 -133
  39. package/dist/mcp/confirm.js +48 -0
  40. package/dist/mcp/context.js +43 -5
  41. package/dist/mcp/deployMethods.js +192 -0
  42. package/dist/mcp/logger.js +5 -10
  43. package/dist/mcp/modules/bulkMembers.js +476 -0
  44. package/dist/mcp/modules/changedLines.js +189 -0
  45. package/dist/mcp/modules/compare.js +198 -0
  46. package/dist/mcp/modules/compile.js +515 -0
  47. package/dist/mcp/modules/ifsOps.js +123 -0
  48. package/dist/mcp/modules/index.js +38 -0
  49. package/dist/mcp/modules/inspect.js +811 -0
  50. package/dist/mcp/modules/libl.js +246 -0
  51. package/dist/mcp/modules/members.js +171 -0
  52. package/dist/mcp/modules/objects.js +339 -0
  53. package/dist/mcp/modules/system.js +266 -0
  54. package/dist/mcp/modules/tn5250.js +800 -0
  55. package/dist/mcp/modules/tn5250App.js +194 -0
  56. package/dist/mcp/modules/tn5250Font.js +8 -0
  57. package/dist/mcp/modules/tn5250Live.js +221 -0
  58. package/dist/mcp/modules/tn5250LivePage.js +480 -0
  59. package/dist/mcp/policy.js +412 -0
  60. package/dist/mcp/tools.js +1719 -698
  61. package/dist/security/credentialStore.js +62 -13
  62. package/dist/security/localPaths.js +138 -0
  63. package/dist/security/redact.js +79 -0
  64. package/dist/util/atomicWrite.js +101 -0
  65. package/dist/util/fileLock.js +206 -0
  66. package/docs/capabilities.md +264 -220
  67. package/docs/configuration.md +193 -60
  68. package/docs/examples.md +109 -73
  69. package/docs/overview.md +15 -15
  70. package/docs/security.md +101 -16
  71. package/docs/tools.md +367 -188
  72. 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 = sanitizeRepoUrl(options?.repoUrl) || process.env.MCP_FOR_I_SKILLS_REPO || DEFAULT_SKILLS_REPO;
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
- try {
171
- await fn(push);
172
- job.status = "success";
173
- }
174
- catch (err) {
175
- job.status = "failed";
176
- job.error = err?.message || String(err);
177
- push(`ERROR: ${job.error}`);
178
- }
179
- finally {
180
- job.finishedAt = new Date().toISOString();
181
- }
182
- return job;
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
- function sanitizeRepoUrl(value) {
457
- if (!value)
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
- return trimmed;
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
- function sanitizeBranch(value) {
465
- if (!value)
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);