mcp-for-i 0.1.13 → 0.2.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 (67) hide show
  1. package/README.md +17 -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 +14 -9
  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 +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -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 +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Small lexical SQL analyser for the text sent to QZDFMDB2.
3
+ *
4
+ * It does not parse SQL grammar. It splits the text into segments (code,
5
+ * string literals, delimited identifiers, comments and `@` CL lines) so that
6
+ * callers can split statements, find embedded CL commands and decide whether
7
+ * a request is read-only without being fooled by quotes or comments.
8
+ */
9
+ const READ_ONLY_FIRST_KEYWORDS = new Set(["SELECT", "WITH", "VALUES"]);
10
+ const DATA_CHANGE_TABLE_KEYWORDS = new Set(["FINAL", "NEW", "OLD"]);
11
+ const DATA_CHANGE_STATEMENTS = new Set(["INSERT", "UPDATE", "DELETE", "MERGE"]);
12
+ /**
13
+ * Splits SQL text into lexical segments. A line whose first character is `@`
14
+ * while the lexer is not inside a literal or block comment is a CL line; this
15
+ * matches what Tools.fixSQL converts into a QCMDEXC call.
16
+ */
17
+ export function lexSqlSegments(sql) {
18
+ const segments = [];
19
+ const n = sql.length;
20
+ let i = 0;
21
+ let codeStart = 0;
22
+ const flushCode = (end) => {
23
+ if (end > codeStart) {
24
+ segments.push({ kind: "code", start: codeStart, end, text: sql.slice(codeStart, end) });
25
+ }
26
+ };
27
+ while (i < n) {
28
+ const ch = sql[i];
29
+ const atLineStart = i === 0 || sql[i - 1] === "\n";
30
+ if (atLineStart && ch === "@") {
31
+ flushCode(i);
32
+ let end = sql.indexOf("\n", i);
33
+ if (end === -1)
34
+ end = n;
35
+ const text = sql.slice(i, end);
36
+ let command = text.slice(1).replace(/\r$/, "");
37
+ if (command.endsWith(";"))
38
+ command = command.slice(0, -1);
39
+ segments.push({ kind: "cl", start: i, end, text, value: command });
40
+ i = end;
41
+ codeStart = i;
42
+ continue;
43
+ }
44
+ if (ch === "'" || ch === "\"") {
45
+ flushCode(i);
46
+ const quote = ch;
47
+ let j = i + 1;
48
+ let value = "";
49
+ let terminated = false;
50
+ while (j < n) {
51
+ if (sql[j] === quote) {
52
+ if (sql[j + 1] === quote) {
53
+ value += quote;
54
+ j += 2;
55
+ continue;
56
+ }
57
+ terminated = true;
58
+ j++;
59
+ break;
60
+ }
61
+ value += sql[j];
62
+ j++;
63
+ }
64
+ segments.push({
65
+ kind: quote === "'" ? "string" : "qident",
66
+ start: i,
67
+ end: j,
68
+ text: sql.slice(i, j),
69
+ value,
70
+ ...(terminated ? {} : { unterminated: true })
71
+ });
72
+ i = j;
73
+ codeStart = i;
74
+ continue;
75
+ }
76
+ if (ch === "-" && sql[i + 1] === "-") {
77
+ flushCode(i);
78
+ let end = sql.indexOf("\n", i);
79
+ if (end === -1)
80
+ end = n;
81
+ segments.push({ kind: "line", start: i, end, text: sql.slice(i, end) });
82
+ i = end;
83
+ codeStart = i;
84
+ continue;
85
+ }
86
+ if (ch === "/" && sql[i + 1] === "*") {
87
+ flushCode(i);
88
+ // Block comments are treated as non-nested: the comment ends at the first */.
89
+ // If Db2 nests them it sees more text as comment, never less.
90
+ const close = sql.indexOf("*/", i + 2);
91
+ const end = close === -1 ? n : close + 2;
92
+ segments.push({
93
+ kind: "block",
94
+ start: i,
95
+ end,
96
+ text: sql.slice(i, end),
97
+ ...(close === -1 ? { unterminated: true } : {})
98
+ });
99
+ i = end;
100
+ codeStart = i;
101
+ continue;
102
+ }
103
+ i++;
104
+ }
105
+ flushCode(n);
106
+ return segments;
107
+ }
108
+ /** Converts segments into significant tokens (comments dropped). */
109
+ export function tokenizeSql(sql) {
110
+ return segmentsToTokens(lexSqlSegments(sql));
111
+ }
112
+ function segmentsToTokens(segments) {
113
+ const tokens = [];
114
+ for (const seg of segments) {
115
+ switch (seg.kind) {
116
+ case "string":
117
+ case "qident":
118
+ tokens.push({ type: seg.kind, value: seg.value ?? "", start: seg.start, end: seg.end });
119
+ break;
120
+ case "cl":
121
+ tokens.push({ type: "cl", value: seg.value ?? "", start: seg.start, end: seg.end });
122
+ break;
123
+ case "code": {
124
+ const re = /([A-Za-z_#$@][A-Za-z0-9_#$@]*)|(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)|(\S)/g;
125
+ let m;
126
+ while ((m = re.exec(seg.text))) {
127
+ const start = seg.start + m.index;
128
+ const end = start + m[0].length;
129
+ if (m[1])
130
+ tokens.push({ type: "word", value: m[1].toUpperCase(), start, end });
131
+ else if (m[2])
132
+ tokens.push({ type: "number", value: m[2], start, end });
133
+ else
134
+ tokens.push({ type: "punct", value: m[3], start, end });
135
+ }
136
+ break;
137
+ }
138
+ default:
139
+ break;
140
+ }
141
+ }
142
+ return tokens;
143
+ }
144
+ /**
145
+ * Splits SQL into statements on `;`, ignoring semicolons inside literals,
146
+ * delimited identifiers and comments. Each `@` CL line is its own statement.
147
+ * Statements with no significant tokens are dropped.
148
+ */
149
+ export function splitSqlStatements(sql) {
150
+ const tokens = tokenizeSql(sql);
151
+ const statements = [];
152
+ let current = [];
153
+ const flush = () => {
154
+ if (current.length > 0) {
155
+ const start = current[0].start;
156
+ const end = current[current.length - 1].end;
157
+ statements.push({ text: sql.slice(start, end), start, end, tokens: current });
158
+ }
159
+ current = [];
160
+ };
161
+ for (const token of tokens) {
162
+ if (token.type === "cl") {
163
+ flush();
164
+ current.push(token);
165
+ flush();
166
+ continue;
167
+ }
168
+ if (token.type === "punct" && token.value === ";") {
169
+ flush();
170
+ continue;
171
+ }
172
+ current.push(token);
173
+ }
174
+ flush();
175
+ return statements;
176
+ }
177
+ /**
178
+ * The statement type of the first statement: its first keyword, upper-cased
179
+ * ("SELECT", "INSERT", ...), "CL" for an `@` line, or "UNKNOWN".
180
+ */
181
+ export function getSqlStatementType(sql) {
182
+ const first = splitSqlStatements(sql)[0];
183
+ if (!first)
184
+ return "UNKNOWN";
185
+ const token = first.tokens.find(t => !(t.type === "punct" && t.value === "("));
186
+ if (!token)
187
+ return "UNKNOWN";
188
+ if (token.type === "cl")
189
+ return "CL";
190
+ if (token.type === "word")
191
+ return token.value;
192
+ return "UNKNOWN";
193
+ }
194
+ /**
195
+ * Lists the CL commands embedded in SQL text: `@` lines (which Tools.fixSQL
196
+ * turns into QCMDEXC calls) and calls to QCMDEXC (procedure or scalar
197
+ * function, qualified or not). For QCMDEXC the first argument is returned when
198
+ * it is a single string literal; otherwise `command` is null because the
199
+ * command is built at run time and cannot be checked statically.
200
+ */
201
+ export function extractEmbeddedClCommands(sql) {
202
+ const tokens = tokenizeSql(sql);
203
+ const result = [];
204
+ for (let i = 0; i < tokens.length; i++) {
205
+ const token = tokens[i];
206
+ if (token.type === "cl") {
207
+ result.push({ source: "at-line", command: token.value, offset: token.start });
208
+ continue;
209
+ }
210
+ if (!isQcmdexcName(token))
211
+ continue;
212
+ const open = tokens[i + 1];
213
+ const arg = tokens[i + 2];
214
+ const after = tokens[i + 3];
215
+ let command = null;
216
+ if (open?.type === "punct" && open.value === "(" &&
217
+ arg?.type === "string" &&
218
+ after?.type === "punct" && (after.value === "," || after.value === ")")) {
219
+ command = arg.value;
220
+ }
221
+ result.push({ source: "qcmdexc", command, offset: token.start });
222
+ }
223
+ return result;
224
+ }
225
+ function isQcmdexcName(token) {
226
+ return (token.type === "word" || token.type === "qident") && token.value.toUpperCase() === "QCMDEXC";
227
+ }
228
+ /**
229
+ * Decides whether SQL may run through the read-only `ibmi.sql.query` tool.
230
+ *
231
+ * Allowed: exactly one SELECT, WITH or VALUES statement (optionally wrapped
232
+ * in parentheses, optionally ending with one `;`). Rejected:
233
+ * - more than one statement;
234
+ * - any line starting with `@` (a CL command);
235
+ * - any reference to QCMDEXC;
236
+ * - data-change table references such as FINAL TABLE (INSERT ...);
237
+ * - unterminated literals or comments;
238
+ * - a `;` inside a literal or comment, because QZDFMDB2 (-t) splits
239
+ * statements itself and is not known to respect quotes.
240
+ */
241
+ export function checkReadOnlySql(sql) {
242
+ if (/^\s*@/m.test(sql)) {
243
+ return { readOnly: false, reason: "lines starting with @ run CL commands" };
244
+ }
245
+ const segments = lexSqlSegments(sql);
246
+ for (const seg of segments) {
247
+ if (seg.kind === "cl") {
248
+ return { readOnly: false, reason: "lines starting with @ run CL commands" };
249
+ }
250
+ if (seg.unterminated) {
251
+ return { readOnly: false, reason: "unterminated string, identifier or comment" };
252
+ }
253
+ if (seg.kind !== "code" && seg.text.includes(";")) {
254
+ return { readOnly: false, reason: "a semicolon inside a string, identifier or comment is not allowed in read-only SQL" };
255
+ }
256
+ }
257
+ const tokens = segmentsToTokens(segments);
258
+ const significant = tokens.filter(t => !(t.type === "punct" && t.value === ";"));
259
+ if (significant.length === 0) {
260
+ return { readOnly: false, reason: "no SQL statement" };
261
+ }
262
+ // Only one statement: every ';' must come after the last significant token.
263
+ const lastSignificant = significant[significant.length - 1];
264
+ const semicolons = tokens.filter(t => t.type === "punct" && t.value === ";");
265
+ if (semicolons.some(t => t.start < lastSignificant.start)) {
266
+ return { readOnly: false, reason: "multiple statements are not allowed" };
267
+ }
268
+ if (semicolons.length > 1) {
269
+ return { readOnly: false, reason: "multiple statements are not allowed" };
270
+ }
271
+ const first = significant.find(t => !(t.type === "punct" && t.value === "("));
272
+ if (!first || first.type !== "word" || !READ_ONLY_FIRST_KEYWORDS.has(first.value)) {
273
+ return { readOnly: false, reason: "only SELECT, WITH and VALUES statements are read-only" };
274
+ }
275
+ for (let i = 0; i < significant.length; i++) {
276
+ const t = significant[i];
277
+ if (isQcmdexcName(t)) {
278
+ return { readOnly: false, reason: "QCMDEXC runs CL commands" };
279
+ }
280
+ if (t.type === "word" && DATA_CHANGE_TABLE_KEYWORDS.has(t.value)) {
281
+ const next = significant[i + 1];
282
+ const open = significant[i + 2];
283
+ const stmt = significant[i + 3];
284
+ if (next?.type === "word" && next.value === "TABLE" &&
285
+ open?.type === "punct" && open.value === "(" &&
286
+ stmt?.type === "word" && DATA_CHANGE_STATEMENTS.has(stmt.value)) {
287
+ return { readOnly: false, reason: `${t.value} TABLE (${stmt.value} ...) changes data` };
288
+ }
289
+ }
290
+ }
291
+ return { readOnly: true };
292
+ }
293
+ export function isReadOnlySql(sql) {
294
+ return checkReadOnlySql(sql).readOnly;
295
+ }
296
+ /**
297
+ * Rewrites SQL for QZDFMDB2: `@` lines become QCMDEXC calls and, when
298
+ * removeComments is set, `--` comments are removed. Literals, delimited
299
+ * identifiers and block comments are left untouched, so `--` inside a string
300
+ * is kept.
301
+ */
302
+ export function rewriteSqlForQzdfmdb2(sql, removeComments = false) {
303
+ let out = "";
304
+ for (const seg of lexSqlSegments(sql)) {
305
+ switch (seg.kind) {
306
+ case "cl":
307
+ out += `Call QSYS2.QCMDEXC('${(seg.value ?? "").replaceAll("'", "''")}');`;
308
+ break;
309
+ case "line":
310
+ out += removeComments ? "" : `\n${seg.text}`;
311
+ break;
312
+ default:
313
+ out += seg.text;
314
+ }
315
+ }
316
+ return out;
317
+ }
@@ -0,0 +1,59 @@
1
+ import path from "path";
2
+ import { getConfigDir } from "../../config/store.js";
3
+ import { hostKeyFingerprint, isNewHostKeyAccepted, knownHostId, readKnownHosts, writeKnownHosts } from "../knownHosts.js";
4
+ /**
5
+ * Trust for TN5250 TLS server certificates.
6
+ *
7
+ * A certificate that chains to a trusted CA and matches the host name is
8
+ * accepted as is. Any other certificate (typically self-signed on IBM i) is
9
+ * trusted on first use: its SHA-256 fingerprint is pinned per host:port in
10
+ * known_tls_certs.json, and a different certificate later is refused unless
11
+ * explicitly accepted.
12
+ */
13
+ export const ACCEPT_NEW_TLS_CERT_ENV = "MCP_FOR_I_ACCEPT_NEW_TLSCERT";
14
+ export class TlsCertificateMismatchError extends Error {
15
+ hostKey;
16
+ expected;
17
+ presented;
18
+ filePath;
19
+ constructor(hostKey, expected, presented, filePath) {
20
+ super(`TLS certificate for ${hostKey} has changed. Stored ${expected}, server presented ${presented}. ` +
21
+ `This can mean a man-in-the-middle attack, so the connection was refused. ` +
22
+ `If the change is expected (for example the certificate was renewed), ` +
23
+ `set ${ACCEPT_NEW_TLS_CERT_ENV}=${hostKey} (or =${presented}) and connect again, ` +
24
+ `or remove the "${hostKey}" entry from ${filePath}.`);
25
+ this.hostKey = hostKey;
26
+ this.expected = expected;
27
+ this.presented = presented;
28
+ this.filePath = filePath;
29
+ this.name = "TlsCertificateMismatchError";
30
+ }
31
+ }
32
+ export function getKnownTlsCertsPath() {
33
+ return path.join(getConfigDir(), "known_tls_certs.json");
34
+ }
35
+ let queue = Promise.resolve();
36
+ export function checkTlsCertificate(options) {
37
+ const fingerprint = hostKeyFingerprint(options.certificate);
38
+ if (options.authorized)
39
+ return Promise.resolve({ status: "ca-verified", fingerprint });
40
+ const run = async () => {
41
+ const filePath = options.filePath || getKnownTlsCertsPath();
42
+ const acceptNew = options.acceptNew ?? process.env[ACCEPT_NEW_TLS_CERT_ENV];
43
+ const id = knownHostId(options.host, options.port);
44
+ const data = await readKnownHosts(filePath);
45
+ const existing = data.hosts[id];
46
+ const now = new Date().toISOString();
47
+ if (existing && existing.fingerprint === fingerprint)
48
+ return { status: "trusted", fingerprint };
49
+ if (existing && !isNewHostKeyAccepted(acceptNew, options.host, options.port, fingerprint)) {
50
+ throw new TlsCertificateMismatchError(id, existing.fingerprint, fingerprint, filePath);
51
+ }
52
+ data.hosts[id] = { fingerprint, keyType: "x509", addedAt: existing ? existing.addedAt : now, updatedAt: now };
53
+ await writeKnownHosts(filePath, data);
54
+ return { status: existing ? "replaced" : "added", fingerprint };
55
+ };
56
+ const result = queue.then(run, run);
57
+ queue = result.catch(() => undefined);
58
+ return result;
59
+ }
@@ -0,0 +1,310 @@
1
+ import { EventEmitter } from "events";
2
+ import net from "net";
3
+ import tls from "tls";
4
+ import { AID, FLAG_ATN, FLAG_SRQ, OP_NO_OP, buildGdsRecord, parseGdsRecord } from "./datastream.js";
5
+ import { Screen } from "./screen.js";
6
+ import { DO, DONT, ENV_SEND, OPT_BINARY, OPT_EOR, OPT_NEW_ENVIRON, OPT_TERMINAL_TYPE, TT_SEND, TelnetParser, WILL, WONT, envIs, frameRecord, parseEnvSend, telnetCommand, terminalTypeIs } from "./telnet.js";
7
+ const AID_ALIASES = {
8
+ ENTER: AID.ENTER,
9
+ RETURN: AID.ENTER,
10
+ HELP: AID.HELP,
11
+ PAGEUP: AID.ROLLDOWN,
12
+ ROLLDOWN: AID.ROLLDOWN,
13
+ PGUP: AID.ROLLDOWN,
14
+ PAGEDOWN: AID.ROLLUP,
15
+ ROLLUP: AID.ROLLUP,
16
+ PGDN: AID.ROLLUP,
17
+ PRINT: AID.PRINT,
18
+ CLEAR: AID.CLEAR,
19
+ RECORDBACKSPACE: AID.RECORD_BACKSPACE
20
+ };
21
+ /** AID byte for a key name, or undefined when it is not an AID key. */
22
+ export function aidForKey(name) {
23
+ const key = name.toUpperCase().replace(/[\s_-]/g, "");
24
+ if (key in AID_ALIASES)
25
+ return AID_ALIASES[key];
26
+ const f = /^F([1-9]|1[0-9]|2[0-4])$/.exec(key);
27
+ if (f)
28
+ return AID[`F${f[1]}`];
29
+ return undefined;
30
+ }
31
+ const SUPPORTED_DO = new Set([OPT_BINARY, OPT_TERMINAL_TYPE, OPT_EOR, OPT_NEW_ENVIRON]);
32
+ const SUPPORTED_WILL = new Set([OPT_BINARY, OPT_EOR]);
33
+ export class Tn5250Client extends EventEmitter {
34
+ options;
35
+ screen;
36
+ closed = false;
37
+ closeReason;
38
+ secure = false;
39
+ certificateStatus;
40
+ autoSignOnOffered = false;
41
+ negotiated = { binary: false, eor: false, terminalType: false, newEnviron: false };
42
+ socket;
43
+ parser = new TelnetParser();
44
+ willSent = new Set();
45
+ doSent = new Set();
46
+ lastUpdateAt = 0;
47
+ constructor(options) {
48
+ super();
49
+ this.options = options;
50
+ this.setMaxListeners(50);
51
+ const terminalType = options.terminalType || "IBM-3179-2";
52
+ this.screen = new Screen({ ccsid: options.ccsid, deviceType: terminalType === "IBM-3477-FC" ? "3477" : "3179" });
53
+ }
54
+ get terminalType() {
55
+ return this.options.terminalType || "IBM-3179-2";
56
+ }
57
+ /** Open the connection. Resolves once TCP (and TLS, if used) is established and trusted. */
58
+ connect() {
59
+ const { host, port } = this.options;
60
+ const timeoutMs = this.options.connectTimeoutMs ?? 15000;
61
+ return new Promise((resolve, reject) => {
62
+ let settled = false;
63
+ const fail = (err) => {
64
+ if (settled)
65
+ return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ this.markClosed(err.message);
69
+ socket.destroy();
70
+ reject(err);
71
+ };
72
+ const timer = setTimeout(() => fail(new Error(`TN5250 connection to ${host}:${port} timed out after ${timeoutMs} ms`)), timeoutMs);
73
+ const ready = () => {
74
+ if (settled)
75
+ return;
76
+ settled = true;
77
+ clearTimeout(timer);
78
+ resolve();
79
+ };
80
+ let socket;
81
+ if (this.options.tls) {
82
+ const tlsSocket = tls.connect({
83
+ host,
84
+ port,
85
+ servername: net.isIP(host) ? undefined : host,
86
+ // Verification is done below so self-signed IBM i certificates can be pinned.
87
+ rejectUnauthorized: false
88
+ });
89
+ socket = tlsSocket;
90
+ tlsSocket.once("secureConnect", async () => {
91
+ try {
92
+ const cert = tlsSocket.getPeerCertificate(false);
93
+ if (!cert || !cert.raw)
94
+ throw new Error("TLS server did not present a certificate");
95
+ this.certificateStatus = await (this.options.checkCertificate
96
+ ? this.options.checkCertificate({
97
+ host,
98
+ port,
99
+ certificate: cert.raw,
100
+ authorized: tlsSocket.authorized,
101
+ authorizationError: tlsSocket.authorizationError ? String(tlsSocket.authorizationError) : undefined
102
+ })
103
+ : Promise.reject(new Error("No TLS certificate check configured")));
104
+ this.secure = true;
105
+ this.attach(socket);
106
+ ready();
107
+ }
108
+ catch (err) {
109
+ fail(err instanceof Error ? err : new Error(String(err)));
110
+ }
111
+ });
112
+ }
113
+ else {
114
+ socket = net.connect({ host, port });
115
+ socket.once("connect", () => {
116
+ this.attach(socket);
117
+ ready();
118
+ });
119
+ }
120
+ socket.on("error", err => {
121
+ if (!settled)
122
+ fail(err);
123
+ else
124
+ this.markClosed(err.message);
125
+ });
126
+ socket.on("close", () => this.markClosed(this.closeReason || "connection closed by host"));
127
+ this.socket = socket;
128
+ });
129
+ }
130
+ attach(socket) {
131
+ socket.setNoDelay(true);
132
+ socket.on("data", (chunk) => this.onData(typeof chunk === "string" ? Buffer.from(chunk, "latin1") : chunk));
133
+ }
134
+ markClosed(reason) {
135
+ if (this.closed)
136
+ return;
137
+ this.closed = true;
138
+ this.closeReason = reason;
139
+ this.emit("update");
140
+ this.emit("close", reason);
141
+ }
142
+ close() {
143
+ this.markClosed("disconnected");
144
+ this.socket?.destroy();
145
+ }
146
+ write(bytes) {
147
+ if (this.closed || !this.socket)
148
+ throw new Error(`TN5250 session is closed (${this.closeReason || "not connected"})`);
149
+ this.socket.write(bytes);
150
+ }
151
+ /** Feed raw bytes (used by the socket handler and by tests). */
152
+ onData(chunk) {
153
+ let events;
154
+ try {
155
+ events = this.parser.push(chunk);
156
+ }
157
+ catch (err) {
158
+ this.screen.warnings.push(`telnet parse error: ${err?.message || err}`);
159
+ return;
160
+ }
161
+ for (const event of events) {
162
+ if (event.type === "command")
163
+ this.onCommand(event.command, event.option);
164
+ else if (event.type === "subnegotiation")
165
+ this.onSubnegotiation(event.option, event.data);
166
+ else
167
+ this.onRecord(event.data);
168
+ }
169
+ }
170
+ onCommand(command, option) {
171
+ if (command === DO) {
172
+ if (SUPPORTED_DO.has(option)) {
173
+ if (!this.willSent.has(option)) {
174
+ this.willSent.add(option);
175
+ this.write(telnetCommand(WILL, option));
176
+ }
177
+ if (option === OPT_BINARY)
178
+ this.negotiated.binary = true;
179
+ if (option === OPT_EOR)
180
+ this.negotiated.eor = true;
181
+ }
182
+ else {
183
+ this.write(telnetCommand(WONT, option));
184
+ }
185
+ }
186
+ else if (command === WILL) {
187
+ if (SUPPORTED_WILL.has(option)) {
188
+ if (!this.doSent.has(option)) {
189
+ this.doSent.add(option);
190
+ this.write(telnetCommand(DO, option));
191
+ }
192
+ }
193
+ else {
194
+ this.write(telnetCommand(DONT, option));
195
+ }
196
+ }
197
+ else if (command === DONT) {
198
+ if (this.willSent.delete(option))
199
+ this.write(telnetCommand(WONT, option));
200
+ }
201
+ }
202
+ onSubnegotiation(option, data) {
203
+ if (option === OPT_TERMINAL_TYPE && data[0] === TT_SEND) {
204
+ this.negotiated.terminalType = true;
205
+ this.write(terminalTypeIs(this.terminalType));
206
+ }
207
+ else if (option === OPT_NEW_ENVIRON && data[0] === ENV_SEND) {
208
+ this.negotiated.newEnviron = true;
209
+ parseEnvSend(data.subarray(1));
210
+ this.write(envIs(this.environment()));
211
+ }
212
+ }
213
+ /** NEW-ENVIRON values sent to the server. */
214
+ environment() {
215
+ const ascii = (text) => Buffer.from(text, "ascii");
216
+ const values = [];
217
+ if (this.options.deviceName)
218
+ values.push({ kind: "uservar", name: "DEVNAME", value: ascii(this.options.deviceName) });
219
+ values.push({ kind: "uservar", name: "KBDTYPE", value: ascii("USB") });
220
+ values.push({ kind: "uservar", name: "CODEPAGE", value: ascii("37") });
221
+ values.push({ kind: "uservar", name: "CHARSET", value: ascii("697") });
222
+ const signOn = this.options.autoSignOn;
223
+ if (signOn) {
224
+ // RFC 4777: an empty IBMRSEED means IBMSUBSPW carries the password unencrypted.
225
+ values.push({ kind: "var", name: "USER", value: ascii(signOn.user) });
226
+ values.push({ kind: "uservar", name: "IBMRSEED", value: Buffer.alloc(0) });
227
+ values.push({ kind: "uservar", name: "IBMSUBSPW", value: ascii(signOn.password) });
228
+ this.autoSignOnOffered = true;
229
+ }
230
+ return values;
231
+ }
232
+ onRecord(record) {
233
+ try {
234
+ const responses = this.screen.process(parseGdsRecord(record));
235
+ for (const response of responses)
236
+ this.write(frameRecord(response));
237
+ }
238
+ catch (err) {
239
+ this.screen.warnings.push(err?.message || String(err));
240
+ }
241
+ this.lastUpdateAt = Date.now();
242
+ this.emit("update");
243
+ }
244
+ /** Send an AID key (Enter, F1-F24, PageUp, ...) or Attn / SysReq. */
245
+ sendKey(name) {
246
+ const key = name.toUpperCase().replace(/[\s_-]/g, "");
247
+ if (key === "ATTN") {
248
+ this.write(frameRecord(buildGdsRecord(OP_NO_OP, new Uint8Array(0), FLAG_ATN)));
249
+ this.screen.keyboardLocked = true;
250
+ return;
251
+ }
252
+ if (key === "SYSREQ") {
253
+ this.write(frameRecord(buildGdsRecord(OP_NO_OP, new Uint8Array(0), FLAG_SRQ)));
254
+ this.screen.keyboardLocked = true;
255
+ return;
256
+ }
257
+ const aid = aidForKey(key);
258
+ if (aid === undefined)
259
+ throw new Error(`Unknown AID key: ${name}`);
260
+ this.lastUpdateAt = Date.now();
261
+ this.write(frameRecord(this.screen.buildAidResponse(aid)));
262
+ }
263
+ /** True once the host has handed control back to the operator. */
264
+ isReady() {
265
+ return !this.screen.keyboardLocked || this.screen.inputInhibited;
266
+ }
267
+ /**
268
+ * Wait until the predicate holds. Re-checks on every screen update.
269
+ * Resolves false on timeout or when the connection closes first.
270
+ */
271
+ waitFor(predicate, timeoutMs) {
272
+ if (predicate())
273
+ return Promise.resolve(true);
274
+ if (this.closed)
275
+ return Promise.resolve(false);
276
+ return new Promise(resolve => {
277
+ const done = (value) => {
278
+ clearTimeout(timer);
279
+ this.off("update", check);
280
+ resolve(value);
281
+ };
282
+ const check = () => {
283
+ if (predicate())
284
+ done(true);
285
+ else if (this.closed)
286
+ done(false);
287
+ };
288
+ const timer = setTimeout(() => done(predicate()), timeoutMs);
289
+ this.on("update", check);
290
+ });
291
+ }
292
+ /** Wait for the keyboard to unlock, then for a short quiet period with no more records. */
293
+ async waitForSettled(timeoutMs, quietMs = 150) {
294
+ const deadline = Date.now() + timeoutMs;
295
+ for (;;) {
296
+ const remaining = deadline - Date.now();
297
+ if (remaining <= 0)
298
+ return this.isReady();
299
+ const ready = await this.waitFor(() => this.isReady(), remaining);
300
+ if (!ready)
301
+ return false;
302
+ const sinceUpdate = Date.now() - this.lastUpdateAt;
303
+ if (sinceUpdate >= quietMs)
304
+ return true;
305
+ await new Promise(resolve => setTimeout(resolve, Math.min(quietMs - sinceUpdate, Math.max(0, deadline - Date.now()))));
306
+ if (Date.now() - this.lastUpdateAt >= quietMs && this.isReady())
307
+ return true;
308
+ }
309
+ }
310
+ }