@kin-tio/cli 0.6.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 (66) hide show
  1. package/.env.example +46 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +202 -0
  4. package/README.md +150 -0
  5. package/README.zh-CN.md +79 -0
  6. package/THIRD_PARTY_NOTICES +31 -0
  7. package/assets/ilink-login-card.png +0 -0
  8. package/bin/kintio.js +3 -0
  9. package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/daemon.js +28 -0
  12. package/dist/index.js +70 -0
  13. package/dist/mcp-relay.js +11 -0
  14. package/dist/src/agent/runtime.js +1 -0
  15. package/dist/src/app.js +34 -0
  16. package/dist/src/cli.js +578 -0
  17. package/dist/src/config.js +237 -0
  18. package/dist/src/domain/message.js +23 -0
  19. package/dist/src/domain/send-contract.js +205 -0
  20. package/dist/src/domain/wecom-message.js +281 -0
  21. package/dist/src/ilink/executor.js +306 -0
  22. package/dist/src/ilink/inbound-image.js +310 -0
  23. package/dist/src/ilink/listener.js +306 -0
  24. package/dist/src/ilink/login-manager.js +198 -0
  25. package/dist/src/ilink/login-store.js +197 -0
  26. package/dist/src/ilink/media-gateway.js +83 -0
  27. package/dist/src/ilink/media.js +267 -0
  28. package/dist/src/ilink/message.js +247 -0
  29. package/dist/src/ilink/protocol/client.js +464 -0
  30. package/dist/src/ilink/protocol/types.js +35 -0
  31. package/dist/src/ilink/qr.js +109 -0
  32. package/dist/src/ilink/secret-box.js +143 -0
  33. package/dist/src/ilink/sqlite-store.js +1194 -0
  34. package/dist/src/ilink/store-types.js +63 -0
  35. package/dist/src/lib/image-format.js +23 -0
  36. package/dist/src/lib/path-identity.js +38 -0
  37. package/dist/src/lib/private-directory.js +51 -0
  38. package/dist/src/lib/text.js +19 -0
  39. package/dist/src/lib/wecom-crypto.js +74 -0
  40. package/dist/src/lib/xml.js +8 -0
  41. package/dist/src/mcp/conversation-memory-server.js +179 -0
  42. package/dist/src/mcp/ilink-server.js +158 -0
  43. package/dist/src/mcp/ipc-host.js +275 -0
  44. package/dist/src/mcp/ipc-protocol.js +226 -0
  45. package/dist/src/mcp/stdio-relay.js +122 -0
  46. package/dist/src/mcp/wechat-kf-executor.js +295 -0
  47. package/dist/src/mcp/wechat-kf-server.js +208 -0
  48. package/dist/src/routes/wecom.js +89 -0
  49. package/dist/src/runtime/daemon-protocol.js +202 -0
  50. package/dist/src/runtime/managed-skill.js +49 -0
  51. package/dist/src/runtime/native-daemon.js +325 -0
  52. package/dist/src/runtime/single-instance-lock.js +167 -0
  53. package/dist/src/runtime.js +503 -0
  54. package/dist/src/services/codex-agent.js +542 -0
  55. package/dist/src/services/codex-app-server.js +436 -0
  56. package/dist/src/services/conversation-processor.js +762 -0
  57. package/dist/src/services/image-stager.js +49 -0
  58. package/dist/src/services/media-gateway.js +83 -0
  59. package/dist/src/services/wecom-api.js +311 -0
  60. package/dist/src/services/wecom-sync.js +316 -0
  61. package/dist/src/state/persistence.js +124 -0
  62. package/dist/src/state/sqlite-store.js +3102 -0
  63. package/dist/src/supervisor.js +212 -0
  64. package/dist/src/types.js +1 -0
  65. package/dist/src/version.js +1 -0
  66. package/package.json +72 -0
@@ -0,0 +1,275 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import { createServer } from 'node:net';
4
+ import path from 'node:path';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { MCP_FRAME_MAX_BYTES, MCP_HANDSHAKE_ERROR, MCP_HANDSHAKE_OK, createMcpGeneration, ensureMcpStateDirectory, mcpInstanceId, mcpIpcAddress, parseMcpHandshake, readSocketLine, writeMcpDescriptor, } from './ipc-protocol.js';
7
+ function writeLine(socket, value) {
8
+ return new Promise((resolve, reject) => {
9
+ socket.write(`${value}\n`, (error) => error ? reject(error) : resolve());
10
+ });
11
+ }
12
+ export class McpIpcHost {
13
+ #instanceKey;
14
+ #stateDirectory;
15
+ #relayFile;
16
+ #wechatKf;
17
+ #memory;
18
+ #ilink;
19
+ #logger;
20
+ #connections = new Set();
21
+ #server;
22
+ #descriptor;
23
+ #descriptorFile = '';
24
+ #starting;
25
+ #closing;
26
+ #force = false;
27
+ #closed = false;
28
+ constructor({ instanceKey, stateDirectory, relayFile, wechatKf, memory, ilink, logger = console, }) {
29
+ this.#instanceKey = path.resolve(instanceKey);
30
+ this.#stateDirectory = path.join(path.resolve(stateDirectory), '.kintio-mcp', mcpInstanceId(this.#instanceKey));
31
+ this.#relayFile = path.resolve(relayFile);
32
+ this.#wechatKf = wechatKf;
33
+ this.#memory = memory;
34
+ this.#ilink = ilink;
35
+ this.#logger = logger;
36
+ }
37
+ start() {
38
+ if (this.#closed)
39
+ return Promise.reject(new Error('MCP IPC host is closed'));
40
+ this.#starting ||= this.#start();
41
+ return this.#starting;
42
+ }
43
+ async #start() {
44
+ let relayStat;
45
+ try {
46
+ relayStat = fs.statSync(this.#relayFile);
47
+ }
48
+ catch {
49
+ throw new Error('MCP relay entry is unavailable');
50
+ }
51
+ if (!relayStat.isFile())
52
+ throw new Error('MCP relay entry is not a file');
53
+ ensureMcpStateDirectory(this.#stateDirectory);
54
+ this.#removeStaleState();
55
+ const generation = createMcpGeneration();
56
+ const descriptor = {
57
+ version: 1,
58
+ generation,
59
+ address: mcpIpcAddress(this.#instanceKey, generation),
60
+ token: randomBytes(32).toString('base64url'),
61
+ };
62
+ const server = createServer((socket) => this.#accept(socket));
63
+ server.maxConnections = 16;
64
+ this.#server = server;
65
+ this.#descriptor = descriptor;
66
+ try {
67
+ if (process.platform !== 'win32')
68
+ fs.rmSync(descriptor.address, { force: true });
69
+ await new Promise((resolve, reject) => {
70
+ const failed = (error) => reject(error);
71
+ server.once('error', failed);
72
+ server.listen(descriptor.address, () => {
73
+ server.off('error', failed);
74
+ resolve();
75
+ });
76
+ });
77
+ if (this.#closed)
78
+ throw new Error('MCP IPC host is closed');
79
+ if (process.platform !== 'win32')
80
+ fs.chmodSync(descriptor.address, 0o600);
81
+ if (this.#closed)
82
+ throw new Error('MCP IPC host is closed');
83
+ this.#descriptorFile = writeMcpDescriptor(this.#stateDirectory, descriptor);
84
+ if (this.#closed)
85
+ throw new Error('MCP IPC host is closed');
86
+ server.on('error', () => this.#logger.error('[mcp] IPC listener failed'));
87
+ return Object.freeze({
88
+ ...(this.#wechatKf ? { wechatKf: this.#launch('wechat_kf') } : {}),
89
+ memory: this.#launch('conversation_memory'),
90
+ ...(this.#ilink ? { ilink: this.#launch('weixin_ilink') } : {}),
91
+ });
92
+ }
93
+ catch (error) {
94
+ this.#cleanupFiles();
95
+ await this.#forceClose();
96
+ await this.#stopServer(server);
97
+ throw error;
98
+ }
99
+ }
100
+ #launch(route) {
101
+ return Object.freeze({
102
+ command: process.execPath,
103
+ args: Object.freeze([
104
+ this.#relayFile,
105
+ '--descriptor',
106
+ this.#descriptorFile,
107
+ '--route',
108
+ route,
109
+ ]),
110
+ });
111
+ }
112
+ #factory(route) {
113
+ switch (route) {
114
+ case 'wechat_kf': return this.#wechatKf;
115
+ case 'weixin_ilink': return this.#ilink;
116
+ case 'conversation_memory': return this.#memory;
117
+ }
118
+ }
119
+ #accept(socket) {
120
+ if (this.#closed) {
121
+ socket.destroy();
122
+ return;
123
+ }
124
+ socket.setNoDelay(true);
125
+ let mcp;
126
+ let closed;
127
+ let authenticatedRoute = false;
128
+ const connection = {
129
+ socket,
130
+ authenticated: false,
131
+ close: () => {
132
+ closed ||= Promise.resolve()
133
+ .then(() => mcp?.close())
134
+ .finally(() => socket.destroy());
135
+ return closed;
136
+ },
137
+ };
138
+ this.#connections.add(connection);
139
+ socket.on('error', () => socket.destroy());
140
+ socket.once('close', () => {
141
+ this.#connections.delete(connection);
142
+ void connection.close().catch(() => undefined);
143
+ });
144
+ void (async () => {
145
+ const descriptor = this.#descriptor;
146
+ if (this.#closed || !descriptor)
147
+ throw new Error('MCP IPC host unavailable');
148
+ const route = parseMcpHandshake(await readSocketLine(socket), descriptor);
149
+ if (this.#closed)
150
+ throw new Error('MCP IPC host unavailable');
151
+ const factory = route ? this.#factory(route) : undefined;
152
+ if (!factory) {
153
+ await writeLine(socket, MCP_HANDSHAKE_ERROR).catch(() => undefined);
154
+ socket.destroy();
155
+ return;
156
+ }
157
+ authenticatedRoute = true;
158
+ mcp = factory();
159
+ const transport = new StdioServerTransport(socket, socket, {
160
+ maxBufferSize: MCP_FRAME_MAX_BYTES,
161
+ });
162
+ transport.onerror = () => socket.destroy();
163
+ transport.onclose = () => socket.destroy();
164
+ await writeLine(socket, MCP_HANDSHAKE_OK);
165
+ if (this.#closed)
166
+ throw new Error('MCP IPC host unavailable');
167
+ await mcp.connect(transport);
168
+ if (this.#closed) {
169
+ await connection.close();
170
+ return;
171
+ }
172
+ connection.authenticated = true;
173
+ socket.resume();
174
+ })().catch(() => {
175
+ if (authenticatedRoute && !this.#closed) {
176
+ this.#logger.error('[mcp] authenticated IPC connection failed');
177
+ }
178
+ socket.destroy();
179
+ });
180
+ }
181
+ close(force = false) {
182
+ if (force)
183
+ this.#force = true;
184
+ if (!this.#closing) {
185
+ this.#closed = true;
186
+ this.#cleanupFiles();
187
+ void this.#closeUnauthenticated();
188
+ this.#closing = this.#finishClose();
189
+ }
190
+ if (force)
191
+ void this.#forceClose();
192
+ return this.#closing;
193
+ }
194
+ async #finishClose() {
195
+ await this.#starting?.catch(() => undefined);
196
+ this.#cleanupFiles();
197
+ const server = this.#server;
198
+ if (!server)
199
+ return;
200
+ const stopped = this.#stopServer(server);
201
+ if (this.#force)
202
+ await this.#forceClose();
203
+ else
204
+ await this.#closeUnauthenticated();
205
+ await stopped;
206
+ this.#cleanupFiles();
207
+ }
208
+ #stopServer(server) {
209
+ if (!server.listening)
210
+ return Promise.resolve();
211
+ return new Promise((resolve, reject) => {
212
+ server.close((error) => error ? reject(error) : resolve());
213
+ });
214
+ }
215
+ async #closeUnauthenticated() {
216
+ await Promise.allSettled([...this.#connections]
217
+ .filter((connection) => !connection.authenticated)
218
+ .map((connection) => connection.close()));
219
+ }
220
+ async #forceClose() {
221
+ await Promise.allSettled([...this.#connections].map((connection) => connection.close()));
222
+ }
223
+ #cleanupFiles() {
224
+ if (this.#descriptorFile)
225
+ fs.rmSync(this.#descriptorFile, { force: true });
226
+ if (this.#descriptor && process.platform !== 'win32') {
227
+ fs.rmSync(this.#descriptor.address, { force: true });
228
+ try {
229
+ fs.rmdirSync(path.dirname(this.#descriptor.address));
230
+ }
231
+ catch (error) {
232
+ const code = error.code;
233
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY')
234
+ throw error;
235
+ }
236
+ }
237
+ }
238
+ #removeStaleState() {
239
+ const pattern = /^mcp-runtime-([A-Za-z0-9_-]{24})\.json$/u;
240
+ for (const name of fs.readdirSync(this.#stateDirectory)) {
241
+ const generation = pattern.exec(name)?.[1];
242
+ if (!generation)
243
+ continue;
244
+ fs.rmSync(path.join(this.#stateDirectory, name), { force: true });
245
+ if (process.platform !== 'win32') {
246
+ fs.rmSync(mcpIpcAddress(this.#instanceKey, generation), { force: true });
247
+ }
248
+ }
249
+ if (process.platform !== 'win32') {
250
+ const socketDirectory = path.dirname(mcpIpcAddress(this.#instanceKey, 'a'.repeat(24)));
251
+ ensureMcpStateDirectory(socketDirectory);
252
+ const uid = process.getuid?.();
253
+ for (const name of fs.readdirSync(socketDirectory)) {
254
+ if (!name.endsWith('.sock'))
255
+ continue;
256
+ const generation = name.slice(0, -'.sock'.length);
257
+ if (!/^[A-Za-z0-9_-]{24}$/u.test(generation))
258
+ continue;
259
+ const socketPath = path.join(socketDirectory, name);
260
+ let stat;
261
+ try {
262
+ stat = fs.lstatSync(socketPath);
263
+ }
264
+ catch (error) {
265
+ if (error.code === 'ENOENT')
266
+ continue;
267
+ throw error;
268
+ }
269
+ if (!stat.isSocket() || (uid !== undefined && stat.uid !== uid))
270
+ continue;
271
+ fs.rmSync(socketPath, { force: true });
272
+ }
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,226 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ensurePrivateDirectory } from '../lib/private-directory.js';
5
+ import { canonicalPath } from '../lib/path-identity.js';
6
+ const MCP_ROUTES = [
7
+ 'wechat_kf',
8
+ 'weixin_ilink',
9
+ 'conversation_memory',
10
+ ];
11
+ export const MCP_FRAME_MAX_BYTES = 256 * 1024;
12
+ export const MCP_HANDSHAKE_OK = 'KINTIO-MCP/1 OK';
13
+ export const MCP_HANDSHAKE_ERROR = 'KINTIO-MCP/1 ERROR';
14
+ const DESCRIPTOR_MAX_BYTES = 4 * 1024;
15
+ const HANDSHAKE_MAX_BYTES = 512;
16
+ const HANDSHAKE_TIMEOUT_MS = 2_000;
17
+ const GENERATION_PATTERN = /^[A-Za-z0-9_-]{24}$/u;
18
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
19
+ const DESCRIPTOR_PREFIX = 'mcp-runtime-';
20
+ export function createMcpGeneration() {
21
+ return randomBytes(18).toString('base64url');
22
+ }
23
+ export function mcpDescriptorPath(directory, generation) {
24
+ if (!GENERATION_PATTERN.test(generation))
25
+ throw new Error('Invalid MCP generation');
26
+ return path.join(path.resolve(directory), `${DESCRIPTOR_PREFIX}${generation}.json`);
27
+ }
28
+ export function mcpIpcAddress(instanceKey, generation, platform = process.platform) {
29
+ if (!GENERATION_PATTERN.test(generation))
30
+ throw new Error('Invalid MCP generation');
31
+ const instance = mcpInstanceId(instanceKey);
32
+ const name = `kintio-mcp-${instance}-${generation}`;
33
+ return platform === 'win32'
34
+ ? `\\\\.\\pipe\\${name}`
35
+ : path.posix.join('/tmp', `kintio-mcp-${instance}`, `${generation}.sock`);
36
+ }
37
+ export function mcpInstanceId(instanceKey) {
38
+ return createHash('sha256').update(canonicalPath(instanceKey)).digest('hex').slice(0, 16);
39
+ }
40
+ export function ensureMcpStateDirectory(directory) {
41
+ const target = ensurePrivateDirectory(directory);
42
+ assertPrivateDirectory(target);
43
+ return target;
44
+ }
45
+ function parseDescriptor(value) {
46
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
47
+ throw new Error('Invalid MCP descriptor');
48
+ }
49
+ const record = value;
50
+ if (Object.keys(record).sort().join(',') !== 'address,generation,token,version' ||
51
+ record.version !== 1 ||
52
+ typeof record.generation !== 'string' ||
53
+ !GENERATION_PATTERN.test(record.generation) ||
54
+ typeof record.address !== 'string' ||
55
+ !record.address ||
56
+ record.address.length > 512 ||
57
+ record.address.includes('\0') ||
58
+ !isMcpIpcAddress(record.address, record.generation) ||
59
+ typeof record.token !== 'string' ||
60
+ !TOKEN_PATTERN.test(record.token))
61
+ throw new Error('Invalid MCP descriptor');
62
+ return Object.freeze({
63
+ version: 1,
64
+ generation: record.generation,
65
+ address: record.address,
66
+ token: record.token,
67
+ });
68
+ }
69
+ export function writeMcpDescriptor(directory, descriptor) {
70
+ const target = mcpDescriptorPath(ensureMcpStateDirectory(directory), descriptor.generation);
71
+ const source = `${JSON.stringify(parseDescriptor(descriptor))}\n`;
72
+ fs.writeFileSync(target, source, { flag: 'wx', mode: 0o600 });
73
+ try {
74
+ fs.chmodSync(target, 0o600);
75
+ }
76
+ catch (error) {
77
+ fs.rmSync(target, { force: true });
78
+ throw error;
79
+ }
80
+ return target;
81
+ }
82
+ export function readMcpDescriptor(filePath) {
83
+ const target = path.resolve(filePath);
84
+ assertPrivateDirectory(path.dirname(target));
85
+ const before = fs.lstatSync(target);
86
+ if (!before.isFile() || before.isSymbolicLink())
87
+ throw new Error('Unsafe MCP descriptor');
88
+ const noFollow = process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW;
89
+ const descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollow);
90
+ try {
91
+ const stat = fs.fstatSync(descriptor);
92
+ assertSafeDescriptor(before, stat);
93
+ const buffer = Buffer.allocUnsafe(DESCRIPTOR_MAX_BYTES + 1);
94
+ const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null);
95
+ if (bytesRead === 0 || bytesRead > DESCRIPTOR_MAX_BYTES) {
96
+ throw new Error('Unsafe MCP descriptor');
97
+ }
98
+ const parsed = parseDescriptor(JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')));
99
+ if (target !== mcpDescriptorPath(path.dirname(target), parsed.generation)) {
100
+ throw new Error('Unsafe MCP descriptor');
101
+ }
102
+ return parsed;
103
+ }
104
+ finally {
105
+ fs.closeSync(descriptor);
106
+ }
107
+ }
108
+ export function mcpHandshake(descriptor, route) {
109
+ return `${JSON.stringify({
110
+ version: descriptor.version,
111
+ generation: descriptor.generation,
112
+ route,
113
+ token: descriptor.token,
114
+ })}\n`;
115
+ }
116
+ export function parseMcpHandshake(source, descriptor) {
117
+ try {
118
+ const value = JSON.parse(source);
119
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
120
+ Object.keys(value).sort().join(',') !== 'generation,route,token,version' ||
121
+ value.version !== 1 ||
122
+ value.generation !== descriptor.generation ||
123
+ typeof value.route !== 'string' ||
124
+ !MCP_ROUTES.includes(value.route) ||
125
+ typeof value.token !== 'string')
126
+ return undefined;
127
+ const presented = Buffer.from(value.token);
128
+ const expected = Buffer.from(descriptor.token);
129
+ return presented.length === expected.length && timingSafeEqual(presented, expected)
130
+ ? value.route
131
+ : undefined;
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ export function readSocketLine(socket) {
138
+ return new Promise((resolve, reject) => {
139
+ let buffer = Buffer.alloc(0);
140
+ let settled = false;
141
+ const cleanup = () => {
142
+ clearTimeout(timer);
143
+ socket.off('data', onData);
144
+ socket.off('error', onError);
145
+ socket.off('end', onEnd);
146
+ socket.off('close', onClose);
147
+ };
148
+ const finish = (error, line = '') => {
149
+ if (settled)
150
+ return;
151
+ settled = true;
152
+ cleanup();
153
+ if (error)
154
+ reject(error);
155
+ else
156
+ resolve(line);
157
+ };
158
+ const onError = () => finish(new Error('MCP IPC handshake failed'));
159
+ const onEnd = () => finish(new Error('MCP IPC handshake ended'));
160
+ const onClose = () => finish(new Error('MCP IPC handshake closed'));
161
+ const onData = (chunk) => {
162
+ const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
163
+ const newline = data.indexOf(0x0a);
164
+ if (newline < 0) {
165
+ if (buffer.length + data.length > HANDSHAKE_MAX_BYTES) {
166
+ finish(new Error('MCP IPC handshake too large'));
167
+ return;
168
+ }
169
+ buffer = Buffer.concat([buffer, data], buffer.length + data.length);
170
+ return;
171
+ }
172
+ const lineBytes = buffer.length + newline;
173
+ if (lineBytes > HANDSHAKE_MAX_BYTES) {
174
+ finish(new Error('MCP IPC handshake too large'));
175
+ return;
176
+ }
177
+ const line = buffer.length
178
+ ? Buffer.concat([buffer, data.subarray(0, newline)], lineBytes)
179
+ : data.subarray(0, newline);
180
+ const remainder = data.subarray(newline + 1);
181
+ socket.pause();
182
+ if (remainder.length)
183
+ socket.unshift(remainder);
184
+ finish(undefined, line.toString('utf8').replace(/\r$/u, ''));
185
+ };
186
+ const timer = setTimeout(() => finish(new Error('MCP IPC handshake timed out')), HANDSHAKE_TIMEOUT_MS);
187
+ timer.unref?.();
188
+ socket.on('data', onData);
189
+ socket.once('error', onError);
190
+ socket.once('end', onEnd);
191
+ socket.once('close', onClose);
192
+ socket.resume();
193
+ });
194
+ }
195
+ export function isMcpRoute(value) {
196
+ return MCP_ROUTES.includes(value);
197
+ }
198
+ function assertPrivateDirectory(directory) {
199
+ const stat = fs.lstatSync(directory);
200
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
201
+ throw new Error('Unsafe MCP state directory');
202
+ }
203
+ if (process.platform !== 'win32') {
204
+ const uid = process.getuid?.();
205
+ if ((uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
206
+ throw new Error('Unsafe MCP state directory');
207
+ }
208
+ }
209
+ }
210
+ function assertSafeDescriptor(before, opened) {
211
+ if (!opened.isFile() || opened.size <= 0 || opened.size > DESCRIPTOR_MAX_BYTES ||
212
+ before.dev !== opened.dev || before.ino !== opened.ino)
213
+ throw new Error('Unsafe MCP descriptor');
214
+ if (process.platform !== 'win32') {
215
+ const uid = process.getuid?.();
216
+ if ((uid !== undefined && opened.uid !== uid) || (opened.mode & 0o077) !== 0) {
217
+ throw new Error('Unsafe MCP descriptor');
218
+ }
219
+ }
220
+ }
221
+ function isMcpIpcAddress(value, generation) {
222
+ const match = process.platform === 'win32'
223
+ ? /^\\\\\.\\pipe\\kintio-mcp-[a-f0-9]{16}-([A-Za-z0-9_-]{24})$/u.exec(value)
224
+ : /^\/tmp\/kintio-mcp-[a-f0-9]{16}\/([A-Za-z0-9_-]{24})\.sock$/u.exec(value);
225
+ return match?.[1] === generation;
226
+ }
@@ -0,0 +1,122 @@
1
+ import { createConnection } from 'node:net';
2
+ import path from 'node:path';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { MCP_HANDSHAKE_OK, isMcpRoute, mcpHandshake, readMcpDescriptor, readSocketLine, } from './ipc-protocol.js';
5
+ const CONNECT_TIMEOUT_MS = 2_000;
6
+ export const MCP_RELAY_ERROR = 'Kintio MCP relay failed\n';
7
+ export function parseMcpRelayArgs(args) {
8
+ if (args.length !== 4 ||
9
+ args[0] !== '--descriptor' ||
10
+ args[2] !== '--route' ||
11
+ !args[1] ||
12
+ !path.isAbsolute(args[1]) ||
13
+ args[1].includes('\0') ||
14
+ !args[3] ||
15
+ !isMcpRoute(args[3]))
16
+ throw new Error('Invalid MCP relay arguments');
17
+ return Object.freeze({
18
+ descriptorFile: path.resolve(args[1]),
19
+ route: args[3],
20
+ });
21
+ }
22
+ export async function runMcpRelay(args, streams) {
23
+ const options = parseMcpRelayArgs(args);
24
+ const descriptor = readMcpDescriptor(options.descriptorFile);
25
+ const socket = await connectIpc(descriptor.address);
26
+ try {
27
+ await writeSocket(socket, mcpHandshake(descriptor, options.route));
28
+ if (await readSocketLine(socket) !== MCP_HANDSHAKE_OK) {
29
+ throw new Error('MCP relay authentication failed');
30
+ }
31
+ await relayBytes(socket, streams);
32
+ }
33
+ finally {
34
+ socket.destroy();
35
+ }
36
+ }
37
+ async function connectIpc(address) {
38
+ const socket = createConnection({ path: address });
39
+ // Keep the dedicated relay process fail-closed even if an error occurs in the
40
+ // small gaps between the connect, handshake, and stream pipeline listeners.
41
+ socket.on('error', () => undefined);
42
+ return new Promise((resolve, reject) => {
43
+ let settled = false;
44
+ const finish = (error) => {
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ clearTimeout(timer);
49
+ socket.off('connect', onConnect);
50
+ socket.off('error', onError);
51
+ socket.off('close', onClose);
52
+ if (error) {
53
+ socket.destroy();
54
+ reject(error);
55
+ }
56
+ else {
57
+ resolve(socket);
58
+ }
59
+ };
60
+ const onConnect = () => finish();
61
+ const onError = () => finish(new Error('MCP IPC connection failed'));
62
+ const onClose = () => finish(new Error('MCP IPC connection closed'));
63
+ const timer = setTimeout(() => finish(new Error('MCP IPC connection timed out')), CONNECT_TIMEOUT_MS);
64
+ timer.unref?.();
65
+ socket.once('connect', onConnect);
66
+ socket.once('error', onError);
67
+ socket.once('close', onClose);
68
+ });
69
+ }
70
+ function writeSocket(socket, source) {
71
+ return new Promise((resolve, reject) => {
72
+ let settled = false;
73
+ const finish = (error) => {
74
+ if (settled)
75
+ return;
76
+ settled = true;
77
+ socket.off('error', onError);
78
+ socket.off('close', onClose);
79
+ if (error)
80
+ reject(new Error('MCP IPC write failed'));
81
+ else
82
+ resolve();
83
+ };
84
+ const onError = () => finish(new Error('MCP IPC write failed'));
85
+ const onClose = () => finish(new Error('MCP IPC write closed'));
86
+ socket.once('error', onError);
87
+ socket.once('close', onClose);
88
+ socket.write(source, finish);
89
+ });
90
+ }
91
+ async function relayBytes(socket, { input, output }) {
92
+ const controller = new AbortController();
93
+ const upstream = pipeline(input, socket, {
94
+ end: false,
95
+ signal: controller.signal,
96
+ });
97
+ const downstream = pipeline(socket, output, { signal: controller.signal });
98
+ try {
99
+ const first = await Promise.race([
100
+ upstream.then(() => 'input'),
101
+ downstream.then(() => 'socket'),
102
+ ]);
103
+ if (first === 'input') {
104
+ socket.end();
105
+ await downstream;
106
+ return;
107
+ }
108
+ // The MCP host ended first. Stop waiting on a still-open parent stdin.
109
+ controller.abort();
110
+ input.destroy();
111
+ void upstream.catch(() => undefined);
112
+ }
113
+ catch {
114
+ controller.abort();
115
+ input.destroy();
116
+ output.destroy();
117
+ socket.destroy();
118
+ void upstream.catch(() => undefined);
119
+ void downstream.catch(() => undefined);
120
+ throw new Error('MCP stdio relay failed');
121
+ }
122
+ }