@schuttdev/gigai 0.1.0-beta.2 → 0.1.0-beta.6
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/dist/chunk-4XUWD3DZ.js +288 -0
- package/dist/chunk-FN4LCKUA.js +42 -0
- package/dist/chunk-OWDYY3IG.js +73 -0
- package/dist/dist-DX6G464T.js +1164 -0
- package/dist/index.js +11 -4148
- package/dist/pairing-IGMDVOIZ-RA7GNFU7.js +10 -0
- package/dist/store-Y4V3TOYJ-GKOB6ANA.js +7 -0
- package/package.json +11 -2
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
AuthStore
|
|
4
|
+
} from "./chunk-OWDYY3IG.js";
|
|
5
|
+
import {
|
|
6
|
+
generatePairingCode,
|
|
7
|
+
validateAndPair
|
|
8
|
+
} from "./chunk-FN4LCKUA.js";
|
|
9
|
+
import {
|
|
10
|
+
ErrorCode,
|
|
11
|
+
GigaiConfigSchema,
|
|
12
|
+
GigaiError,
|
|
13
|
+
decrypt,
|
|
14
|
+
generateEncryptionKey
|
|
15
|
+
} from "./chunk-4XUWD3DZ.js";
|
|
16
|
+
|
|
17
|
+
// ../server/dist/index.mjs
|
|
18
|
+
import { parseArgs } from "util";
|
|
19
|
+
import Fastify from "fastify";
|
|
20
|
+
import cors from "@fastify/cors";
|
|
21
|
+
import rateLimit from "@fastify/rate-limit";
|
|
22
|
+
import multipart from "@fastify/multipart";
|
|
23
|
+
import fp from "fastify-plugin";
|
|
24
|
+
import { randomBytes } from "crypto";
|
|
25
|
+
import fp2 from "fastify-plugin";
|
|
26
|
+
import fp3 from "fastify-plugin";
|
|
27
|
+
import { spawn } from "child_process";
|
|
28
|
+
import fp4 from "fastify-plugin";
|
|
29
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
30
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
31
|
+
import { readFile } from "fs/promises";
|
|
32
|
+
import { resolve } from "path";
|
|
33
|
+
import { readFile as fsReadFile, readdir } from "fs/promises";
|
|
34
|
+
import { resolve as resolve2, relative, join } from "path";
|
|
35
|
+
import { realpath } from "fs/promises";
|
|
36
|
+
import { spawn as spawn2 } from "child_process";
|
|
37
|
+
import { writeFile, readFile as readFile2, unlink, mkdir } from "fs/promises";
|
|
38
|
+
import { join as join2 } from "path";
|
|
39
|
+
import { tmpdir } from "os";
|
|
40
|
+
import { nanoid } from "nanoid";
|
|
41
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
42
|
+
import { resolve as resolve3 } from "path";
|
|
43
|
+
import { input, select, checkbox, confirm } from "@inquirer/prompts";
|
|
44
|
+
import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
45
|
+
import { resolve as resolve4, join as join3 } from "path";
|
|
46
|
+
import { input as input2 } from "@inquirer/prompts";
|
|
47
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
48
|
+
import { resolve as resolve5 } from "path";
|
|
49
|
+
function connectWithToken(store, encryptedToken, orgUuid, encryptionKey, sessionTtlSeconds) {
|
|
50
|
+
let payload;
|
|
51
|
+
try {
|
|
52
|
+
payload = JSON.parse(encryptedToken);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new GigaiError(ErrorCode.TOKEN_INVALID, "Invalid token format");
|
|
55
|
+
}
|
|
56
|
+
let decrypted;
|
|
57
|
+
try {
|
|
58
|
+
decrypted = decrypt(payload, encryptionKey);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new GigaiError(ErrorCode.TOKEN_DECRYPT_FAILED, "Failed to decrypt token");
|
|
61
|
+
}
|
|
62
|
+
if (decrypted.orgUuid !== orgUuid) {
|
|
63
|
+
throw new GigaiError(ErrorCode.ORG_MISMATCH, "Organization UUID mismatch");
|
|
64
|
+
}
|
|
65
|
+
return store.createSession(orgUuid, sessionTtlSeconds);
|
|
66
|
+
}
|
|
67
|
+
function validateSession(store, token) {
|
|
68
|
+
const session = store.getSession(token);
|
|
69
|
+
if (!session) {
|
|
70
|
+
throw new GigaiError(ErrorCode.SESSION_INVALID, "Invalid session token");
|
|
71
|
+
}
|
|
72
|
+
if (session.expiresAt < Date.now()) {
|
|
73
|
+
throw new GigaiError(ErrorCode.SESSION_EXPIRED, "Session expired");
|
|
74
|
+
}
|
|
75
|
+
return session;
|
|
76
|
+
}
|
|
77
|
+
function createAuthMiddleware(store) {
|
|
78
|
+
return async function authMiddleware(request, _reply) {
|
|
79
|
+
const authHeader = request.headers.authorization;
|
|
80
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
81
|
+
throw new GigaiError(ErrorCode.AUTH_REQUIRED, "Authorization header required");
|
|
82
|
+
}
|
|
83
|
+
const token = authHeader.slice(7);
|
|
84
|
+
const session = validateSession(store, token);
|
|
85
|
+
request.session = session;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function registerAuthRoutes(server, store, config) {
|
|
89
|
+
const serverFingerprint = randomBytes(16).toString("hex");
|
|
90
|
+
server.post("/auth/pair", {
|
|
91
|
+
config: {
|
|
92
|
+
rateLimit: { max: 5, timeWindow: "1 hour" }
|
|
93
|
+
},
|
|
94
|
+
schema: {
|
|
95
|
+
body: {
|
|
96
|
+
type: "object",
|
|
97
|
+
required: ["pairingCode", "orgUuid"],
|
|
98
|
+
properties: {
|
|
99
|
+
pairingCode: { type: "string" },
|
|
100
|
+
orgUuid: { type: "string" }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, async (request) => {
|
|
105
|
+
const { pairingCode, orgUuid } = request.body;
|
|
106
|
+
const encryptedToken = validateAndPair(
|
|
107
|
+
store,
|
|
108
|
+
pairingCode,
|
|
109
|
+
orgUuid,
|
|
110
|
+
config.auth.encryptionKey,
|
|
111
|
+
serverFingerprint
|
|
112
|
+
);
|
|
113
|
+
return { encryptedToken: JSON.stringify(encryptedToken) };
|
|
114
|
+
});
|
|
115
|
+
server.post("/auth/connect", {
|
|
116
|
+
config: {
|
|
117
|
+
rateLimit: { max: 10, timeWindow: "1 minute" }
|
|
118
|
+
},
|
|
119
|
+
schema: {
|
|
120
|
+
body: {
|
|
121
|
+
type: "object",
|
|
122
|
+
required: ["encryptedToken", "orgUuid"],
|
|
123
|
+
properties: {
|
|
124
|
+
encryptedToken: { type: "string" },
|
|
125
|
+
orgUuid: { type: "string" }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}, async (request) => {
|
|
130
|
+
const { encryptedToken, orgUuid } = request.body;
|
|
131
|
+
const session = connectWithToken(
|
|
132
|
+
store,
|
|
133
|
+
encryptedToken,
|
|
134
|
+
orgUuid,
|
|
135
|
+
config.auth.encryptionKey,
|
|
136
|
+
config.auth.sessionTtlSeconds
|
|
137
|
+
);
|
|
138
|
+
return {
|
|
139
|
+
sessionToken: session.token,
|
|
140
|
+
expiresAt: session.expiresAt
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
server.get("/auth/pair/generate", {
|
|
144
|
+
config: { skipAuth: true }
|
|
145
|
+
}, async (request) => {
|
|
146
|
+
const remoteAddr = request.ip;
|
|
147
|
+
if (remoteAddr !== "127.0.0.1" && remoteAddr !== "::1" && remoteAddr !== "::ffff:127.0.0.1") {
|
|
148
|
+
throw new GigaiError(ErrorCode.AUTH_REQUIRED, "Pairing code generation is only available from localhost");
|
|
149
|
+
}
|
|
150
|
+
const code = generatePairingCode(store, config.auth.pairingTtlSeconds);
|
|
151
|
+
return { code, expiresIn: config.auth.pairingTtlSeconds };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
var authPlugin = fp(async (server, opts) => {
|
|
155
|
+
const store = new AuthStore();
|
|
156
|
+
const authMiddleware = createAuthMiddleware(store);
|
|
157
|
+
server.decorate("authStore", store);
|
|
158
|
+
server.addHook("onRequest", async (request, reply) => {
|
|
159
|
+
const routeConfig = request.routeOptions?.config ?? {};
|
|
160
|
+
if (routeConfig.skipAuth) return;
|
|
161
|
+
if (request.url === "/health") return;
|
|
162
|
+
if (request.url.startsWith("/auth/")) return;
|
|
163
|
+
await authMiddleware(request, reply);
|
|
164
|
+
});
|
|
165
|
+
registerAuthRoutes(server, store, opts.config);
|
|
166
|
+
server.addHook("onClose", async () => {
|
|
167
|
+
store.destroy();
|
|
168
|
+
});
|
|
169
|
+
}, { name: "auth" });
|
|
170
|
+
var ToolRegistry = class {
|
|
171
|
+
tools = /* @__PURE__ */ new Map();
|
|
172
|
+
loadFromConfig(tools) {
|
|
173
|
+
for (const tool of tools) {
|
|
174
|
+
this.register(tool);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
register(config) {
|
|
178
|
+
const entry = { type: config.type, config };
|
|
179
|
+
this.tools.set(config.name, entry);
|
|
180
|
+
}
|
|
181
|
+
get(name) {
|
|
182
|
+
const entry = this.tools.get(name);
|
|
183
|
+
if (!entry) {
|
|
184
|
+
throw new GigaiError(ErrorCode.TOOL_NOT_FOUND, `Tool not found: ${name}`);
|
|
185
|
+
}
|
|
186
|
+
return entry;
|
|
187
|
+
}
|
|
188
|
+
has(name) {
|
|
189
|
+
return this.tools.has(name);
|
|
190
|
+
}
|
|
191
|
+
list() {
|
|
192
|
+
return Array.from(this.tools.values()).map((entry) => ({
|
|
193
|
+
name: entry.config.name,
|
|
194
|
+
type: entry.config.type,
|
|
195
|
+
description: entry.config.description
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
getDetail(name) {
|
|
199
|
+
const entry = this.get(name);
|
|
200
|
+
const detail = {
|
|
201
|
+
name: entry.config.name,
|
|
202
|
+
type: entry.config.type,
|
|
203
|
+
description: entry.config.description
|
|
204
|
+
};
|
|
205
|
+
if (entry.type === "cli") {
|
|
206
|
+
detail.usage = `${entry.config.command} ${(entry.config.args ?? []).join(" ")} [args...]`;
|
|
207
|
+
} else if (entry.type === "script") {
|
|
208
|
+
detail.usage = `${entry.config.path} [args...]`;
|
|
209
|
+
}
|
|
210
|
+
return detail;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
var registryPlugin = fp2(async (server, opts) => {
|
|
214
|
+
const registry = new ToolRegistry();
|
|
215
|
+
registry.loadFromConfig(opts.config.tools);
|
|
216
|
+
server.decorate("registry", registry);
|
|
217
|
+
server.log.info(`Loaded ${registry.list().length} tools`);
|
|
218
|
+
}, { name: "registry" });
|
|
219
|
+
var MAX_ARG_LENGTH = 64 * 1024;
|
|
220
|
+
function sanitizeArgs(args) {
|
|
221
|
+
return args.map((arg, i) => {
|
|
222
|
+
if (arg.includes("\0")) {
|
|
223
|
+
throw new GigaiError(
|
|
224
|
+
ErrorCode.VALIDATION_ERROR,
|
|
225
|
+
`Argument ${i} contains null byte`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (arg.length > MAX_ARG_LENGTH) {
|
|
229
|
+
throw new GigaiError(
|
|
230
|
+
ErrorCode.VALIDATION_ERROR,
|
|
231
|
+
`Argument ${i} exceeds maximum length of ${MAX_ARG_LENGTH}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return arg;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
238
|
+
var KILL_GRACE_PERIOD = 5e3;
|
|
239
|
+
var MAX_OUTPUT_SIZE = 10 * 1024 * 1024;
|
|
240
|
+
function executeTool(entry, args, timeout) {
|
|
241
|
+
const sanitized = sanitizeArgs(args);
|
|
242
|
+
const effectiveTimeout = timeout ?? DEFAULT_TIMEOUT;
|
|
243
|
+
let command;
|
|
244
|
+
let spawnArgs;
|
|
245
|
+
let cwd;
|
|
246
|
+
let env;
|
|
247
|
+
switch (entry.type) {
|
|
248
|
+
case "cli":
|
|
249
|
+
command = entry.config.command;
|
|
250
|
+
spawnArgs = [...entry.config.args ?? [], ...sanitized];
|
|
251
|
+
cwd = entry.config.cwd;
|
|
252
|
+
env = entry.config.env;
|
|
253
|
+
break;
|
|
254
|
+
case "script": {
|
|
255
|
+
const interpreter = entry.config.interpreter ?? "node";
|
|
256
|
+
command = interpreter;
|
|
257
|
+
spawnArgs = [entry.config.path, ...sanitized];
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
throw new GigaiError(
|
|
262
|
+
ErrorCode.EXEC_FAILED,
|
|
263
|
+
`Cannot execute tool of type: ${entry.type}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return new Promise((resolve6, reject) => {
|
|
267
|
+
const start = Date.now();
|
|
268
|
+
const stdoutChunks = [];
|
|
269
|
+
const stderrChunks = [];
|
|
270
|
+
const child = spawn(command, spawnArgs, {
|
|
271
|
+
shell: false,
|
|
272
|
+
cwd,
|
|
273
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
274
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
275
|
+
});
|
|
276
|
+
let killed = false;
|
|
277
|
+
const timer = setTimeout(() => {
|
|
278
|
+
killed = true;
|
|
279
|
+
child.kill("SIGTERM");
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
if (!child.killed) {
|
|
282
|
+
child.kill("SIGKILL");
|
|
283
|
+
}
|
|
284
|
+
}, KILL_GRACE_PERIOD);
|
|
285
|
+
}, effectiveTimeout);
|
|
286
|
+
let totalSize = 0;
|
|
287
|
+
child.stdout.on("data", (chunk) => {
|
|
288
|
+
totalSize += chunk.length;
|
|
289
|
+
if (totalSize <= MAX_OUTPUT_SIZE) stdoutChunks.push(chunk);
|
|
290
|
+
else if (!killed) {
|
|
291
|
+
killed = true;
|
|
292
|
+
child.kill("SIGTERM");
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
child.stderr.on("data", (chunk) => {
|
|
296
|
+
totalSize += chunk.length;
|
|
297
|
+
if (totalSize <= MAX_OUTPUT_SIZE) stderrChunks.push(chunk);
|
|
298
|
+
else if (!killed) {
|
|
299
|
+
killed = true;
|
|
300
|
+
child.kill("SIGTERM");
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
child.on("error", (err) => {
|
|
304
|
+
clearTimeout(timer);
|
|
305
|
+
reject(new GigaiError(ErrorCode.EXEC_FAILED, `Failed to spawn: ${err.message}`));
|
|
306
|
+
});
|
|
307
|
+
child.on("close", (exitCode) => {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
const durationMs = Date.now() - start;
|
|
310
|
+
if (killed) {
|
|
311
|
+
reject(new GigaiError(ErrorCode.EXEC_TIMEOUT, `Tool execution timed out after ${effectiveTimeout}ms`));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
resolve6({
|
|
315
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
316
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
317
|
+
exitCode: exitCode ?? 1,
|
|
318
|
+
durationMs
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
var executorPlugin = fp3(async (server) => {
|
|
324
|
+
server.decorate("executor", {
|
|
325
|
+
execute: executeTool
|
|
326
|
+
});
|
|
327
|
+
}, { name: "executor" });
|
|
328
|
+
var McpClientWrapper = class {
|
|
329
|
+
constructor(config) {
|
|
330
|
+
this.config = config;
|
|
331
|
+
}
|
|
332
|
+
client = null;
|
|
333
|
+
transport = null;
|
|
334
|
+
toolsCache = null;
|
|
335
|
+
connected = false;
|
|
336
|
+
async ensureConnected() {
|
|
337
|
+
if (this.connected && this.client) return;
|
|
338
|
+
this.transport = new StdioClientTransport({
|
|
339
|
+
command: this.config.command,
|
|
340
|
+
args: this.config.args,
|
|
341
|
+
env: this.config.env ? { ...process.env, ...this.config.env } : process.env
|
|
342
|
+
});
|
|
343
|
+
this.client = new Client({
|
|
344
|
+
name: `gigai-${this.config.name}`,
|
|
345
|
+
version: "0.1.0"
|
|
346
|
+
});
|
|
347
|
+
await this.client.connect(this.transport);
|
|
348
|
+
this.connected = true;
|
|
349
|
+
}
|
|
350
|
+
async listTools() {
|
|
351
|
+
if (this.toolsCache) return this.toolsCache;
|
|
352
|
+
await this.ensureConnected();
|
|
353
|
+
const result = await this.client.listTools();
|
|
354
|
+
this.toolsCache = result.tools.map((t) => ({
|
|
355
|
+
name: t.name,
|
|
356
|
+
description: t.description ?? "",
|
|
357
|
+
inputSchema: t.inputSchema
|
|
358
|
+
}));
|
|
359
|
+
return this.toolsCache;
|
|
360
|
+
}
|
|
361
|
+
async callTool(toolName, args) {
|
|
362
|
+
await this.ensureConnected();
|
|
363
|
+
try {
|
|
364
|
+
const result = await this.client.callTool({ name: toolName, arguments: args });
|
|
365
|
+
return {
|
|
366
|
+
content: result.content ?? [],
|
|
367
|
+
isError: result.isError ?? false
|
|
368
|
+
};
|
|
369
|
+
} catch (err) {
|
|
370
|
+
throw new GigaiError(
|
|
371
|
+
ErrorCode.MCP_ERROR,
|
|
372
|
+
`MCP tool call failed: ${err.message}`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async disconnect() {
|
|
377
|
+
if (this.client && this.connected) {
|
|
378
|
+
try {
|
|
379
|
+
await this.client.close();
|
|
380
|
+
} catch {
|
|
381
|
+
}
|
|
382
|
+
this.client = null;
|
|
383
|
+
this.transport = null;
|
|
384
|
+
this.connected = false;
|
|
385
|
+
this.toolsCache = null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
get isConnected() {
|
|
389
|
+
return this.connected;
|
|
390
|
+
}
|
|
391
|
+
get name() {
|
|
392
|
+
return this.config.name;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
var McpPool = class {
|
|
396
|
+
clients = /* @__PURE__ */ new Map();
|
|
397
|
+
loadFromConfig(tools) {
|
|
398
|
+
for (const tool of tools) {
|
|
399
|
+
this.clients.set(tool.name, new McpClientWrapper(tool));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
getClient(name) {
|
|
403
|
+
const client = this.clients.get(name);
|
|
404
|
+
if (!client) {
|
|
405
|
+
throw new GigaiError(ErrorCode.TOOL_NOT_FOUND, `MCP tool not found: ${name}`);
|
|
406
|
+
}
|
|
407
|
+
return client;
|
|
408
|
+
}
|
|
409
|
+
has(name) {
|
|
410
|
+
return this.clients.has(name);
|
|
411
|
+
}
|
|
412
|
+
async listToolsFor(name) {
|
|
413
|
+
const client = this.getClient(name);
|
|
414
|
+
return client.listTools();
|
|
415
|
+
}
|
|
416
|
+
list() {
|
|
417
|
+
return Array.from(this.clients.keys());
|
|
418
|
+
}
|
|
419
|
+
async shutdownAll() {
|
|
420
|
+
const disconnects = Array.from(this.clients.values()).map((c) => c.disconnect());
|
|
421
|
+
await Promise.allSettled(disconnects);
|
|
422
|
+
this.clients.clear();
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
var McpLifecycleManager = class {
|
|
426
|
+
constructor(pool) {
|
|
427
|
+
this.pool = pool;
|
|
428
|
+
}
|
|
429
|
+
healthCheckInterval = null;
|
|
430
|
+
startHealthChecks(intervalMs = 3e4) {
|
|
431
|
+
this.healthCheckInterval = setInterval(async () => {
|
|
432
|
+
for (const name of this.pool.list()) {
|
|
433
|
+
try {
|
|
434
|
+
const client = this.pool.getClient(name);
|
|
435
|
+
if (client.isConnected) {
|
|
436
|
+
await client.listTools();
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}, intervalMs);
|
|
442
|
+
}
|
|
443
|
+
async shutdown() {
|
|
444
|
+
if (this.healthCheckInterval) {
|
|
445
|
+
clearInterval(this.healthCheckInterval);
|
|
446
|
+
this.healthCheckInterval = null;
|
|
447
|
+
}
|
|
448
|
+
await this.pool.shutdownAll();
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
var mcpPlugin = fp4(async (server, opts) => {
|
|
452
|
+
const pool = new McpPool();
|
|
453
|
+
const mcpTools = opts.config.tools.filter(
|
|
454
|
+
(t) => t.type === "mcp"
|
|
455
|
+
);
|
|
456
|
+
pool.loadFromConfig(mcpTools);
|
|
457
|
+
server.decorate("mcpPool", pool);
|
|
458
|
+
const lifecycle = new McpLifecycleManager(pool);
|
|
459
|
+
lifecycle.startHealthChecks();
|
|
460
|
+
server.log.info(`MCP pool initialized with ${mcpTools.length} servers`);
|
|
461
|
+
server.addHook("onClose", async () => {
|
|
462
|
+
await lifecycle.shutdown();
|
|
463
|
+
});
|
|
464
|
+
}, { name: "mcp" });
|
|
465
|
+
var startTime = Date.now();
|
|
466
|
+
async function healthRoutes(server) {
|
|
467
|
+
server.get("/health", {
|
|
468
|
+
config: { skipAuth: true }
|
|
469
|
+
}, async () => {
|
|
470
|
+
let version = "0.1.0";
|
|
471
|
+
try {
|
|
472
|
+
const pkg = JSON.parse(
|
|
473
|
+
await readFile(resolve(import.meta.dirname ?? ".", "../package.json"), "utf8")
|
|
474
|
+
);
|
|
475
|
+
version = pkg.version;
|
|
476
|
+
} catch {
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
status: "ok",
|
|
480
|
+
version,
|
|
481
|
+
uptime: Date.now() - startTime
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
async function toolRoutes(server) {
|
|
486
|
+
server.get("/tools", async () => {
|
|
487
|
+
return { tools: server.registry.list() };
|
|
488
|
+
});
|
|
489
|
+
server.get("/tools/:name", async (request) => {
|
|
490
|
+
const { name } = request.params;
|
|
491
|
+
const detail = server.registry.getDetail(name);
|
|
492
|
+
const entry = server.registry.get(name);
|
|
493
|
+
if (entry.type === "mcp") {
|
|
494
|
+
try {
|
|
495
|
+
const mcpTools = await server.mcpPool.listToolsFor(name);
|
|
496
|
+
detail.mcpTools = mcpTools;
|
|
497
|
+
} catch {
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return { tool: detail };
|
|
501
|
+
});
|
|
502
|
+
server.get("/tools/:name/mcp", async (request) => {
|
|
503
|
+
const { name } = request.params;
|
|
504
|
+
const entry = server.registry.get(name);
|
|
505
|
+
if (entry.type !== "mcp") {
|
|
506
|
+
return { tools: [] };
|
|
507
|
+
}
|
|
508
|
+
const mcpTools = await server.mcpPool.listToolsFor(name);
|
|
509
|
+
return { tools: mcpTools };
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
async function validatePath(targetPath, allowedPaths) {
|
|
513
|
+
const resolved = resolve2(targetPath);
|
|
514
|
+
let real;
|
|
515
|
+
try {
|
|
516
|
+
real = await realpath(resolved);
|
|
517
|
+
} catch {
|
|
518
|
+
real = resolved;
|
|
519
|
+
}
|
|
520
|
+
const isAllowed = allowedPaths.some((allowed) => {
|
|
521
|
+
const resolvedAllowed = resolve2(allowed);
|
|
522
|
+
const allowedPrefix = resolvedAllowed.endsWith("/") ? resolvedAllowed : resolvedAllowed + "/";
|
|
523
|
+
return real === resolvedAllowed || real.startsWith(allowedPrefix) || resolved === resolvedAllowed || resolved.startsWith(allowedPrefix);
|
|
524
|
+
});
|
|
525
|
+
if (!isAllowed) {
|
|
526
|
+
throw new GigaiError(
|
|
527
|
+
ErrorCode.PATH_NOT_ALLOWED,
|
|
528
|
+
`Path not within allowed directories: ${targetPath}`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return resolved;
|
|
532
|
+
}
|
|
533
|
+
async function readFileSafe(path, allowedPaths) {
|
|
534
|
+
const safePath = await validatePath(path, allowedPaths);
|
|
535
|
+
return fsReadFile(safePath, "utf8");
|
|
536
|
+
}
|
|
537
|
+
async function listDirSafe(path, allowedPaths) {
|
|
538
|
+
const safePath = await validatePath(path, allowedPaths);
|
|
539
|
+
const entries = await readdir(safePath, { withFileTypes: true });
|
|
540
|
+
return entries.map((e) => ({
|
|
541
|
+
name: e.name,
|
|
542
|
+
type: e.isDirectory() ? "directory" : "file"
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
async function searchFilesSafe(path, pattern, allowedPaths) {
|
|
546
|
+
const safePath = await validatePath(path, allowedPaths);
|
|
547
|
+
const results = [];
|
|
548
|
+
let regex;
|
|
549
|
+
try {
|
|
550
|
+
regex = new RegExp(pattern, "i");
|
|
551
|
+
} catch {
|
|
552
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, `Invalid search pattern: ${pattern}`);
|
|
553
|
+
}
|
|
554
|
+
async function walk(dir) {
|
|
555
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
556
|
+
for (const entry of entries) {
|
|
557
|
+
const fullPath = join(dir, entry.name);
|
|
558
|
+
if (regex.test(entry.name)) {
|
|
559
|
+
results.push(relative(safePath, fullPath));
|
|
560
|
+
}
|
|
561
|
+
if (entry.isDirectory()) {
|
|
562
|
+
await walk(fullPath);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
await walk(safePath);
|
|
567
|
+
return results;
|
|
568
|
+
}
|
|
569
|
+
var SHELL_INTERPRETERS = /* @__PURE__ */ new Set([
|
|
570
|
+
"sh",
|
|
571
|
+
"bash",
|
|
572
|
+
"zsh",
|
|
573
|
+
"fish",
|
|
574
|
+
"csh",
|
|
575
|
+
"tcsh",
|
|
576
|
+
"dash",
|
|
577
|
+
"ksh",
|
|
578
|
+
"env",
|
|
579
|
+
"xargs",
|
|
580
|
+
"nohup",
|
|
581
|
+
"strace",
|
|
582
|
+
"ltrace"
|
|
583
|
+
]);
|
|
584
|
+
var MAX_OUTPUT_SIZE2 = 10 * 1024 * 1024;
|
|
585
|
+
async function execCommandSafe(command, args, config) {
|
|
586
|
+
if (!config.allowlist.includes(command)) {
|
|
587
|
+
throw new GigaiError(
|
|
588
|
+
ErrorCode.COMMAND_NOT_ALLOWED,
|
|
589
|
+
`Command not in allowlist: ${command}. Allowed: ${config.allowlist.join(", ")}`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (command === "sudo" && !config.allowSudo) {
|
|
593
|
+
throw new GigaiError(ErrorCode.COMMAND_NOT_ALLOWED, "sudo is not allowed");
|
|
594
|
+
}
|
|
595
|
+
if (SHELL_INTERPRETERS.has(command)) {
|
|
596
|
+
throw new GigaiError(
|
|
597
|
+
ErrorCode.COMMAND_NOT_ALLOWED,
|
|
598
|
+
`Shell interpreter not allowed: ${command}`
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
for (const arg of args) {
|
|
602
|
+
if (arg.includes("\0")) {
|
|
603
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, "Null byte in argument");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return new Promise((resolve6, reject) => {
|
|
607
|
+
const child = spawn2(command, args, {
|
|
608
|
+
shell: false,
|
|
609
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
610
|
+
});
|
|
611
|
+
const stdoutChunks = [];
|
|
612
|
+
const stderrChunks = [];
|
|
613
|
+
let totalSize = 0;
|
|
614
|
+
child.stdout.on("data", (chunk) => {
|
|
615
|
+
totalSize += chunk.length;
|
|
616
|
+
if (totalSize <= MAX_OUTPUT_SIZE2) stdoutChunks.push(chunk);
|
|
617
|
+
else child.kill("SIGTERM");
|
|
618
|
+
});
|
|
619
|
+
child.stderr.on("data", (chunk) => {
|
|
620
|
+
totalSize += chunk.length;
|
|
621
|
+
if (totalSize <= MAX_OUTPUT_SIZE2) stderrChunks.push(chunk);
|
|
622
|
+
else child.kill("SIGTERM");
|
|
623
|
+
});
|
|
624
|
+
child.on("error", (err) => {
|
|
625
|
+
reject(new GigaiError(ErrorCode.EXEC_FAILED, `Failed to spawn ${command}: ${err.message}`));
|
|
626
|
+
});
|
|
627
|
+
child.on("close", (exitCode) => {
|
|
628
|
+
resolve6({
|
|
629
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
630
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
631
|
+
exitCode: exitCode ?? 1
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
async function execRoutes(server) {
|
|
637
|
+
server.post("/exec", {
|
|
638
|
+
config: {
|
|
639
|
+
rateLimit: { max: 60, timeWindow: "1 minute" }
|
|
640
|
+
},
|
|
641
|
+
schema: {
|
|
642
|
+
body: {
|
|
643
|
+
type: "object",
|
|
644
|
+
required: ["tool", "args"],
|
|
645
|
+
properties: {
|
|
646
|
+
tool: { type: "string" },
|
|
647
|
+
args: { type: "array", items: { type: "string" } },
|
|
648
|
+
timeout: { type: "number" }
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}, async (request) => {
|
|
653
|
+
const { tool, args, timeout } = request.body;
|
|
654
|
+
const entry = server.registry.get(tool);
|
|
655
|
+
if (entry.type === "builtin") {
|
|
656
|
+
return handleBuiltin(entry.config, args);
|
|
657
|
+
}
|
|
658
|
+
const result = await server.executor.execute(entry, args, timeout);
|
|
659
|
+
return result;
|
|
660
|
+
});
|
|
661
|
+
server.post("/exec/mcp", {
|
|
662
|
+
config: {
|
|
663
|
+
rateLimit: { max: 60, timeWindow: "1 minute" }
|
|
664
|
+
},
|
|
665
|
+
schema: {
|
|
666
|
+
body: {
|
|
667
|
+
type: "object",
|
|
668
|
+
required: ["tool", "mcpTool", "args"],
|
|
669
|
+
properties: {
|
|
670
|
+
tool: { type: "string" },
|
|
671
|
+
mcpTool: { type: "string" },
|
|
672
|
+
args: { type: "object" }
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}, async (request) => {
|
|
677
|
+
const { tool, mcpTool, args } = request.body;
|
|
678
|
+
const entry = server.registry.get(tool);
|
|
679
|
+
if (entry.type !== "mcp") {
|
|
680
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, `Tool ${tool} is not an MCP tool`);
|
|
681
|
+
}
|
|
682
|
+
const start = Date.now();
|
|
683
|
+
const client = server.mcpPool.getClient(tool);
|
|
684
|
+
const result = await client.callTool(mcpTool, args);
|
|
685
|
+
return {
|
|
686
|
+
content: result.content,
|
|
687
|
+
isError: result.isError,
|
|
688
|
+
durationMs: Date.now() - start
|
|
689
|
+
};
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
async function handleBuiltin(config, args) {
|
|
693
|
+
const builtinConfig = config.config ?? {};
|
|
694
|
+
switch (config.builtin) {
|
|
695
|
+
case "filesystem": {
|
|
696
|
+
const allowedPaths = builtinConfig.allowedPaths ?? ["."];
|
|
697
|
+
const subcommand = args[0];
|
|
698
|
+
const target = args[1] ?? ".";
|
|
699
|
+
switch (subcommand) {
|
|
700
|
+
case "read":
|
|
701
|
+
return { stdout: await readFileSafe(target, allowedPaths), stderr: "", exitCode: 0, durationMs: 0 };
|
|
702
|
+
case "list":
|
|
703
|
+
return { stdout: JSON.stringify(await listDirSafe(target, allowedPaths), null, 2), stderr: "", exitCode: 0, durationMs: 0 };
|
|
704
|
+
case "search":
|
|
705
|
+
return { stdout: JSON.stringify(await searchFilesSafe(target, args[2] ?? ".*", allowedPaths), null, 2), stderr: "", exitCode: 0, durationMs: 0 };
|
|
706
|
+
default:
|
|
707
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, `Unknown filesystem subcommand: ${subcommand}. Use: read, list, search`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
case "shell": {
|
|
711
|
+
const allowlist = builtinConfig.allowlist ?? [];
|
|
712
|
+
const allowSudo = builtinConfig.allowSudo ?? false;
|
|
713
|
+
const command = args[0];
|
|
714
|
+
if (!command) {
|
|
715
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, "No command specified");
|
|
716
|
+
}
|
|
717
|
+
const result = await execCommandSafe(command, args.slice(1), { allowlist, allowSudo });
|
|
718
|
+
return { ...result, durationMs: 0 };
|
|
719
|
+
}
|
|
720
|
+
default:
|
|
721
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, `Unknown builtin: ${config.builtin}`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
var transfers = /* @__PURE__ */ new Map();
|
|
725
|
+
var TRANSFER_DIR = join2(tmpdir(), "gigai-transfers");
|
|
726
|
+
var TRANSFER_TTL = 60 * 60 * 1e3;
|
|
727
|
+
setInterval(async () => {
|
|
728
|
+
const now = Date.now();
|
|
729
|
+
for (const [id, entry] of transfers) {
|
|
730
|
+
if (entry.expiresAt < now) {
|
|
731
|
+
transfers.delete(id);
|
|
732
|
+
try {
|
|
733
|
+
await unlink(entry.path);
|
|
734
|
+
} catch {
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}, 6e4);
|
|
739
|
+
async function transferRoutes(server) {
|
|
740
|
+
await mkdir(TRANSFER_DIR, { recursive: true });
|
|
741
|
+
server.post("/transfer/upload", async (request) => {
|
|
742
|
+
const data = await request.file();
|
|
743
|
+
if (!data) {
|
|
744
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, "No file uploaded");
|
|
745
|
+
}
|
|
746
|
+
const id = nanoid(16);
|
|
747
|
+
const buffer = await data.toBuffer();
|
|
748
|
+
const filePath = join2(TRANSFER_DIR, id);
|
|
749
|
+
await writeFile(filePath, buffer);
|
|
750
|
+
const entry = {
|
|
751
|
+
id,
|
|
752
|
+
path: filePath,
|
|
753
|
+
filename: data.filename,
|
|
754
|
+
mimeType: data.mimetype,
|
|
755
|
+
expiresAt: Date.now() + TRANSFER_TTL
|
|
756
|
+
};
|
|
757
|
+
transfers.set(id, entry);
|
|
758
|
+
return {
|
|
759
|
+
id,
|
|
760
|
+
expiresAt: entry.expiresAt
|
|
761
|
+
};
|
|
762
|
+
});
|
|
763
|
+
server.get("/transfer/:id", async (request, reply) => {
|
|
764
|
+
const { id } = request.params;
|
|
765
|
+
const entry = transfers.get(id);
|
|
766
|
+
if (!entry) {
|
|
767
|
+
throw new GigaiError(ErrorCode.TRANSFER_NOT_FOUND, "Transfer not found");
|
|
768
|
+
}
|
|
769
|
+
if (entry.expiresAt < Date.now()) {
|
|
770
|
+
transfers.delete(id);
|
|
771
|
+
throw new GigaiError(ErrorCode.TRANSFER_EXPIRED, "Transfer expired");
|
|
772
|
+
}
|
|
773
|
+
const content = await readFile2(entry.path);
|
|
774
|
+
reply.type(entry.mimeType).send(content);
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
async function createServer(opts) {
|
|
778
|
+
const { config, dev = false } = opts;
|
|
779
|
+
const server = Fastify({
|
|
780
|
+
logger: {
|
|
781
|
+
level: dev ? "debug" : "info"
|
|
782
|
+
},
|
|
783
|
+
trustProxy: !dev
|
|
784
|
+
// Only trust proxy headers in production (behind HTTPS reverse proxy)
|
|
785
|
+
});
|
|
786
|
+
if (!dev) {
|
|
787
|
+
server.addHook("onRequest", async (request, _reply) => {
|
|
788
|
+
if (request.protocol !== "https") {
|
|
789
|
+
throw new GigaiError(ErrorCode.HTTPS_REQUIRED, "HTTPS is required");
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
await server.register(cors, { origin: false });
|
|
794
|
+
await server.register(rateLimit, { max: 100, timeWindow: "1 minute" });
|
|
795
|
+
await server.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
|
|
796
|
+
await server.register(authPlugin, { config });
|
|
797
|
+
await server.register(registryPlugin, { config });
|
|
798
|
+
await server.register(executorPlugin);
|
|
799
|
+
await server.register(mcpPlugin, { config });
|
|
800
|
+
await server.register(healthRoutes);
|
|
801
|
+
await server.register(toolRoutes);
|
|
802
|
+
await server.register(execRoutes);
|
|
803
|
+
await server.register(transferRoutes);
|
|
804
|
+
server.setErrorHandler((error, _request, reply) => {
|
|
805
|
+
if (error instanceof GigaiError) {
|
|
806
|
+
reply.status(error.statusCode).send(error.toJSON());
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if ("statusCode" in error && error.statusCode === 429) {
|
|
810
|
+
reply.status(429).send({
|
|
811
|
+
error: { code: ErrorCode.RATE_LIMITED, message: "Too many requests" }
|
|
812
|
+
});
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
server.log.error(error);
|
|
816
|
+
reply.status(500).send({
|
|
817
|
+
error: { code: ErrorCode.INTERNAL_ERROR, message: "Internal server error" }
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
return server;
|
|
821
|
+
}
|
|
822
|
+
var DEFAULT_CONFIG_PATH = "gigai.config.json";
|
|
823
|
+
async function loadConfig(path) {
|
|
824
|
+
const configPath = resolve3(path ?? DEFAULT_CONFIG_PATH);
|
|
825
|
+
const raw = await readFile3(configPath, "utf8");
|
|
826
|
+
const json = JSON.parse(raw);
|
|
827
|
+
return GigaiConfigSchema.parse(json);
|
|
828
|
+
}
|
|
829
|
+
async function runInit() {
|
|
830
|
+
console.log("\n gigai server setup\n");
|
|
831
|
+
const httpsProvider = await select({
|
|
832
|
+
message: "HTTPS provider:",
|
|
833
|
+
choices: [
|
|
834
|
+
{ name: "Tailscale Funnel (recommended)", value: "tailscale" },
|
|
835
|
+
{ name: "Cloudflare Tunnel", value: "cloudflare" },
|
|
836
|
+
{ name: "Let's Encrypt", value: "letsencrypt" },
|
|
837
|
+
{ name: "Manual (provide certs)", value: "manual" },
|
|
838
|
+
{ name: "None (dev mode only)", value: "none" }
|
|
839
|
+
]
|
|
840
|
+
});
|
|
841
|
+
let httpsConfig;
|
|
842
|
+
switch (httpsProvider) {
|
|
843
|
+
case "tailscale":
|
|
844
|
+
httpsConfig = {
|
|
845
|
+
provider: "tailscale",
|
|
846
|
+
funnelPort: 7443
|
|
847
|
+
};
|
|
848
|
+
console.log(" Will use Tailscale Funnel for HTTPS.");
|
|
849
|
+
break;
|
|
850
|
+
case "cloudflare": {
|
|
851
|
+
const tunnelName = await input({
|
|
852
|
+
message: "Cloudflare tunnel name:",
|
|
853
|
+
default: "gigai"
|
|
854
|
+
});
|
|
855
|
+
const domain = await input({
|
|
856
|
+
message: "Domain (optional):"
|
|
857
|
+
});
|
|
858
|
+
httpsConfig = {
|
|
859
|
+
provider: "cloudflare",
|
|
860
|
+
tunnelName,
|
|
861
|
+
...domain && { domain }
|
|
862
|
+
};
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
case "letsencrypt": {
|
|
866
|
+
const domain = await input({
|
|
867
|
+
message: "Domain name:",
|
|
868
|
+
required: true
|
|
869
|
+
});
|
|
870
|
+
const email = await input({
|
|
871
|
+
message: "Email for Let's Encrypt:",
|
|
872
|
+
required: true
|
|
873
|
+
});
|
|
874
|
+
httpsConfig = {
|
|
875
|
+
provider: "letsencrypt",
|
|
876
|
+
domain,
|
|
877
|
+
email
|
|
878
|
+
};
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
case "manual": {
|
|
882
|
+
const certPath = await input({
|
|
883
|
+
message: "Path to TLS certificate:",
|
|
884
|
+
required: true
|
|
885
|
+
});
|
|
886
|
+
const keyPath = await input({
|
|
887
|
+
message: "Path to TLS private key:",
|
|
888
|
+
required: true
|
|
889
|
+
});
|
|
890
|
+
httpsConfig = {
|
|
891
|
+
provider: "manual",
|
|
892
|
+
certPath,
|
|
893
|
+
keyPath
|
|
894
|
+
};
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
case "none":
|
|
898
|
+
default:
|
|
899
|
+
httpsConfig = void 0;
|
|
900
|
+
console.log(" No HTTPS \u2014 dev mode only.");
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
const portStr = await input({
|
|
904
|
+
message: "Server port:",
|
|
905
|
+
default: "7443"
|
|
906
|
+
});
|
|
907
|
+
const port = parseInt(portStr, 10);
|
|
908
|
+
const selectedBuiltins = await checkbox({
|
|
909
|
+
message: "Built-in tools to enable:",
|
|
910
|
+
choices: [
|
|
911
|
+
{ name: "Filesystem (read/list/search files)", value: "filesystem", checked: true },
|
|
912
|
+
{ name: "Shell (execute allowed commands)", value: "shell", checked: true }
|
|
913
|
+
]
|
|
914
|
+
});
|
|
915
|
+
const tools = [];
|
|
916
|
+
if (selectedBuiltins.includes("filesystem")) {
|
|
917
|
+
const pathsStr = await input({
|
|
918
|
+
message: "Allowed filesystem paths (comma-separated):",
|
|
919
|
+
default: process.env.HOME ?? "~"
|
|
920
|
+
});
|
|
921
|
+
const allowedPaths = pathsStr.split(",").map((p) => p.trim());
|
|
922
|
+
tools.push({
|
|
923
|
+
type: "builtin",
|
|
924
|
+
name: "fs",
|
|
925
|
+
builtin: "filesystem",
|
|
926
|
+
description: "Read, list, and search files",
|
|
927
|
+
config: { allowedPaths }
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
if (selectedBuiltins.includes("shell")) {
|
|
931
|
+
const allowlistStr = await input({
|
|
932
|
+
message: "Allowed shell commands (comma-separated):",
|
|
933
|
+
default: "ls,cat,head,tail,grep,find,wc,echo,date,whoami,pwd,git,npm,node"
|
|
934
|
+
});
|
|
935
|
+
const allowlist = allowlistStr.split(",").map((c) => c.trim());
|
|
936
|
+
const allowSudo = await confirm({
|
|
937
|
+
message: "Allow sudo?",
|
|
938
|
+
default: false
|
|
939
|
+
});
|
|
940
|
+
tools.push({
|
|
941
|
+
type: "builtin",
|
|
942
|
+
name: "shell",
|
|
943
|
+
builtin: "shell",
|
|
944
|
+
description: "Execute allowed shell commands",
|
|
945
|
+
config: { allowlist, allowSudo }
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
const encryptionKey = generateEncryptionKey();
|
|
949
|
+
const config = {
|
|
950
|
+
server: {
|
|
951
|
+
port,
|
|
952
|
+
host: "0.0.0.0",
|
|
953
|
+
...httpsConfig && { https: httpsConfig }
|
|
954
|
+
},
|
|
955
|
+
auth: {
|
|
956
|
+
encryptionKey,
|
|
957
|
+
pairingTtlSeconds: 300,
|
|
958
|
+
sessionTtlSeconds: 14400
|
|
959
|
+
},
|
|
960
|
+
tools
|
|
961
|
+
};
|
|
962
|
+
const configPath = resolve4("gigai.config.json");
|
|
963
|
+
await writeFile2(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
964
|
+
console.log(`
|
|
965
|
+
Config written to: ${configPath}`);
|
|
966
|
+
const skillDir = resolve4("gigai-skill");
|
|
967
|
+
await mkdir2(skillDir, { recursive: true });
|
|
968
|
+
const skillMd = `# gigai Skill
|
|
969
|
+
|
|
970
|
+
This skill gives you access to tools running on the user's machine via the gigai CLI.
|
|
971
|
+
|
|
972
|
+
## Setup
|
|
973
|
+
|
|
974
|
+
The gigai CLI is pre-installed. To use it:
|
|
975
|
+
|
|
976
|
+
1. Connect to the server: \`gigai connect\`
|
|
977
|
+
2. List available tools: \`gigai list\`
|
|
978
|
+
3. Get help on a tool: \`gigai help <tool-name>\`
|
|
979
|
+
4. Use a tool: \`gigai <tool-name> [args...]\`
|
|
980
|
+
|
|
981
|
+
## File Transfer
|
|
982
|
+
|
|
983
|
+
- Upload: \`gigai upload <file>\`
|
|
984
|
+
- Download: \`gigai download <id> <dest>\`
|
|
985
|
+
|
|
986
|
+
## Notes
|
|
987
|
+
|
|
988
|
+
- The connection is authenticated and encrypted
|
|
989
|
+
- Tools are scoped to what the user has configured
|
|
990
|
+
- If a command fails, check \`gigai status\` for connection info
|
|
991
|
+
`;
|
|
992
|
+
await writeFile2(join3(skillDir, "SKILL.md"), skillMd);
|
|
993
|
+
const skillConfig = {
|
|
994
|
+
server: "<YOUR_SERVER_URL>",
|
|
995
|
+
token: "<PASTE_ENCRYPTED_TOKEN_HERE>"
|
|
996
|
+
};
|
|
997
|
+
await writeFile2(join3(skillDir, "config.json"), JSON.stringify(skillConfig, null, 2) + "\n");
|
|
998
|
+
console.log(` Skill template written to: ${skillDir}/`);
|
|
999
|
+
const store = new AuthStore();
|
|
1000
|
+
const code = generatePairingCode(store, config.auth.pairingTtlSeconds);
|
|
1001
|
+
console.log(`
|
|
1002
|
+
Pairing code: ${code}`);
|
|
1003
|
+
console.log(` Expires in ${config.auth.pairingTtlSeconds / 60} minutes.`);
|
|
1004
|
+
console.log(`
|
|
1005
|
+
Start the server with: gigai server start${httpsConfig ? "" : " --dev"}`);
|
|
1006
|
+
store.destroy();
|
|
1007
|
+
}
|
|
1008
|
+
async function loadConfigFile(path) {
|
|
1009
|
+
const configPath = resolve5(path ?? "gigai.config.json");
|
|
1010
|
+
const raw = await readFile4(configPath, "utf8");
|
|
1011
|
+
const config = GigaiConfigSchema.parse(JSON.parse(raw));
|
|
1012
|
+
return { config, path: configPath };
|
|
1013
|
+
}
|
|
1014
|
+
async function saveConfig(config, path) {
|
|
1015
|
+
await writeFile3(path, JSON.stringify(config, null, 2) + "\n");
|
|
1016
|
+
}
|
|
1017
|
+
async function wrapCli() {
|
|
1018
|
+
const { config, path } = await loadConfigFile();
|
|
1019
|
+
const name = await input2({ message: "Tool name:", required: true });
|
|
1020
|
+
const command = await input2({ message: "Command:", required: true });
|
|
1021
|
+
const description = await input2({ message: "Description:", required: true });
|
|
1022
|
+
const argsStr = await input2({ message: "Default args (space-separated, optional):" });
|
|
1023
|
+
const timeoutStr = await input2({ message: "Timeout in ms (optional):", default: "30000" });
|
|
1024
|
+
const tool = {
|
|
1025
|
+
type: "cli",
|
|
1026
|
+
name,
|
|
1027
|
+
command,
|
|
1028
|
+
description,
|
|
1029
|
+
...argsStr && { args: argsStr.split(" ").filter(Boolean) },
|
|
1030
|
+
timeout: parseInt(timeoutStr, 10)
|
|
1031
|
+
};
|
|
1032
|
+
config.tools.push(tool);
|
|
1033
|
+
await saveConfig(config, path);
|
|
1034
|
+
console.log(`Added CLI tool: ${name}`);
|
|
1035
|
+
}
|
|
1036
|
+
async function wrapMcp() {
|
|
1037
|
+
const { config, path } = await loadConfigFile();
|
|
1038
|
+
const name = await input2({ message: "Tool name:", required: true });
|
|
1039
|
+
const command = await input2({ message: "MCP server command:", required: true });
|
|
1040
|
+
const description = await input2({ message: "Description:", required: true });
|
|
1041
|
+
const argsStr = await input2({ message: "Command args (space-separated, optional):" });
|
|
1042
|
+
const envStr = await input2({
|
|
1043
|
+
message: "Environment variables (KEY=VALUE, comma-separated, optional):"
|
|
1044
|
+
});
|
|
1045
|
+
const env = {};
|
|
1046
|
+
if (envStr) {
|
|
1047
|
+
for (const pair of envStr.split(",")) {
|
|
1048
|
+
const [k, ...v] = pair.trim().split("=");
|
|
1049
|
+
if (k && v.length > 0) {
|
|
1050
|
+
env[k.trim()] = v.join("=").trim();
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
const tool = {
|
|
1055
|
+
type: "mcp",
|
|
1056
|
+
name,
|
|
1057
|
+
command,
|
|
1058
|
+
description,
|
|
1059
|
+
...argsStr && { args: argsStr.split(" ").filter(Boolean) },
|
|
1060
|
+
...Object.keys(env).length > 0 && { env }
|
|
1061
|
+
};
|
|
1062
|
+
config.tools.push(tool);
|
|
1063
|
+
await saveConfig(config, path);
|
|
1064
|
+
console.log(`Added MCP tool: ${name}`);
|
|
1065
|
+
}
|
|
1066
|
+
async function wrapScript() {
|
|
1067
|
+
const { config, path } = await loadConfigFile();
|
|
1068
|
+
const name = await input2({ message: "Tool name:", required: true });
|
|
1069
|
+
const scriptPath = await input2({ message: "Script path:", required: true });
|
|
1070
|
+
const description = await input2({ message: "Description:", required: true });
|
|
1071
|
+
const interpreter = await input2({ message: "Interpreter:", default: "node" });
|
|
1072
|
+
const tool = {
|
|
1073
|
+
type: "script",
|
|
1074
|
+
name,
|
|
1075
|
+
path: scriptPath,
|
|
1076
|
+
description,
|
|
1077
|
+
interpreter
|
|
1078
|
+
};
|
|
1079
|
+
config.tools.push(tool);
|
|
1080
|
+
await saveConfig(config, path);
|
|
1081
|
+
console.log(`Added script tool: ${name}`);
|
|
1082
|
+
}
|
|
1083
|
+
async function wrapImport(configFilePath) {
|
|
1084
|
+
const { config, path } = await loadConfigFile();
|
|
1085
|
+
const raw = await readFile4(resolve5(configFilePath), "utf8");
|
|
1086
|
+
const desktopConfig = JSON.parse(raw);
|
|
1087
|
+
const mcpServers = desktopConfig.mcpServers ?? {};
|
|
1088
|
+
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
1089
|
+
const sc = serverConfig;
|
|
1090
|
+
const tool = {
|
|
1091
|
+
type: "mcp",
|
|
1092
|
+
name: serverName,
|
|
1093
|
+
command: sc.command,
|
|
1094
|
+
description: `Imported MCP server: ${serverName}`,
|
|
1095
|
+
...sc.args && { args: sc.args },
|
|
1096
|
+
...sc.env && { env: sc.env }
|
|
1097
|
+
};
|
|
1098
|
+
config.tools.push(tool);
|
|
1099
|
+
console.log(` Imported: ${serverName}`);
|
|
1100
|
+
}
|
|
1101
|
+
await saveConfig(config, path);
|
|
1102
|
+
console.log(`
|
|
1103
|
+
Imported ${Object.keys(mcpServers).length} MCP servers.`);
|
|
1104
|
+
}
|
|
1105
|
+
async function unwrapTool(name) {
|
|
1106
|
+
const { config, path } = await loadConfigFile();
|
|
1107
|
+
const idx = config.tools.findIndex((t) => t.name === name);
|
|
1108
|
+
if (idx === -1) {
|
|
1109
|
+
console.error(`Tool not found: ${name}`);
|
|
1110
|
+
process.exitCode = 1;
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
config.tools.splice(idx, 1);
|
|
1114
|
+
await saveConfig(config, path);
|
|
1115
|
+
console.log(`Removed tool: ${name}`);
|
|
1116
|
+
}
|
|
1117
|
+
async function generateServerPairingCode(configPath) {
|
|
1118
|
+
const { config } = await loadConfigFile(configPath);
|
|
1119
|
+
const { AuthStore: AuthStore2 } = await import("./store-Y4V3TOYJ-GKOB6ANA.js");
|
|
1120
|
+
const { generatePairingCode: generatePairingCode2 } = await import("./pairing-IGMDVOIZ-RA7GNFU7.js");
|
|
1121
|
+
const store = new AuthStore2();
|
|
1122
|
+
const code = generatePairingCode2(store, config.auth.pairingTtlSeconds);
|
|
1123
|
+
console.log(`
|
|
1124
|
+
Pairing code: ${code}`);
|
|
1125
|
+
console.log(`Expires in ${config.auth.pairingTtlSeconds / 60} minutes.`);
|
|
1126
|
+
console.log(`
|
|
1127
|
+
Note: The server must be running for the client to use this code.`);
|
|
1128
|
+
console.log(`For a live pairing code, use the server's /auth/pair/generate endpoint.`);
|
|
1129
|
+
store.destroy();
|
|
1130
|
+
}
|
|
1131
|
+
async function startServer() {
|
|
1132
|
+
const { values } = parseArgs({
|
|
1133
|
+
options: {
|
|
1134
|
+
config: { type: "string", short: "c" },
|
|
1135
|
+
dev: { type: "boolean", default: false }
|
|
1136
|
+
},
|
|
1137
|
+
strict: false
|
|
1138
|
+
});
|
|
1139
|
+
const config = await loadConfig(values.config);
|
|
1140
|
+
const server = await createServer({ config, dev: values.dev });
|
|
1141
|
+
const port = config.server.port;
|
|
1142
|
+
const host = config.server.host;
|
|
1143
|
+
await server.listen({ port, host });
|
|
1144
|
+
server.log.info(`gigai server listening on ${host}:${port}`);
|
|
1145
|
+
const shutdown = async () => {
|
|
1146
|
+
server.log.info("Shutting down...");
|
|
1147
|
+
await server.close();
|
|
1148
|
+
process.exit(0);
|
|
1149
|
+
};
|
|
1150
|
+
process.on("SIGTERM", shutdown);
|
|
1151
|
+
process.on("SIGINT", shutdown);
|
|
1152
|
+
}
|
|
1153
|
+
export {
|
|
1154
|
+
createServer,
|
|
1155
|
+
generateServerPairingCode,
|
|
1156
|
+
loadConfig,
|
|
1157
|
+
runInit,
|
|
1158
|
+
startServer,
|
|
1159
|
+
unwrapTool,
|
|
1160
|
+
wrapCli,
|
|
1161
|
+
wrapImport,
|
|
1162
|
+
wrapMcp,
|
|
1163
|
+
wrapScript
|
|
1164
|
+
};
|