@schuttdev/gigai 0.2.6 → 0.2.8
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/dist-Z7XWWQBF.js +1889 -0
- package/dist/index.js +144 -1045
- package/package.json +1 -4
|
@@ -0,0 +1,1889 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../server/dist/index.mjs
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
import Fastify from "fastify";
|
|
6
|
+
import cors from "@fastify/cors";
|
|
7
|
+
import rateLimit from "@fastify/rate-limit";
|
|
8
|
+
import multipart from "@fastify/multipart";
|
|
9
|
+
|
|
10
|
+
// ../shared/dist/index.mjs
|
|
11
|
+
import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
var ALGORITHM = "aes-256-gcm";
|
|
14
|
+
var IV_LENGTH = 12;
|
|
15
|
+
var TAG_LENGTH = 16;
|
|
16
|
+
function encrypt(payload, key) {
|
|
17
|
+
const keyBuffer = Buffer.from(key, "hex");
|
|
18
|
+
if (keyBuffer.length !== 32) {
|
|
19
|
+
throw new Error("Encryption key must be 32 bytes (64 hex chars)");
|
|
20
|
+
}
|
|
21
|
+
const iv = randomBytes(IV_LENGTH);
|
|
22
|
+
const cipher = createCipheriv(ALGORITHM, keyBuffer, iv, {
|
|
23
|
+
authTagLength: TAG_LENGTH
|
|
24
|
+
});
|
|
25
|
+
const plaintext = JSON.stringify(payload);
|
|
26
|
+
const encrypted = Buffer.concat([
|
|
27
|
+
cipher.update(plaintext, "utf8"),
|
|
28
|
+
cipher.final()
|
|
29
|
+
]);
|
|
30
|
+
const tag = cipher.getAuthTag();
|
|
31
|
+
return {
|
|
32
|
+
iv: iv.toString("base64"),
|
|
33
|
+
ciphertext: encrypted.toString("base64"),
|
|
34
|
+
tag: tag.toString("base64")
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function decrypt(encrypted, key) {
|
|
38
|
+
const keyBuffer = Buffer.from(key, "hex");
|
|
39
|
+
if (keyBuffer.length !== 32) {
|
|
40
|
+
throw new Error("Encryption key must be 32 bytes (64 hex chars)");
|
|
41
|
+
}
|
|
42
|
+
const iv = Buffer.from(encrypted.iv, "base64");
|
|
43
|
+
const ciphertext = Buffer.from(encrypted.ciphertext, "base64");
|
|
44
|
+
const tag = Buffer.from(encrypted.tag, "base64");
|
|
45
|
+
const decipher = createDecipheriv(ALGORITHM, keyBuffer, iv, {
|
|
46
|
+
authTagLength: TAG_LENGTH
|
|
47
|
+
});
|
|
48
|
+
decipher.setAuthTag(tag);
|
|
49
|
+
const decrypted = Buffer.concat([
|
|
50
|
+
decipher.update(ciphertext),
|
|
51
|
+
decipher.final()
|
|
52
|
+
]);
|
|
53
|
+
return JSON.parse(decrypted.toString("utf8"));
|
|
54
|
+
}
|
|
55
|
+
function generateEncryptionKey() {
|
|
56
|
+
return randomBytes(32).toString("hex");
|
|
57
|
+
}
|
|
58
|
+
var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
|
|
59
|
+
ErrorCode2["PAIRING_EXPIRED"] = "PAIRING_EXPIRED";
|
|
60
|
+
ErrorCode2["PAIRING_INVALID"] = "PAIRING_INVALID";
|
|
61
|
+
ErrorCode2["PAIRING_USED"] = "PAIRING_USED";
|
|
62
|
+
ErrorCode2["TOKEN_INVALID"] = "TOKEN_INVALID";
|
|
63
|
+
ErrorCode2["TOKEN_DECRYPT_FAILED"] = "TOKEN_DECRYPT_FAILED";
|
|
64
|
+
ErrorCode2["ORG_MISMATCH"] = "ORG_MISMATCH";
|
|
65
|
+
ErrorCode2["SESSION_EXPIRED"] = "SESSION_EXPIRED";
|
|
66
|
+
ErrorCode2["SESSION_INVALID"] = "SESSION_INVALID";
|
|
67
|
+
ErrorCode2["AUTH_REQUIRED"] = "AUTH_REQUIRED";
|
|
68
|
+
ErrorCode2["TOOL_NOT_FOUND"] = "TOOL_NOT_FOUND";
|
|
69
|
+
ErrorCode2["EXEC_TIMEOUT"] = "EXEC_TIMEOUT";
|
|
70
|
+
ErrorCode2["EXEC_FAILED"] = "EXEC_FAILED";
|
|
71
|
+
ErrorCode2["HTTPS_REQUIRED"] = "HTTPS_REQUIRED";
|
|
72
|
+
ErrorCode2["RATE_LIMITED"] = "RATE_LIMITED";
|
|
73
|
+
ErrorCode2["VALIDATION_ERROR"] = "VALIDATION_ERROR";
|
|
74
|
+
ErrorCode2["INTERNAL_ERROR"] = "INTERNAL_ERROR";
|
|
75
|
+
ErrorCode2["MCP_ERROR"] = "MCP_ERROR";
|
|
76
|
+
ErrorCode2["MCP_NOT_CONNECTED"] = "MCP_NOT_CONNECTED";
|
|
77
|
+
ErrorCode2["TRANSFER_NOT_FOUND"] = "TRANSFER_NOT_FOUND";
|
|
78
|
+
ErrorCode2["TRANSFER_EXPIRED"] = "TRANSFER_EXPIRED";
|
|
79
|
+
ErrorCode2["PATH_NOT_ALLOWED"] = "PATH_NOT_ALLOWED";
|
|
80
|
+
ErrorCode2["COMMAND_NOT_ALLOWED"] = "COMMAND_NOT_ALLOWED";
|
|
81
|
+
return ErrorCode2;
|
|
82
|
+
})(ErrorCode || {});
|
|
83
|
+
var STATUS_CODES = {
|
|
84
|
+
[
|
|
85
|
+
"PAIRING_EXPIRED"
|
|
86
|
+
/* PAIRING_EXPIRED */
|
|
87
|
+
]: 410,
|
|
88
|
+
[
|
|
89
|
+
"PAIRING_INVALID"
|
|
90
|
+
/* PAIRING_INVALID */
|
|
91
|
+
]: 400,
|
|
92
|
+
[
|
|
93
|
+
"PAIRING_USED"
|
|
94
|
+
/* PAIRING_USED */
|
|
95
|
+
]: 409,
|
|
96
|
+
[
|
|
97
|
+
"TOKEN_INVALID"
|
|
98
|
+
/* TOKEN_INVALID */
|
|
99
|
+
]: 401,
|
|
100
|
+
[
|
|
101
|
+
"TOKEN_DECRYPT_FAILED"
|
|
102
|
+
/* TOKEN_DECRYPT_FAILED */
|
|
103
|
+
]: 401,
|
|
104
|
+
[
|
|
105
|
+
"ORG_MISMATCH"
|
|
106
|
+
/* ORG_MISMATCH */
|
|
107
|
+
]: 403,
|
|
108
|
+
[
|
|
109
|
+
"SESSION_EXPIRED"
|
|
110
|
+
/* SESSION_EXPIRED */
|
|
111
|
+
]: 401,
|
|
112
|
+
[
|
|
113
|
+
"SESSION_INVALID"
|
|
114
|
+
/* SESSION_INVALID */
|
|
115
|
+
]: 401,
|
|
116
|
+
[
|
|
117
|
+
"AUTH_REQUIRED"
|
|
118
|
+
/* AUTH_REQUIRED */
|
|
119
|
+
]: 401,
|
|
120
|
+
[
|
|
121
|
+
"TOOL_NOT_FOUND"
|
|
122
|
+
/* TOOL_NOT_FOUND */
|
|
123
|
+
]: 404,
|
|
124
|
+
[
|
|
125
|
+
"EXEC_TIMEOUT"
|
|
126
|
+
/* EXEC_TIMEOUT */
|
|
127
|
+
]: 408,
|
|
128
|
+
[
|
|
129
|
+
"EXEC_FAILED"
|
|
130
|
+
/* EXEC_FAILED */
|
|
131
|
+
]: 500,
|
|
132
|
+
[
|
|
133
|
+
"HTTPS_REQUIRED"
|
|
134
|
+
/* HTTPS_REQUIRED */
|
|
135
|
+
]: 403,
|
|
136
|
+
[
|
|
137
|
+
"RATE_LIMITED"
|
|
138
|
+
/* RATE_LIMITED */
|
|
139
|
+
]: 429,
|
|
140
|
+
[
|
|
141
|
+
"VALIDATION_ERROR"
|
|
142
|
+
/* VALIDATION_ERROR */
|
|
143
|
+
]: 400,
|
|
144
|
+
[
|
|
145
|
+
"INTERNAL_ERROR"
|
|
146
|
+
/* INTERNAL_ERROR */
|
|
147
|
+
]: 500,
|
|
148
|
+
[
|
|
149
|
+
"MCP_ERROR"
|
|
150
|
+
/* MCP_ERROR */
|
|
151
|
+
]: 502,
|
|
152
|
+
[
|
|
153
|
+
"MCP_NOT_CONNECTED"
|
|
154
|
+
/* MCP_NOT_CONNECTED */
|
|
155
|
+
]: 503,
|
|
156
|
+
[
|
|
157
|
+
"TRANSFER_NOT_FOUND"
|
|
158
|
+
/* TRANSFER_NOT_FOUND */
|
|
159
|
+
]: 404,
|
|
160
|
+
[
|
|
161
|
+
"TRANSFER_EXPIRED"
|
|
162
|
+
/* TRANSFER_EXPIRED */
|
|
163
|
+
]: 410,
|
|
164
|
+
[
|
|
165
|
+
"PATH_NOT_ALLOWED"
|
|
166
|
+
/* PATH_NOT_ALLOWED */
|
|
167
|
+
]: 403,
|
|
168
|
+
[
|
|
169
|
+
"COMMAND_NOT_ALLOWED"
|
|
170
|
+
/* COMMAND_NOT_ALLOWED */
|
|
171
|
+
]: 403
|
|
172
|
+
};
|
|
173
|
+
var GigaiError = class extends Error {
|
|
174
|
+
code;
|
|
175
|
+
statusCode;
|
|
176
|
+
details;
|
|
177
|
+
constructor(code, message, details) {
|
|
178
|
+
super(message);
|
|
179
|
+
this.name = "GigaiError";
|
|
180
|
+
this.code = code;
|
|
181
|
+
this.statusCode = STATUS_CODES[code];
|
|
182
|
+
this.details = details;
|
|
183
|
+
}
|
|
184
|
+
toJSON() {
|
|
185
|
+
return {
|
|
186
|
+
error: {
|
|
187
|
+
code: this.code,
|
|
188
|
+
message: this.message,
|
|
189
|
+
...this.details !== void 0 && { details: this.details }
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
var TailscaleHttpsConfigSchema = z.object({
|
|
195
|
+
provider: z.literal("tailscale"),
|
|
196
|
+
funnelPort: z.number().optional()
|
|
197
|
+
});
|
|
198
|
+
var CloudflareHttpsConfigSchema = z.object({
|
|
199
|
+
provider: z.literal("cloudflare"),
|
|
200
|
+
tunnelName: z.string(),
|
|
201
|
+
domain: z.string().optional()
|
|
202
|
+
});
|
|
203
|
+
var ManualHttpsConfigSchema = z.object({
|
|
204
|
+
provider: z.literal("manual"),
|
|
205
|
+
certPath: z.string(),
|
|
206
|
+
keyPath: z.string()
|
|
207
|
+
});
|
|
208
|
+
var HttpsConfigSchema = z.discriminatedUnion("provider", [
|
|
209
|
+
TailscaleHttpsConfigSchema,
|
|
210
|
+
CloudflareHttpsConfigSchema,
|
|
211
|
+
ManualHttpsConfigSchema
|
|
212
|
+
]);
|
|
213
|
+
var CliToolConfigSchema = z.object({
|
|
214
|
+
type: z.literal("cli"),
|
|
215
|
+
name: z.string(),
|
|
216
|
+
command: z.string(),
|
|
217
|
+
args: z.array(z.string()).optional(),
|
|
218
|
+
description: z.string(),
|
|
219
|
+
timeout: z.number().optional(),
|
|
220
|
+
cwd: z.string().optional(),
|
|
221
|
+
env: z.record(z.string()).optional()
|
|
222
|
+
});
|
|
223
|
+
var McpToolConfigSchema = z.object({
|
|
224
|
+
type: z.literal("mcp"),
|
|
225
|
+
name: z.string(),
|
|
226
|
+
command: z.string(),
|
|
227
|
+
args: z.array(z.string()).optional(),
|
|
228
|
+
description: z.string(),
|
|
229
|
+
env: z.record(z.string()).optional()
|
|
230
|
+
});
|
|
231
|
+
var ScriptToolConfigSchema = z.object({
|
|
232
|
+
type: z.literal("script"),
|
|
233
|
+
name: z.string(),
|
|
234
|
+
path: z.string(),
|
|
235
|
+
description: z.string(),
|
|
236
|
+
timeout: z.number().optional(),
|
|
237
|
+
interpreter: z.string().optional()
|
|
238
|
+
});
|
|
239
|
+
var BuiltinToolConfigSchema = z.object({
|
|
240
|
+
type: z.literal("builtin"),
|
|
241
|
+
name: z.string(),
|
|
242
|
+
builtin: z.enum(["filesystem", "shell"]),
|
|
243
|
+
description: z.string(),
|
|
244
|
+
config: z.record(z.unknown()).optional()
|
|
245
|
+
});
|
|
246
|
+
var ToolConfigSchema = z.discriminatedUnion("type", [
|
|
247
|
+
CliToolConfigSchema,
|
|
248
|
+
McpToolConfigSchema,
|
|
249
|
+
ScriptToolConfigSchema,
|
|
250
|
+
BuiltinToolConfigSchema
|
|
251
|
+
]);
|
|
252
|
+
var AuthConfigSchema = z.object({
|
|
253
|
+
encryptionKey: z.string().length(64),
|
|
254
|
+
pairingTtlSeconds: z.number().default(300),
|
|
255
|
+
sessionTtlSeconds: z.number().default(14400)
|
|
256
|
+
});
|
|
257
|
+
var ServerConfigSchema = z.object({
|
|
258
|
+
port: z.number().default(7443),
|
|
259
|
+
host: z.string().default("0.0.0.0"),
|
|
260
|
+
https: HttpsConfigSchema.optional()
|
|
261
|
+
});
|
|
262
|
+
var GigaiConfigSchema = z.object({
|
|
263
|
+
serverName: z.string().optional(),
|
|
264
|
+
server: ServerConfigSchema,
|
|
265
|
+
auth: AuthConfigSchema,
|
|
266
|
+
tools: z.array(ToolConfigSchema).default([])
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ../server/dist/index.mjs
|
|
270
|
+
import fp from "fastify-plugin";
|
|
271
|
+
import { nanoid } from "nanoid";
|
|
272
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
273
|
+
import { hostname } from "os";
|
|
274
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
275
|
+
import fp2 from "fastify-plugin";
|
|
276
|
+
import fp3 from "fastify-plugin";
|
|
277
|
+
import { spawn } from "child_process";
|
|
278
|
+
import fp4 from "fastify-plugin";
|
|
279
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
280
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
281
|
+
import { readFileSync } from "fs";
|
|
282
|
+
import { resolve } from "path";
|
|
283
|
+
import { readFile as fsReadFile, readdir } from "fs/promises";
|
|
284
|
+
import { resolve as resolve2, relative, join } from "path";
|
|
285
|
+
import { realpath } from "fs/promises";
|
|
286
|
+
import { spawn as spawn2 } from "child_process";
|
|
287
|
+
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
|
|
288
|
+
import { join as join2 } from "path";
|
|
289
|
+
import { tmpdir } from "os";
|
|
290
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
291
|
+
import { execFile, spawn as spawn3 } from "child_process";
|
|
292
|
+
import { promisify } from "util";
|
|
293
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
294
|
+
import { resolve as resolve3 } from "path";
|
|
295
|
+
import { spawn as spawn4 } from "child_process";
|
|
296
|
+
import { spawn as spawn5 } from "child_process";
|
|
297
|
+
import { input, select, checkbox, confirm } from "@inquirer/prompts";
|
|
298
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
299
|
+
import { resolve as resolve4 } from "path";
|
|
300
|
+
import { execFile as execFile2, spawn as spawn6 } from "child_process";
|
|
301
|
+
import { promisify as promisify2 } from "util";
|
|
302
|
+
import { input as input2 } from "@inquirer/prompts";
|
|
303
|
+
import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
304
|
+
import { resolve as resolve5 } from "path";
|
|
305
|
+
import { writeFile as writeFile4 } from "fs/promises";
|
|
306
|
+
import { resolve as resolve6, join as join3 } from "path";
|
|
307
|
+
import { homedir, platform } from "os";
|
|
308
|
+
import { execFile as execFile3 } from "child_process";
|
|
309
|
+
import { promisify as promisify3 } from "util";
|
|
310
|
+
var AuthStore = class {
|
|
311
|
+
pairingCodes = /* @__PURE__ */ new Map();
|
|
312
|
+
sessions = /* @__PURE__ */ new Map();
|
|
313
|
+
cleanupInterval;
|
|
314
|
+
constructor() {
|
|
315
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 6e4);
|
|
316
|
+
}
|
|
317
|
+
// Pairing codes
|
|
318
|
+
addPairingCode(code, ttlSeconds) {
|
|
319
|
+
const entry = {
|
|
320
|
+
code,
|
|
321
|
+
expiresAt: Date.now() + ttlSeconds * 1e3,
|
|
322
|
+
used: false
|
|
323
|
+
};
|
|
324
|
+
this.pairingCodes.set(code, entry);
|
|
325
|
+
return entry;
|
|
326
|
+
}
|
|
327
|
+
getPairingCode(code) {
|
|
328
|
+
return this.pairingCodes.get(code);
|
|
329
|
+
}
|
|
330
|
+
markPairingCodeUsed(code) {
|
|
331
|
+
const entry = this.pairingCodes.get(code);
|
|
332
|
+
if (entry) {
|
|
333
|
+
entry.used = true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Sessions
|
|
337
|
+
createSession(orgUuid, ttlSeconds) {
|
|
338
|
+
const session = {
|
|
339
|
+
id: nanoid(32),
|
|
340
|
+
orgUuid,
|
|
341
|
+
token: nanoid(48),
|
|
342
|
+
expiresAt: Date.now() + ttlSeconds * 1e3,
|
|
343
|
+
lastActivity: Date.now()
|
|
344
|
+
};
|
|
345
|
+
this.sessions.set(session.token, session);
|
|
346
|
+
return session;
|
|
347
|
+
}
|
|
348
|
+
getSession(token) {
|
|
349
|
+
const session = this.sessions.get(token);
|
|
350
|
+
if (session) {
|
|
351
|
+
session.lastActivity = Date.now();
|
|
352
|
+
}
|
|
353
|
+
return session;
|
|
354
|
+
}
|
|
355
|
+
// Cleanup
|
|
356
|
+
cleanup() {
|
|
357
|
+
const now = Date.now();
|
|
358
|
+
for (const [key, code] of this.pairingCodes) {
|
|
359
|
+
if (code.expiresAt < now) {
|
|
360
|
+
this.pairingCodes.delete(key);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const [key, session] of this.sessions) {
|
|
364
|
+
if (session.expiresAt < now) {
|
|
365
|
+
this.sessions.delete(key);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
destroy() {
|
|
370
|
+
clearInterval(this.cleanupInterval);
|
|
371
|
+
this.pairingCodes.clear();
|
|
372
|
+
this.sessions.clear();
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
function connectWithToken(store, encryptedToken, orgUuid, encryptionKey, sessionTtlSeconds) {
|
|
376
|
+
let payload;
|
|
377
|
+
try {
|
|
378
|
+
payload = JSON.parse(encryptedToken);
|
|
379
|
+
} catch {
|
|
380
|
+
throw new GigaiError(ErrorCode.TOKEN_INVALID, "Invalid token format");
|
|
381
|
+
}
|
|
382
|
+
let decrypted;
|
|
383
|
+
try {
|
|
384
|
+
decrypted = decrypt(payload, encryptionKey);
|
|
385
|
+
} catch {
|
|
386
|
+
throw new GigaiError(ErrorCode.TOKEN_DECRYPT_FAILED, "Failed to decrypt token");
|
|
387
|
+
}
|
|
388
|
+
if (decrypted.orgUuid !== orgUuid) {
|
|
389
|
+
throw new GigaiError(ErrorCode.ORG_MISMATCH, "Organization UUID mismatch");
|
|
390
|
+
}
|
|
391
|
+
return store.createSession(orgUuid, sessionTtlSeconds);
|
|
392
|
+
}
|
|
393
|
+
function validateSession(store, token) {
|
|
394
|
+
const session = store.getSession(token);
|
|
395
|
+
if (!session) {
|
|
396
|
+
throw new GigaiError(ErrorCode.SESSION_INVALID, "Invalid session token");
|
|
397
|
+
}
|
|
398
|
+
if (session.expiresAt < Date.now()) {
|
|
399
|
+
throw new GigaiError(ErrorCode.SESSION_EXPIRED, "Session expired");
|
|
400
|
+
}
|
|
401
|
+
return session;
|
|
402
|
+
}
|
|
403
|
+
function createAuthMiddleware(store) {
|
|
404
|
+
return async function authMiddleware(request, _reply) {
|
|
405
|
+
const authHeader = request.headers.authorization;
|
|
406
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
407
|
+
throw new GigaiError(ErrorCode.AUTH_REQUIRED, "Authorization header required");
|
|
408
|
+
}
|
|
409
|
+
const token = authHeader.slice(7);
|
|
410
|
+
const session = validateSession(store, token);
|
|
411
|
+
request.session = session;
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
var PAIRING_CODE_LENGTH = 8;
|
|
415
|
+
var PAIRING_CODE_CHARS = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
416
|
+
function generatePairingCode(store, ttlSeconds) {
|
|
417
|
+
let code = "";
|
|
418
|
+
const bytes = nanoid2(PAIRING_CODE_LENGTH);
|
|
419
|
+
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
|
|
420
|
+
code += PAIRING_CODE_CHARS[bytes.charCodeAt(i) % PAIRING_CODE_CHARS.length];
|
|
421
|
+
}
|
|
422
|
+
store.addPairingCode(code, ttlSeconds);
|
|
423
|
+
return code;
|
|
424
|
+
}
|
|
425
|
+
function validateAndPair(store, code, orgUuid, encryptionKey, serverFingerprint) {
|
|
426
|
+
const entry = store.getPairingCode(code.toUpperCase());
|
|
427
|
+
if (!entry) {
|
|
428
|
+
throw new GigaiError(ErrorCode.PAIRING_INVALID, "Invalid pairing code");
|
|
429
|
+
}
|
|
430
|
+
if (entry.used) {
|
|
431
|
+
throw new GigaiError(ErrorCode.PAIRING_USED, "Pairing code already used");
|
|
432
|
+
}
|
|
433
|
+
if (entry.expiresAt < Date.now()) {
|
|
434
|
+
throw new GigaiError(ErrorCode.PAIRING_EXPIRED, "Pairing code expired");
|
|
435
|
+
}
|
|
436
|
+
store.markPairingCodeUsed(code.toUpperCase());
|
|
437
|
+
return encrypt(
|
|
438
|
+
{ orgUuid, serverFingerprint, createdAt: Date.now() },
|
|
439
|
+
encryptionKey
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
function registerAuthRoutes(server, store, config) {
|
|
443
|
+
const serverFingerprint = randomBytes2(16).toString("hex");
|
|
444
|
+
const serverName = config.serverName ?? hostname();
|
|
445
|
+
server.post("/auth/pair", {
|
|
446
|
+
config: {
|
|
447
|
+
rateLimit: { max: 5, timeWindow: "1 hour" }
|
|
448
|
+
},
|
|
449
|
+
schema: {
|
|
450
|
+
body: {
|
|
451
|
+
type: "object",
|
|
452
|
+
required: ["pairingCode", "orgUuid"],
|
|
453
|
+
properties: {
|
|
454
|
+
pairingCode: { type: "string" },
|
|
455
|
+
orgUuid: { type: "string" }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}, async (request) => {
|
|
460
|
+
const { pairingCode, orgUuid } = request.body;
|
|
461
|
+
const encryptedToken = validateAndPair(
|
|
462
|
+
store,
|
|
463
|
+
pairingCode,
|
|
464
|
+
orgUuid,
|
|
465
|
+
config.auth.encryptionKey,
|
|
466
|
+
serverFingerprint
|
|
467
|
+
);
|
|
468
|
+
return { encryptedToken: JSON.stringify(encryptedToken), serverName };
|
|
469
|
+
});
|
|
470
|
+
server.post("/auth/connect", {
|
|
471
|
+
config: {
|
|
472
|
+
rateLimit: { max: 10, timeWindow: "1 minute" }
|
|
473
|
+
},
|
|
474
|
+
schema: {
|
|
475
|
+
body: {
|
|
476
|
+
type: "object",
|
|
477
|
+
required: ["encryptedToken", "orgUuid"],
|
|
478
|
+
properties: {
|
|
479
|
+
encryptedToken: { type: "string" },
|
|
480
|
+
orgUuid: { type: "string" }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}, async (request) => {
|
|
485
|
+
const { encryptedToken, orgUuid } = request.body;
|
|
486
|
+
const session = connectWithToken(
|
|
487
|
+
store,
|
|
488
|
+
encryptedToken,
|
|
489
|
+
orgUuid,
|
|
490
|
+
config.auth.encryptionKey,
|
|
491
|
+
config.auth.sessionTtlSeconds
|
|
492
|
+
);
|
|
493
|
+
return {
|
|
494
|
+
sessionToken: session.token,
|
|
495
|
+
expiresAt: session.expiresAt
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
server.get("/auth/pair/generate", {
|
|
499
|
+
config: { skipAuth: true }
|
|
500
|
+
}, async (request) => {
|
|
501
|
+
const remoteAddr = request.ip;
|
|
502
|
+
if (remoteAddr !== "127.0.0.1" && remoteAddr !== "::1" && remoteAddr !== "::ffff:127.0.0.1") {
|
|
503
|
+
throw new GigaiError(ErrorCode.AUTH_REQUIRED, "Pairing code generation is only available from localhost");
|
|
504
|
+
}
|
|
505
|
+
const code = generatePairingCode(store, config.auth.pairingTtlSeconds);
|
|
506
|
+
return { code, expiresIn: config.auth.pairingTtlSeconds };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
var authPlugin = fp(async (server, opts) => {
|
|
510
|
+
const store = new AuthStore();
|
|
511
|
+
const authMiddleware = createAuthMiddleware(store);
|
|
512
|
+
server.decorate("authStore", store);
|
|
513
|
+
server.addHook("onRequest", async (request, reply) => {
|
|
514
|
+
const routeConfig = request.routeOptions?.config ?? {};
|
|
515
|
+
if (routeConfig.skipAuth) return;
|
|
516
|
+
if (request.url === "/health") return;
|
|
517
|
+
if (request.url.startsWith("/auth/")) return;
|
|
518
|
+
await authMiddleware(request, reply);
|
|
519
|
+
});
|
|
520
|
+
registerAuthRoutes(server, store, opts.config);
|
|
521
|
+
server.addHook("onClose", async () => {
|
|
522
|
+
store.destroy();
|
|
523
|
+
});
|
|
524
|
+
}, { name: "auth" });
|
|
525
|
+
var ToolRegistry = class {
|
|
526
|
+
tools = /* @__PURE__ */ new Map();
|
|
527
|
+
loadFromConfig(tools) {
|
|
528
|
+
for (const tool of tools) {
|
|
529
|
+
this.register(tool);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
register(config) {
|
|
533
|
+
const entry = { type: config.type, config };
|
|
534
|
+
this.tools.set(config.name, entry);
|
|
535
|
+
}
|
|
536
|
+
get(name) {
|
|
537
|
+
const entry = this.tools.get(name);
|
|
538
|
+
if (!entry) {
|
|
539
|
+
throw new GigaiError(ErrorCode.TOOL_NOT_FOUND, `Tool not found: ${name}`);
|
|
540
|
+
}
|
|
541
|
+
return entry;
|
|
542
|
+
}
|
|
543
|
+
has(name) {
|
|
544
|
+
return this.tools.has(name);
|
|
545
|
+
}
|
|
546
|
+
list() {
|
|
547
|
+
return Array.from(this.tools.values()).map((entry) => ({
|
|
548
|
+
name: entry.config.name,
|
|
549
|
+
type: entry.config.type,
|
|
550
|
+
description: entry.config.description
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
getDetail(name) {
|
|
554
|
+
const entry = this.get(name);
|
|
555
|
+
const detail = {
|
|
556
|
+
name: entry.config.name,
|
|
557
|
+
type: entry.config.type,
|
|
558
|
+
description: entry.config.description
|
|
559
|
+
};
|
|
560
|
+
if (entry.type === "cli") {
|
|
561
|
+
detail.usage = `${entry.config.command} ${(entry.config.args ?? []).join(" ")} [args...]`;
|
|
562
|
+
} else if (entry.type === "script") {
|
|
563
|
+
detail.usage = `${entry.config.path} [args...]`;
|
|
564
|
+
}
|
|
565
|
+
return detail;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
var registryPlugin = fp2(async (server, opts) => {
|
|
569
|
+
const registry = new ToolRegistry();
|
|
570
|
+
registry.loadFromConfig(opts.config.tools);
|
|
571
|
+
server.decorate("registry", registry);
|
|
572
|
+
server.log.info(`Loaded ${registry.list().length} tools`);
|
|
573
|
+
}, { name: "registry" });
|
|
574
|
+
var MAX_ARG_LENGTH = 64 * 1024;
|
|
575
|
+
function sanitizeArgs(args) {
|
|
576
|
+
return args.map((arg, i) => {
|
|
577
|
+
if (arg.includes("\0")) {
|
|
578
|
+
throw new GigaiError(
|
|
579
|
+
ErrorCode.VALIDATION_ERROR,
|
|
580
|
+
`Argument ${i} contains null byte`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (arg.length > MAX_ARG_LENGTH) {
|
|
584
|
+
throw new GigaiError(
|
|
585
|
+
ErrorCode.VALIDATION_ERROR,
|
|
586
|
+
`Argument ${i} exceeds maximum length of ${MAX_ARG_LENGTH}`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
return arg;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
593
|
+
var KILL_GRACE_PERIOD = 5e3;
|
|
594
|
+
var MAX_OUTPUT_SIZE = 10 * 1024 * 1024;
|
|
595
|
+
function executeTool(entry, args, timeout) {
|
|
596
|
+
const sanitized = sanitizeArgs(args);
|
|
597
|
+
const effectiveTimeout = timeout ?? DEFAULT_TIMEOUT;
|
|
598
|
+
let command;
|
|
599
|
+
let spawnArgs;
|
|
600
|
+
let cwd;
|
|
601
|
+
let env;
|
|
602
|
+
switch (entry.type) {
|
|
603
|
+
case "cli":
|
|
604
|
+
command = entry.config.command;
|
|
605
|
+
spawnArgs = [...entry.config.args ?? [], ...sanitized];
|
|
606
|
+
cwd = entry.config.cwd;
|
|
607
|
+
env = entry.config.env;
|
|
608
|
+
break;
|
|
609
|
+
case "script": {
|
|
610
|
+
const interpreter = entry.config.interpreter ?? "node";
|
|
611
|
+
command = interpreter;
|
|
612
|
+
spawnArgs = [entry.config.path, ...sanitized];
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
default:
|
|
616
|
+
throw new GigaiError(
|
|
617
|
+
ErrorCode.EXEC_FAILED,
|
|
618
|
+
`Cannot execute tool of type: ${entry.type}`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return new Promise((resolve7, reject) => {
|
|
622
|
+
const start = Date.now();
|
|
623
|
+
const stdoutChunks = [];
|
|
624
|
+
const stderrChunks = [];
|
|
625
|
+
const child = spawn(command, spawnArgs, {
|
|
626
|
+
shell: false,
|
|
627
|
+
cwd,
|
|
628
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
629
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
630
|
+
});
|
|
631
|
+
let killed = false;
|
|
632
|
+
const timer = setTimeout(() => {
|
|
633
|
+
killed = true;
|
|
634
|
+
child.kill("SIGTERM");
|
|
635
|
+
setTimeout(() => {
|
|
636
|
+
if (!child.killed) {
|
|
637
|
+
child.kill("SIGKILL");
|
|
638
|
+
}
|
|
639
|
+
}, KILL_GRACE_PERIOD);
|
|
640
|
+
}, effectiveTimeout);
|
|
641
|
+
let totalSize = 0;
|
|
642
|
+
child.stdout.on("data", (chunk) => {
|
|
643
|
+
totalSize += chunk.length;
|
|
644
|
+
if (totalSize <= MAX_OUTPUT_SIZE) stdoutChunks.push(chunk);
|
|
645
|
+
else if (!killed) {
|
|
646
|
+
killed = true;
|
|
647
|
+
child.kill("SIGTERM");
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
child.stderr.on("data", (chunk) => {
|
|
651
|
+
totalSize += chunk.length;
|
|
652
|
+
if (totalSize <= MAX_OUTPUT_SIZE) stderrChunks.push(chunk);
|
|
653
|
+
else if (!killed) {
|
|
654
|
+
killed = true;
|
|
655
|
+
child.kill("SIGTERM");
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
child.on("error", (err) => {
|
|
659
|
+
clearTimeout(timer);
|
|
660
|
+
reject(new GigaiError(ErrorCode.EXEC_FAILED, `Failed to spawn: ${err.message}`));
|
|
661
|
+
});
|
|
662
|
+
child.on("close", (exitCode) => {
|
|
663
|
+
clearTimeout(timer);
|
|
664
|
+
const durationMs = Date.now() - start;
|
|
665
|
+
if (killed) {
|
|
666
|
+
reject(new GigaiError(ErrorCode.EXEC_TIMEOUT, `Tool execution timed out after ${effectiveTimeout}ms`));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
resolve7({
|
|
670
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
671
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
672
|
+
exitCode: exitCode ?? 1,
|
|
673
|
+
durationMs
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
var executorPlugin = fp3(async (server) => {
|
|
679
|
+
server.decorate("executor", {
|
|
680
|
+
execute: executeTool
|
|
681
|
+
});
|
|
682
|
+
}, { name: "executor" });
|
|
683
|
+
var McpClientWrapper = class {
|
|
684
|
+
constructor(config) {
|
|
685
|
+
this.config = config;
|
|
686
|
+
}
|
|
687
|
+
client = null;
|
|
688
|
+
transport = null;
|
|
689
|
+
toolsCache = null;
|
|
690
|
+
connected = false;
|
|
691
|
+
async ensureConnected() {
|
|
692
|
+
if (this.connected && this.client) return;
|
|
693
|
+
this.transport = new StdioClientTransport({
|
|
694
|
+
command: this.config.command,
|
|
695
|
+
args: this.config.args,
|
|
696
|
+
env: this.config.env ? { ...process.env, ...this.config.env } : process.env
|
|
697
|
+
});
|
|
698
|
+
this.client = new Client({
|
|
699
|
+
name: `gigai-${this.config.name}`,
|
|
700
|
+
version: "0.1.0"
|
|
701
|
+
});
|
|
702
|
+
await this.client.connect(this.transport);
|
|
703
|
+
this.connected = true;
|
|
704
|
+
}
|
|
705
|
+
async listTools() {
|
|
706
|
+
if (this.toolsCache) return this.toolsCache;
|
|
707
|
+
await this.ensureConnected();
|
|
708
|
+
const result = await this.client.listTools();
|
|
709
|
+
this.toolsCache = result.tools.map((t) => ({
|
|
710
|
+
name: t.name,
|
|
711
|
+
description: t.description ?? "",
|
|
712
|
+
inputSchema: t.inputSchema
|
|
713
|
+
}));
|
|
714
|
+
return this.toolsCache;
|
|
715
|
+
}
|
|
716
|
+
async callTool(toolName, args) {
|
|
717
|
+
await this.ensureConnected();
|
|
718
|
+
try {
|
|
719
|
+
const result = await this.client.callTool({ name: toolName, arguments: args });
|
|
720
|
+
return {
|
|
721
|
+
content: result.content ?? [],
|
|
722
|
+
isError: result.isError ?? false
|
|
723
|
+
};
|
|
724
|
+
} catch (err) {
|
|
725
|
+
throw new GigaiError(
|
|
726
|
+
ErrorCode.MCP_ERROR,
|
|
727
|
+
`MCP tool call failed: ${err.message}`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async disconnect() {
|
|
732
|
+
if (this.client && this.connected) {
|
|
733
|
+
try {
|
|
734
|
+
await this.client.close();
|
|
735
|
+
} catch {
|
|
736
|
+
}
|
|
737
|
+
this.client = null;
|
|
738
|
+
this.transport = null;
|
|
739
|
+
this.connected = false;
|
|
740
|
+
this.toolsCache = null;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
get isConnected() {
|
|
744
|
+
return this.connected;
|
|
745
|
+
}
|
|
746
|
+
get name() {
|
|
747
|
+
return this.config.name;
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
var McpPool = class {
|
|
751
|
+
clients = /* @__PURE__ */ new Map();
|
|
752
|
+
loadFromConfig(tools) {
|
|
753
|
+
for (const tool of tools) {
|
|
754
|
+
this.clients.set(tool.name, new McpClientWrapper(tool));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
getClient(name) {
|
|
758
|
+
const client = this.clients.get(name);
|
|
759
|
+
if (!client) {
|
|
760
|
+
throw new GigaiError(ErrorCode.TOOL_NOT_FOUND, `MCP tool not found: ${name}`);
|
|
761
|
+
}
|
|
762
|
+
return client;
|
|
763
|
+
}
|
|
764
|
+
has(name) {
|
|
765
|
+
return this.clients.has(name);
|
|
766
|
+
}
|
|
767
|
+
async listToolsFor(name) {
|
|
768
|
+
const client = this.getClient(name);
|
|
769
|
+
return client.listTools();
|
|
770
|
+
}
|
|
771
|
+
list() {
|
|
772
|
+
return Array.from(this.clients.keys());
|
|
773
|
+
}
|
|
774
|
+
async shutdownAll() {
|
|
775
|
+
const disconnects = Array.from(this.clients.values()).map((c) => c.disconnect());
|
|
776
|
+
await Promise.allSettled(disconnects);
|
|
777
|
+
this.clients.clear();
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
var McpLifecycleManager = class {
|
|
781
|
+
constructor(pool) {
|
|
782
|
+
this.pool = pool;
|
|
783
|
+
}
|
|
784
|
+
healthCheckInterval = null;
|
|
785
|
+
startHealthChecks(intervalMs = 3e4) {
|
|
786
|
+
this.healthCheckInterval = setInterval(async () => {
|
|
787
|
+
for (const name of this.pool.list()) {
|
|
788
|
+
try {
|
|
789
|
+
const client = this.pool.getClient(name);
|
|
790
|
+
if (client.isConnected) {
|
|
791
|
+
await client.listTools();
|
|
792
|
+
}
|
|
793
|
+
} catch {
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}, intervalMs);
|
|
797
|
+
}
|
|
798
|
+
async shutdown() {
|
|
799
|
+
if (this.healthCheckInterval) {
|
|
800
|
+
clearInterval(this.healthCheckInterval);
|
|
801
|
+
this.healthCheckInterval = null;
|
|
802
|
+
}
|
|
803
|
+
await this.pool.shutdownAll();
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
var mcpPlugin = fp4(async (server, opts) => {
|
|
807
|
+
const pool = new McpPool();
|
|
808
|
+
const mcpTools = opts.config.tools.filter(
|
|
809
|
+
(t) => t.type === "mcp"
|
|
810
|
+
);
|
|
811
|
+
pool.loadFromConfig(mcpTools);
|
|
812
|
+
server.decorate("mcpPool", pool);
|
|
813
|
+
const lifecycle = new McpLifecycleManager(pool);
|
|
814
|
+
lifecycle.startHealthChecks();
|
|
815
|
+
server.log.info(`MCP pool initialized with ${mcpTools.length} servers`);
|
|
816
|
+
server.addHook("onClose", async () => {
|
|
817
|
+
await lifecycle.shutdown();
|
|
818
|
+
});
|
|
819
|
+
}, { name: "mcp" });
|
|
820
|
+
var startTime = Date.now();
|
|
821
|
+
var startupVersion = "0.0.0";
|
|
822
|
+
try {
|
|
823
|
+
const pkg = JSON.parse(
|
|
824
|
+
readFileSync(resolve(import.meta.dirname ?? ".", "../package.json"), "utf8")
|
|
825
|
+
);
|
|
826
|
+
startupVersion = pkg.version;
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
async function healthRoutes(server) {
|
|
830
|
+
server.get("/health", {
|
|
831
|
+
config: { skipAuth: true }
|
|
832
|
+
}, async () => {
|
|
833
|
+
return {
|
|
834
|
+
status: "ok",
|
|
835
|
+
version: startupVersion,
|
|
836
|
+
uptime: Date.now() - startTime
|
|
837
|
+
};
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
async function toolRoutes(server) {
|
|
841
|
+
server.get("/tools", async () => {
|
|
842
|
+
return { tools: server.registry.list() };
|
|
843
|
+
});
|
|
844
|
+
server.get("/tools/:name", async (request) => {
|
|
845
|
+
const { name } = request.params;
|
|
846
|
+
const detail = server.registry.getDetail(name);
|
|
847
|
+
const entry = server.registry.get(name);
|
|
848
|
+
if (entry.type === "mcp") {
|
|
849
|
+
try {
|
|
850
|
+
const mcpTools = await server.mcpPool.listToolsFor(name);
|
|
851
|
+
detail.mcpTools = mcpTools;
|
|
852
|
+
} catch {
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return { tool: detail };
|
|
856
|
+
});
|
|
857
|
+
server.get("/tools/:name/mcp", async (request) => {
|
|
858
|
+
const { name } = request.params;
|
|
859
|
+
const entry = server.registry.get(name);
|
|
860
|
+
if (entry.type !== "mcp") {
|
|
861
|
+
return { tools: [] };
|
|
862
|
+
}
|
|
863
|
+
const mcpTools = await server.mcpPool.listToolsFor(name);
|
|
864
|
+
return { tools: mcpTools };
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
async function validatePath(targetPath, allowedPaths) {
|
|
868
|
+
const resolved = resolve2(targetPath);
|
|
869
|
+
let real;
|
|
870
|
+
try {
|
|
871
|
+
real = await realpath(resolved);
|
|
872
|
+
} catch {
|
|
873
|
+
real = resolved;
|
|
874
|
+
}
|
|
875
|
+
const isAllowed = allowedPaths.some((allowed) => {
|
|
876
|
+
const resolvedAllowed = resolve2(allowed);
|
|
877
|
+
const allowedPrefix = resolvedAllowed.endsWith("/") ? resolvedAllowed : resolvedAllowed + "/";
|
|
878
|
+
return real === resolvedAllowed || real.startsWith(allowedPrefix) || resolved === resolvedAllowed || resolved.startsWith(allowedPrefix);
|
|
879
|
+
});
|
|
880
|
+
if (!isAllowed) {
|
|
881
|
+
throw new GigaiError(
|
|
882
|
+
ErrorCode.PATH_NOT_ALLOWED,
|
|
883
|
+
`Path not within allowed directories: ${targetPath}`
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
return resolved;
|
|
887
|
+
}
|
|
888
|
+
async function readFileSafe(path, allowedPaths) {
|
|
889
|
+
const safePath = await validatePath(path, allowedPaths);
|
|
890
|
+
return fsReadFile(safePath, "utf8");
|
|
891
|
+
}
|
|
892
|
+
async function listDirSafe(path, allowedPaths) {
|
|
893
|
+
const safePath = await validatePath(path, allowedPaths);
|
|
894
|
+
const entries = await readdir(safePath, { withFileTypes: true });
|
|
895
|
+
return entries.map((e) => ({
|
|
896
|
+
name: e.name,
|
|
897
|
+
type: e.isDirectory() ? "directory" : "file"
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
900
|
+
async function searchFilesSafe(path, pattern, allowedPaths) {
|
|
901
|
+
const safePath = await validatePath(path, allowedPaths);
|
|
902
|
+
const results = [];
|
|
903
|
+
let regex;
|
|
904
|
+
try {
|
|
905
|
+
regex = new RegExp(pattern, "i");
|
|
906
|
+
} catch {
|
|
907
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, `Invalid search pattern: ${pattern}`);
|
|
908
|
+
}
|
|
909
|
+
async function walk(dir) {
|
|
910
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
911
|
+
for (const entry of entries) {
|
|
912
|
+
const fullPath = join(dir, entry.name);
|
|
913
|
+
if (regex.test(entry.name)) {
|
|
914
|
+
results.push(relative(safePath, fullPath));
|
|
915
|
+
}
|
|
916
|
+
if (entry.isDirectory()) {
|
|
917
|
+
await walk(fullPath);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
await walk(safePath);
|
|
922
|
+
return results;
|
|
923
|
+
}
|
|
924
|
+
var SHELL_INTERPRETERS = /* @__PURE__ */ new Set([
|
|
925
|
+
"sh",
|
|
926
|
+
"bash",
|
|
927
|
+
"zsh",
|
|
928
|
+
"fish",
|
|
929
|
+
"csh",
|
|
930
|
+
"tcsh",
|
|
931
|
+
"dash",
|
|
932
|
+
"ksh",
|
|
933
|
+
"env",
|
|
934
|
+
"xargs",
|
|
935
|
+
"nohup",
|
|
936
|
+
"strace",
|
|
937
|
+
"ltrace"
|
|
938
|
+
]);
|
|
939
|
+
var MAX_OUTPUT_SIZE2 = 10 * 1024 * 1024;
|
|
940
|
+
async function execCommandSafe(command, args, config) {
|
|
941
|
+
if (!config.allowlist.includes(command)) {
|
|
942
|
+
throw new GigaiError(
|
|
943
|
+
ErrorCode.COMMAND_NOT_ALLOWED,
|
|
944
|
+
`Command not in allowlist: ${command}. Allowed: ${config.allowlist.join(", ")}`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
if (command === "sudo" && !config.allowSudo) {
|
|
948
|
+
throw new GigaiError(ErrorCode.COMMAND_NOT_ALLOWED, "sudo is not allowed");
|
|
949
|
+
}
|
|
950
|
+
if (SHELL_INTERPRETERS.has(command)) {
|
|
951
|
+
throw new GigaiError(
|
|
952
|
+
ErrorCode.COMMAND_NOT_ALLOWED,
|
|
953
|
+
`Shell interpreter not allowed: ${command}`
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
for (const arg of args) {
|
|
957
|
+
if (arg.includes("\0")) {
|
|
958
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, "Null byte in argument");
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return new Promise((resolve7, reject) => {
|
|
962
|
+
const child = spawn2(command, args, {
|
|
963
|
+
shell: false,
|
|
964
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
965
|
+
});
|
|
966
|
+
const stdoutChunks = [];
|
|
967
|
+
const stderrChunks = [];
|
|
968
|
+
let totalSize = 0;
|
|
969
|
+
child.stdout.on("data", (chunk) => {
|
|
970
|
+
totalSize += chunk.length;
|
|
971
|
+
if (totalSize <= MAX_OUTPUT_SIZE2) stdoutChunks.push(chunk);
|
|
972
|
+
else child.kill("SIGTERM");
|
|
973
|
+
});
|
|
974
|
+
child.stderr.on("data", (chunk) => {
|
|
975
|
+
totalSize += chunk.length;
|
|
976
|
+
if (totalSize <= MAX_OUTPUT_SIZE2) stderrChunks.push(chunk);
|
|
977
|
+
else child.kill("SIGTERM");
|
|
978
|
+
});
|
|
979
|
+
child.on("error", (err) => {
|
|
980
|
+
reject(new GigaiError(ErrorCode.EXEC_FAILED, `Failed to spawn ${command}: ${err.message}`));
|
|
981
|
+
});
|
|
982
|
+
child.on("close", (exitCode) => {
|
|
983
|
+
resolve7({
|
|
984
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
985
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
986
|
+
exitCode: exitCode ?? 1
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
async function execRoutes(server) {
|
|
992
|
+
server.post("/exec", {
|
|
993
|
+
config: {
|
|
994
|
+
rateLimit: { max: 60, timeWindow: "1 minute" }
|
|
995
|
+
},
|
|
996
|
+
schema: {
|
|
997
|
+
body: {
|
|
998
|
+
type: "object",
|
|
999
|
+
required: ["tool", "args"],
|
|
1000
|
+
properties: {
|
|
1001
|
+
tool: { type: "string" },
|
|
1002
|
+
args: { type: "array", items: { type: "string" } },
|
|
1003
|
+
timeout: { type: "number" }
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}, async (request) => {
|
|
1008
|
+
const { tool, args, timeout } = request.body;
|
|
1009
|
+
const entry = server.registry.get(tool);
|
|
1010
|
+
if (entry.type === "builtin") {
|
|
1011
|
+
return handleBuiltin(entry.config, args);
|
|
1012
|
+
}
|
|
1013
|
+
const result = await server.executor.execute(entry, args, timeout);
|
|
1014
|
+
return result;
|
|
1015
|
+
});
|
|
1016
|
+
server.post("/exec/mcp", {
|
|
1017
|
+
config: {
|
|
1018
|
+
rateLimit: { max: 60, timeWindow: "1 minute" }
|
|
1019
|
+
},
|
|
1020
|
+
schema: {
|
|
1021
|
+
body: {
|
|
1022
|
+
type: "object",
|
|
1023
|
+
required: ["tool", "mcpTool", "args"],
|
|
1024
|
+
properties: {
|
|
1025
|
+
tool: { type: "string" },
|
|
1026
|
+
mcpTool: { type: "string" },
|
|
1027
|
+
args: { type: "object" }
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}, async (request) => {
|
|
1032
|
+
const { tool, mcpTool, args } = request.body;
|
|
1033
|
+
const entry = server.registry.get(tool);
|
|
1034
|
+
if (entry.type !== "mcp") {
|
|
1035
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, `Tool ${tool} is not an MCP tool`);
|
|
1036
|
+
}
|
|
1037
|
+
const start = Date.now();
|
|
1038
|
+
const client = server.mcpPool.getClient(tool);
|
|
1039
|
+
const result = await client.callTool(mcpTool, args);
|
|
1040
|
+
return {
|
|
1041
|
+
content: result.content,
|
|
1042
|
+
isError: result.isError,
|
|
1043
|
+
durationMs: Date.now() - start
|
|
1044
|
+
};
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
async function handleBuiltin(config, args) {
|
|
1048
|
+
const builtinConfig = config.config ?? {};
|
|
1049
|
+
switch (config.builtin) {
|
|
1050
|
+
case "filesystem": {
|
|
1051
|
+
const allowedPaths = builtinConfig.allowedPaths ?? ["."];
|
|
1052
|
+
const subcommand = args[0];
|
|
1053
|
+
const target = args[1] ?? ".";
|
|
1054
|
+
switch (subcommand) {
|
|
1055
|
+
case "read":
|
|
1056
|
+
return { stdout: await readFileSafe(target, allowedPaths), stderr: "", exitCode: 0, durationMs: 0 };
|
|
1057
|
+
case "list":
|
|
1058
|
+
return { stdout: JSON.stringify(await listDirSafe(target, allowedPaths), null, 2), stderr: "", exitCode: 0, durationMs: 0 };
|
|
1059
|
+
case "search":
|
|
1060
|
+
return { stdout: JSON.stringify(await searchFilesSafe(target, args[2] ?? ".*", allowedPaths), null, 2), stderr: "", exitCode: 0, durationMs: 0 };
|
|
1061
|
+
default:
|
|
1062
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, `Unknown filesystem subcommand: ${subcommand}. Use: read, list, search`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
case "shell": {
|
|
1066
|
+
const allowlist = builtinConfig.allowlist ?? [];
|
|
1067
|
+
const allowSudo = builtinConfig.allowSudo ?? false;
|
|
1068
|
+
const command = args[0];
|
|
1069
|
+
if (!command) {
|
|
1070
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, "No command specified");
|
|
1071
|
+
}
|
|
1072
|
+
const result = await execCommandSafe(command, args.slice(1), { allowlist, allowSudo });
|
|
1073
|
+
return { ...result, durationMs: 0 };
|
|
1074
|
+
}
|
|
1075
|
+
default:
|
|
1076
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, `Unknown builtin: ${config.builtin}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
var transfers = /* @__PURE__ */ new Map();
|
|
1080
|
+
var TRANSFER_DIR = join2(tmpdir(), "gigai-transfers");
|
|
1081
|
+
var TRANSFER_TTL = 60 * 60 * 1e3;
|
|
1082
|
+
setInterval(async () => {
|
|
1083
|
+
const now = Date.now();
|
|
1084
|
+
for (const [id, entry] of transfers) {
|
|
1085
|
+
if (entry.expiresAt < now) {
|
|
1086
|
+
transfers.delete(id);
|
|
1087
|
+
try {
|
|
1088
|
+
await unlink(entry.path);
|
|
1089
|
+
} catch {
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}, 6e4);
|
|
1094
|
+
async function transferRoutes(server) {
|
|
1095
|
+
await mkdir(TRANSFER_DIR, { recursive: true });
|
|
1096
|
+
server.post("/transfer/upload", async (request) => {
|
|
1097
|
+
const data = await request.file();
|
|
1098
|
+
if (!data) {
|
|
1099
|
+
throw new GigaiError(ErrorCode.VALIDATION_ERROR, "No file uploaded");
|
|
1100
|
+
}
|
|
1101
|
+
const id = nanoid3(16);
|
|
1102
|
+
const buffer = await data.toBuffer();
|
|
1103
|
+
const filePath = join2(TRANSFER_DIR, id);
|
|
1104
|
+
await writeFile(filePath, buffer);
|
|
1105
|
+
const entry = {
|
|
1106
|
+
id,
|
|
1107
|
+
path: filePath,
|
|
1108
|
+
filename: data.filename,
|
|
1109
|
+
mimeType: data.mimetype,
|
|
1110
|
+
expiresAt: Date.now() + TRANSFER_TTL
|
|
1111
|
+
};
|
|
1112
|
+
transfers.set(id, entry);
|
|
1113
|
+
return {
|
|
1114
|
+
id,
|
|
1115
|
+
expiresAt: entry.expiresAt
|
|
1116
|
+
};
|
|
1117
|
+
});
|
|
1118
|
+
server.get("/transfer/:id", async (request, reply) => {
|
|
1119
|
+
const { id } = request.params;
|
|
1120
|
+
const entry = transfers.get(id);
|
|
1121
|
+
if (!entry) {
|
|
1122
|
+
throw new GigaiError(ErrorCode.TRANSFER_NOT_FOUND, "Transfer not found");
|
|
1123
|
+
}
|
|
1124
|
+
if (entry.expiresAt < Date.now()) {
|
|
1125
|
+
transfers.delete(id);
|
|
1126
|
+
throw new GigaiError(ErrorCode.TRANSFER_EXPIRED, "Transfer expired");
|
|
1127
|
+
}
|
|
1128
|
+
const content = await readFile(entry.path);
|
|
1129
|
+
reply.type(entry.mimeType).send(content);
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
var execFileAsync = promisify(execFile);
|
|
1133
|
+
async function adminRoutes(server) {
|
|
1134
|
+
server.post("/admin/update", async (_request, reply) => {
|
|
1135
|
+
try {
|
|
1136
|
+
const { stdout, stderr } = await execFileAsync(
|
|
1137
|
+
"npm",
|
|
1138
|
+
["install", "-g", "@schuttdev/gigai@latest"],
|
|
1139
|
+
{ timeout: 12e4 }
|
|
1140
|
+
);
|
|
1141
|
+
server.log.info(`Update output: ${stdout}`);
|
|
1142
|
+
if (stderr) server.log.warn(`Update stderr: ${stderr}`);
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
server.log.error(`Update failed: ${e.message}`);
|
|
1145
|
+
reply.status(500);
|
|
1146
|
+
return { updated: false, error: e.message };
|
|
1147
|
+
}
|
|
1148
|
+
setTimeout(async () => {
|
|
1149
|
+
server.log.info("Restarting server after update...");
|
|
1150
|
+
const args = ["start"];
|
|
1151
|
+
const configIdx = process.argv.indexOf("--config");
|
|
1152
|
+
if (configIdx !== -1 && process.argv[configIdx + 1]) {
|
|
1153
|
+
args.push("--config", process.argv[configIdx + 1]);
|
|
1154
|
+
}
|
|
1155
|
+
const shortIdx = process.argv.indexOf("-c");
|
|
1156
|
+
if (shortIdx !== -1 && process.argv[shortIdx + 1]) {
|
|
1157
|
+
args.push("--config", process.argv[shortIdx + 1]);
|
|
1158
|
+
}
|
|
1159
|
+
if (process.argv.includes("--dev")) {
|
|
1160
|
+
args.push("--dev");
|
|
1161
|
+
}
|
|
1162
|
+
await server.close();
|
|
1163
|
+
const child = spawn3("gigai", args, {
|
|
1164
|
+
detached: true,
|
|
1165
|
+
stdio: "ignore",
|
|
1166
|
+
cwd: process.cwd()
|
|
1167
|
+
});
|
|
1168
|
+
child.unref();
|
|
1169
|
+
process.exit(0);
|
|
1170
|
+
}, 500);
|
|
1171
|
+
return { updated: true, restarting: true };
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
async function createServer(opts) {
|
|
1175
|
+
const { config, dev = false } = opts;
|
|
1176
|
+
const server = Fastify({
|
|
1177
|
+
logger: {
|
|
1178
|
+
level: dev ? "debug" : "info"
|
|
1179
|
+
},
|
|
1180
|
+
trustProxy: !dev
|
|
1181
|
+
// Only trust proxy headers in production (behind HTTPS reverse proxy)
|
|
1182
|
+
});
|
|
1183
|
+
const httpsProvider = config.server.https?.provider;
|
|
1184
|
+
const behindTunnel = httpsProvider === "tailscale" || httpsProvider === "cloudflare";
|
|
1185
|
+
if (!dev && !behindTunnel) {
|
|
1186
|
+
server.addHook("onRequest", async (request, _reply) => {
|
|
1187
|
+
if (request.protocol !== "https") {
|
|
1188
|
+
throw new GigaiError(ErrorCode.HTTPS_REQUIRED, "HTTPS is required");
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
await server.register(cors, { origin: false });
|
|
1193
|
+
await server.register(rateLimit, { max: 100, timeWindow: "1 minute" });
|
|
1194
|
+
await server.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
|
|
1195
|
+
await server.register(authPlugin, { config });
|
|
1196
|
+
await server.register(registryPlugin, { config });
|
|
1197
|
+
await server.register(executorPlugin);
|
|
1198
|
+
await server.register(mcpPlugin, { config });
|
|
1199
|
+
await server.register(healthRoutes);
|
|
1200
|
+
await server.register(toolRoutes);
|
|
1201
|
+
await server.register(execRoutes);
|
|
1202
|
+
await server.register(transferRoutes);
|
|
1203
|
+
await server.register(adminRoutes);
|
|
1204
|
+
server.setErrorHandler((error, _request, reply) => {
|
|
1205
|
+
if (error instanceof GigaiError) {
|
|
1206
|
+
reply.status(error.statusCode).send(error.toJSON());
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if ("statusCode" in error && error.statusCode === 429) {
|
|
1210
|
+
reply.status(429).send({
|
|
1211
|
+
error: { code: ErrorCode.RATE_LIMITED, message: "Too many requests" }
|
|
1212
|
+
});
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
server.log.error(error);
|
|
1216
|
+
reply.status(500).send({
|
|
1217
|
+
error: { code: ErrorCode.INTERNAL_ERROR, message: "Internal server error" }
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
return server;
|
|
1221
|
+
}
|
|
1222
|
+
var DEFAULT_CONFIG_PATH = "gigai.config.json";
|
|
1223
|
+
async function loadConfig(path) {
|
|
1224
|
+
const configPath = resolve3(path ?? DEFAULT_CONFIG_PATH);
|
|
1225
|
+
const raw = await readFile2(configPath, "utf8");
|
|
1226
|
+
const json = JSON.parse(raw);
|
|
1227
|
+
return GigaiConfigSchema.parse(json);
|
|
1228
|
+
}
|
|
1229
|
+
function runCommand(command, args) {
|
|
1230
|
+
return new Promise((resolve7, reject) => {
|
|
1231
|
+
const child = spawn4(command, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
1232
|
+
const chunks = [];
|
|
1233
|
+
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
1234
|
+
child.on("error", reject);
|
|
1235
|
+
child.on("close", (exitCode) => {
|
|
1236
|
+
resolve7({ stdout: Buffer.concat(chunks).toString("utf8").trim(), exitCode: exitCode ?? 1 });
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
async function getTailscaleStatus() {
|
|
1241
|
+
try {
|
|
1242
|
+
const { stdout, exitCode } = await runCommand("tailscale", ["status", "--json"]);
|
|
1243
|
+
if (exitCode !== 0) return { online: false };
|
|
1244
|
+
const status = JSON.parse(stdout);
|
|
1245
|
+
return {
|
|
1246
|
+
online: status.BackendState === "Running",
|
|
1247
|
+
hostname: status.Self?.DNSName?.replace(/\.$/, "")
|
|
1248
|
+
};
|
|
1249
|
+
} catch {
|
|
1250
|
+
return { online: false };
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
async function enableFunnel(port) {
|
|
1254
|
+
const status = await getTailscaleStatus();
|
|
1255
|
+
if (!status.online || !status.hostname) {
|
|
1256
|
+
throw new Error("Tailscale is not running or not connected");
|
|
1257
|
+
}
|
|
1258
|
+
const { exitCode, stdout } = await runCommand("tailscale", [
|
|
1259
|
+
"funnel",
|
|
1260
|
+
"--bg",
|
|
1261
|
+
`${port}`
|
|
1262
|
+
]);
|
|
1263
|
+
if (exitCode !== 0) {
|
|
1264
|
+
throw new Error(`Failed to enable Tailscale Funnel: ${stdout}`);
|
|
1265
|
+
}
|
|
1266
|
+
return `https://${status.hostname}`;
|
|
1267
|
+
}
|
|
1268
|
+
async function disableFunnel(port) {
|
|
1269
|
+
await runCommand("tailscale", ["funnel", "--bg", "off", `${port}`]);
|
|
1270
|
+
}
|
|
1271
|
+
function runTunnel(tunnelName, localPort) {
|
|
1272
|
+
const child = spawn5("cloudflared", [
|
|
1273
|
+
"tunnel",
|
|
1274
|
+
"--url",
|
|
1275
|
+
`http://localhost:${localPort}`,
|
|
1276
|
+
"run",
|
|
1277
|
+
tunnelName
|
|
1278
|
+
], {
|
|
1279
|
+
shell: false,
|
|
1280
|
+
stdio: "ignore",
|
|
1281
|
+
detached: true
|
|
1282
|
+
});
|
|
1283
|
+
child.unref();
|
|
1284
|
+
return child;
|
|
1285
|
+
}
|
|
1286
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
1287
|
+
async function getTailscaleDnsName() {
|
|
1288
|
+
try {
|
|
1289
|
+
const { stdout } = await execFileAsync2("tailscale", ["status", "--json"]);
|
|
1290
|
+
const data = JSON.parse(stdout);
|
|
1291
|
+
const dnsName = data?.Self?.DNSName;
|
|
1292
|
+
if (dnsName) return dnsName.replace(/\.$/, "");
|
|
1293
|
+
return null;
|
|
1294
|
+
} catch {
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
async function ensureTailscaleFunnel(port) {
|
|
1299
|
+
const dnsName = await getTailscaleDnsName();
|
|
1300
|
+
if (!dnsName) {
|
|
1301
|
+
throw new Error("Tailscale is not running or not connected. Install/start Tailscale first.");
|
|
1302
|
+
}
|
|
1303
|
+
console.log(" Enabling Tailscale Funnel...");
|
|
1304
|
+
try {
|
|
1305
|
+
const { stdout, stderr } = await execFileAsync2("tailscale", ["funnel", "--bg", `${port}`]);
|
|
1306
|
+
const output = stdout + stderr;
|
|
1307
|
+
if (output.includes("Funnel is not enabled")) {
|
|
1308
|
+
const urlMatch = output.match(/(https:\/\/login\.tailscale\.com\/\S+)/);
|
|
1309
|
+
const enableUrl = urlMatch?.[1] ?? "https://login.tailscale.com/admin/machines";
|
|
1310
|
+
console.log(`
|
|
1311
|
+
Funnel is not enabled on your tailnet.`);
|
|
1312
|
+
console.log(` Enable it here: ${enableUrl}
|
|
1313
|
+
`);
|
|
1314
|
+
await confirm({ message: "I've enabled Funnel in my Tailscale admin. Continue?", default: true });
|
|
1315
|
+
const retry = await execFileAsync2("tailscale", ["funnel", "--bg", `${port}`]);
|
|
1316
|
+
if ((retry.stdout + retry.stderr).includes("Funnel is not enabled")) {
|
|
1317
|
+
throw new Error("Funnel is still not enabled. Please enable it in your Tailscale admin and try again.");
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
if (e.message.includes("Funnel is still not enabled")) throw e;
|
|
1322
|
+
}
|
|
1323
|
+
try {
|
|
1324
|
+
const { stdout } = await execFileAsync2("tailscale", ["funnel", "status"]);
|
|
1325
|
+
if (stdout.includes("No serve config")) {
|
|
1326
|
+
throw new Error("Funnel setup failed. Run 'tailscale funnel --bg " + port + "' manually to debug.");
|
|
1327
|
+
}
|
|
1328
|
+
} catch {
|
|
1329
|
+
}
|
|
1330
|
+
console.log(` Tailscale Funnel active: https://${dnsName}`);
|
|
1331
|
+
return `https://${dnsName}`;
|
|
1332
|
+
}
|
|
1333
|
+
async function runInit() {
|
|
1334
|
+
console.log("\n gigai server setup\n");
|
|
1335
|
+
const httpsProvider = await select({
|
|
1336
|
+
message: "HTTPS provider:",
|
|
1337
|
+
choices: [
|
|
1338
|
+
{ name: "Tailscale Funnel (recommended)", value: "tailscale" },
|
|
1339
|
+
{ name: "Cloudflare Tunnel", value: "cloudflare" },
|
|
1340
|
+
{ name: "Manual (provide certs)", value: "manual" },
|
|
1341
|
+
{ name: "None (dev mode only)", value: "none" }
|
|
1342
|
+
]
|
|
1343
|
+
});
|
|
1344
|
+
let httpsConfig;
|
|
1345
|
+
switch (httpsProvider) {
|
|
1346
|
+
case "tailscale":
|
|
1347
|
+
httpsConfig = {
|
|
1348
|
+
provider: "tailscale",
|
|
1349
|
+
funnelPort: 7443
|
|
1350
|
+
};
|
|
1351
|
+
break;
|
|
1352
|
+
case "cloudflare": {
|
|
1353
|
+
const tunnelName = await input({
|
|
1354
|
+
message: "Cloudflare tunnel name:",
|
|
1355
|
+
default: "gigai"
|
|
1356
|
+
});
|
|
1357
|
+
const domain = await input({
|
|
1358
|
+
message: "Domain (optional):"
|
|
1359
|
+
});
|
|
1360
|
+
httpsConfig = {
|
|
1361
|
+
provider: "cloudflare",
|
|
1362
|
+
tunnelName,
|
|
1363
|
+
...domain && { domain }
|
|
1364
|
+
};
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
case "manual": {
|
|
1368
|
+
const certPath = await input({
|
|
1369
|
+
message: "Path to TLS certificate:",
|
|
1370
|
+
required: true
|
|
1371
|
+
});
|
|
1372
|
+
const keyPath = await input({
|
|
1373
|
+
message: "Path to TLS private key:",
|
|
1374
|
+
required: true
|
|
1375
|
+
});
|
|
1376
|
+
httpsConfig = {
|
|
1377
|
+
provider: "manual",
|
|
1378
|
+
certPath,
|
|
1379
|
+
keyPath
|
|
1380
|
+
};
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
case "none":
|
|
1384
|
+
default:
|
|
1385
|
+
httpsConfig = void 0;
|
|
1386
|
+
console.log(" No HTTPS \u2014 dev mode only.");
|
|
1387
|
+
break;
|
|
1388
|
+
}
|
|
1389
|
+
const portStr = await input({
|
|
1390
|
+
message: "Server port:",
|
|
1391
|
+
default: "7443"
|
|
1392
|
+
});
|
|
1393
|
+
const port = parseInt(portStr, 10);
|
|
1394
|
+
const selectedBuiltins = await checkbox({
|
|
1395
|
+
message: "Built-in tools to enable:",
|
|
1396
|
+
choices: [
|
|
1397
|
+
{ name: "Filesystem (read/list/search files)", value: "filesystem", checked: true },
|
|
1398
|
+
{ name: "Shell (execute allowed commands)", value: "shell", checked: true }
|
|
1399
|
+
]
|
|
1400
|
+
});
|
|
1401
|
+
const tools = [];
|
|
1402
|
+
if (selectedBuiltins.includes("filesystem")) {
|
|
1403
|
+
const pathsStr = await input({
|
|
1404
|
+
message: "Allowed filesystem paths (comma-separated):",
|
|
1405
|
+
default: process.env.HOME ?? "~"
|
|
1406
|
+
});
|
|
1407
|
+
const allowedPaths = pathsStr.split(",").map((p) => p.trim());
|
|
1408
|
+
tools.push({
|
|
1409
|
+
type: "builtin",
|
|
1410
|
+
name: "fs",
|
|
1411
|
+
builtin: "filesystem",
|
|
1412
|
+
description: "Read, list, and search files",
|
|
1413
|
+
config: { allowedPaths }
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
if (selectedBuiltins.includes("shell")) {
|
|
1417
|
+
const allowlistStr = await input({
|
|
1418
|
+
message: "Allowed shell commands (comma-separated):",
|
|
1419
|
+
default: "ls,cat,head,tail,grep,find,wc,echo,date,whoami,pwd,git,npm,node"
|
|
1420
|
+
});
|
|
1421
|
+
const allowlist = allowlistStr.split(",").map((c) => c.trim());
|
|
1422
|
+
const allowSudo = await confirm({
|
|
1423
|
+
message: "Allow sudo?",
|
|
1424
|
+
default: false
|
|
1425
|
+
});
|
|
1426
|
+
tools.push({
|
|
1427
|
+
type: "builtin",
|
|
1428
|
+
name: "shell",
|
|
1429
|
+
builtin: "shell",
|
|
1430
|
+
description: "Execute allowed shell commands",
|
|
1431
|
+
config: { allowlist, allowSudo }
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
let serverName;
|
|
1435
|
+
if (httpsProvider === "tailscale") {
|
|
1436
|
+
const dnsName = await getTailscaleDnsName();
|
|
1437
|
+
if (dnsName) {
|
|
1438
|
+
serverName = dnsName.split(".")[0];
|
|
1439
|
+
}
|
|
1440
|
+
} else if (httpsProvider === "cloudflare") {
|
|
1441
|
+
serverName = await input({
|
|
1442
|
+
message: "Server name (identifies this machine):",
|
|
1443
|
+
required: true
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
if (!serverName) {
|
|
1447
|
+
const { hostname: osHostname } = await import("os");
|
|
1448
|
+
serverName = osHostname();
|
|
1449
|
+
}
|
|
1450
|
+
const encryptionKey = generateEncryptionKey();
|
|
1451
|
+
const config = {
|
|
1452
|
+
serverName,
|
|
1453
|
+
server: {
|
|
1454
|
+
port,
|
|
1455
|
+
host: "0.0.0.0",
|
|
1456
|
+
...httpsConfig && { https: httpsConfig }
|
|
1457
|
+
},
|
|
1458
|
+
auth: {
|
|
1459
|
+
encryptionKey,
|
|
1460
|
+
pairingTtlSeconds: 300,
|
|
1461
|
+
sessionTtlSeconds: 14400
|
|
1462
|
+
},
|
|
1463
|
+
tools
|
|
1464
|
+
};
|
|
1465
|
+
const configPath = resolve4("gigai.config.json");
|
|
1466
|
+
await writeFile2(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
1467
|
+
console.log(`
|
|
1468
|
+
Config written to: ${configPath}`);
|
|
1469
|
+
let serverUrl;
|
|
1470
|
+
if (httpsProvider === "tailscale") {
|
|
1471
|
+
try {
|
|
1472
|
+
serverUrl = await ensureTailscaleFunnel(port);
|
|
1473
|
+
} catch (e) {
|
|
1474
|
+
console.error(` ${e.message}`);
|
|
1475
|
+
console.log(" You can enable Funnel later and run 'gigai start' manually.\n");
|
|
1476
|
+
}
|
|
1477
|
+
} else if (httpsProvider === "cloudflare" && httpsConfig && "domain" in httpsConfig && httpsConfig.domain) {
|
|
1478
|
+
serverUrl = `https://${httpsConfig.domain}`;
|
|
1479
|
+
console.log(` Cloudflare URL: ${serverUrl}`);
|
|
1480
|
+
}
|
|
1481
|
+
if (!serverUrl) {
|
|
1482
|
+
serverUrl = await input({
|
|
1483
|
+
message: "Server URL (how clients will reach this server):",
|
|
1484
|
+
required: true
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
console.log("\n Starting server...");
|
|
1488
|
+
const serverArgs = ["start", "--config", configPath];
|
|
1489
|
+
if (!httpsConfig) serverArgs.push("--dev");
|
|
1490
|
+
const child = spawn6("gigai", serverArgs, {
|
|
1491
|
+
detached: true,
|
|
1492
|
+
stdio: "ignore",
|
|
1493
|
+
cwd: resolve4(".")
|
|
1494
|
+
});
|
|
1495
|
+
child.unref();
|
|
1496
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
1497
|
+
try {
|
|
1498
|
+
const res = await fetch(`http://localhost:${port}/health`);
|
|
1499
|
+
if (res.ok) {
|
|
1500
|
+
console.log(` Server running on port ${port} (PID ${child.pid})`);
|
|
1501
|
+
}
|
|
1502
|
+
} catch {
|
|
1503
|
+
console.log(` Server starting in background (PID ${child.pid})`);
|
|
1504
|
+
}
|
|
1505
|
+
let code;
|
|
1506
|
+
const maxRetries = 5;
|
|
1507
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
1508
|
+
try {
|
|
1509
|
+
const res = await fetch(`http://localhost:${port}/auth/pair/generate`);
|
|
1510
|
+
if (res.ok) {
|
|
1511
|
+
const data = await res.json();
|
|
1512
|
+
code = data.code;
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1515
|
+
} catch {
|
|
1516
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (!code) {
|
|
1520
|
+
console.log("\n Server is starting but not ready yet.");
|
|
1521
|
+
console.log(" Run 'gigai pair' once it's up to get a pairing code.\n");
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
console.log(`
|
|
1525
|
+
Paste this into Claude to pair:
|
|
1526
|
+
`);
|
|
1527
|
+
console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
1528
|
+
console.log(` Install kon and pair with my server:
|
|
1529
|
+
`);
|
|
1530
|
+
console.log(` \`\`\`bash`);
|
|
1531
|
+
console.log(` npm install -g @schuttdev/kon`);
|
|
1532
|
+
console.log(` kon pair ${code} ${serverUrl}`);
|
|
1533
|
+
console.log(` \`\`\`
|
|
1534
|
+
`);
|
|
1535
|
+
console.log(` Then show me the skill file output so I can save it.`);
|
|
1536
|
+
console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
1537
|
+
console.log(`
|
|
1538
|
+
Pairing code expires in ${config.auth.pairingTtlSeconds / 60} minutes.`);
|
|
1539
|
+
console.log(` Run 'gigai pair' to generate a new one.
|
|
1540
|
+
`);
|
|
1541
|
+
}
|
|
1542
|
+
async function loadConfigFile(path) {
|
|
1543
|
+
const configPath = resolve5(path ?? "gigai.config.json");
|
|
1544
|
+
const raw = await readFile3(configPath, "utf8");
|
|
1545
|
+
const config = GigaiConfigSchema.parse(JSON.parse(raw));
|
|
1546
|
+
return { config, path: configPath };
|
|
1547
|
+
}
|
|
1548
|
+
async function saveConfig(config, path) {
|
|
1549
|
+
await writeFile3(path, JSON.stringify(config, null, 2) + "\n");
|
|
1550
|
+
}
|
|
1551
|
+
async function wrapCli() {
|
|
1552
|
+
const { config, path } = await loadConfigFile();
|
|
1553
|
+
const name = await input2({ message: "Tool name:", required: true });
|
|
1554
|
+
const command = await input2({ message: "Command:", required: true });
|
|
1555
|
+
const description = await input2({ message: "Description:", required: true });
|
|
1556
|
+
const argsStr = await input2({ message: "Default args (space-separated, optional):" });
|
|
1557
|
+
const timeoutStr = await input2({ message: "Timeout in ms (optional):", default: "30000" });
|
|
1558
|
+
const tool = {
|
|
1559
|
+
type: "cli",
|
|
1560
|
+
name,
|
|
1561
|
+
command,
|
|
1562
|
+
description,
|
|
1563
|
+
...argsStr && { args: argsStr.split(" ").filter(Boolean) },
|
|
1564
|
+
timeout: parseInt(timeoutStr, 10)
|
|
1565
|
+
};
|
|
1566
|
+
config.tools.push(tool);
|
|
1567
|
+
await saveConfig(config, path);
|
|
1568
|
+
console.log(`Added CLI tool: ${name}`);
|
|
1569
|
+
}
|
|
1570
|
+
async function wrapMcp() {
|
|
1571
|
+
const { config, path } = await loadConfigFile();
|
|
1572
|
+
const name = await input2({ message: "Tool name:", required: true });
|
|
1573
|
+
const command = await input2({ message: "MCP server command:", required: true });
|
|
1574
|
+
const description = await input2({ message: "Description:", required: true });
|
|
1575
|
+
const argsStr = await input2({ message: "Command args (space-separated, optional):" });
|
|
1576
|
+
const envStr = await input2({
|
|
1577
|
+
message: "Environment variables (KEY=VALUE, comma-separated, optional):"
|
|
1578
|
+
});
|
|
1579
|
+
const env = {};
|
|
1580
|
+
if (envStr) {
|
|
1581
|
+
for (const pair of envStr.split(",")) {
|
|
1582
|
+
const [k, ...v] = pair.trim().split("=");
|
|
1583
|
+
if (k && v.length > 0) {
|
|
1584
|
+
env[k.trim()] = v.join("=").trim();
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const tool = {
|
|
1589
|
+
type: "mcp",
|
|
1590
|
+
name,
|
|
1591
|
+
command,
|
|
1592
|
+
description,
|
|
1593
|
+
...argsStr && { args: argsStr.split(" ").filter(Boolean) },
|
|
1594
|
+
...Object.keys(env).length > 0 && { env }
|
|
1595
|
+
};
|
|
1596
|
+
config.tools.push(tool);
|
|
1597
|
+
await saveConfig(config, path);
|
|
1598
|
+
console.log(`Added MCP tool: ${name}`);
|
|
1599
|
+
}
|
|
1600
|
+
async function wrapScript() {
|
|
1601
|
+
const { config, path } = await loadConfigFile();
|
|
1602
|
+
const name = await input2({ message: "Tool name:", required: true });
|
|
1603
|
+
const scriptPath = await input2({ message: "Script path:", required: true });
|
|
1604
|
+
const description = await input2({ message: "Description:", required: true });
|
|
1605
|
+
const interpreter = await input2({ message: "Interpreter:", default: "node" });
|
|
1606
|
+
const tool = {
|
|
1607
|
+
type: "script",
|
|
1608
|
+
name,
|
|
1609
|
+
path: scriptPath,
|
|
1610
|
+
description,
|
|
1611
|
+
interpreter
|
|
1612
|
+
};
|
|
1613
|
+
config.tools.push(tool);
|
|
1614
|
+
await saveConfig(config, path);
|
|
1615
|
+
console.log(`Added script tool: ${name}`);
|
|
1616
|
+
}
|
|
1617
|
+
async function wrapImport(configFilePath) {
|
|
1618
|
+
const { config, path } = await loadConfigFile();
|
|
1619
|
+
const raw = await readFile3(resolve5(configFilePath), "utf8");
|
|
1620
|
+
const desktopConfig = JSON.parse(raw);
|
|
1621
|
+
const mcpServers = desktopConfig.mcpServers ?? {};
|
|
1622
|
+
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
1623
|
+
const sc = serverConfig;
|
|
1624
|
+
const tool = {
|
|
1625
|
+
type: "mcp",
|
|
1626
|
+
name: serverName,
|
|
1627
|
+
command: sc.command,
|
|
1628
|
+
description: `Imported MCP server: ${serverName}`,
|
|
1629
|
+
...sc.args && { args: sc.args },
|
|
1630
|
+
...sc.env && { env: sc.env }
|
|
1631
|
+
};
|
|
1632
|
+
config.tools.push(tool);
|
|
1633
|
+
console.log(` Imported: ${serverName}`);
|
|
1634
|
+
}
|
|
1635
|
+
await saveConfig(config, path);
|
|
1636
|
+
console.log(`
|
|
1637
|
+
Imported ${Object.keys(mcpServers).length} MCP servers.`);
|
|
1638
|
+
}
|
|
1639
|
+
async function unwrapTool(name) {
|
|
1640
|
+
const { config, path } = await loadConfigFile();
|
|
1641
|
+
const idx = config.tools.findIndex((t) => t.name === name);
|
|
1642
|
+
if (idx === -1) {
|
|
1643
|
+
console.error(`Tool not found: ${name}`);
|
|
1644
|
+
process.exitCode = 1;
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
config.tools.splice(idx, 1);
|
|
1648
|
+
await saveConfig(config, path);
|
|
1649
|
+
console.log(`Removed tool: ${name}`);
|
|
1650
|
+
}
|
|
1651
|
+
async function generateServerPairingCode(configPath) {
|
|
1652
|
+
const { config } = await loadConfigFile(configPath);
|
|
1653
|
+
const port = config.server.port;
|
|
1654
|
+
try {
|
|
1655
|
+
const res = await fetch(`http://localhost:${port}/auth/pair/generate`);
|
|
1656
|
+
if (!res.ok) {
|
|
1657
|
+
const body = await res.text();
|
|
1658
|
+
throw new Error(`Server returned ${res.status}: ${body}`);
|
|
1659
|
+
}
|
|
1660
|
+
const data = await res.json();
|
|
1661
|
+
console.log(`
|
|
1662
|
+
Pairing code: ${data.code}`);
|
|
1663
|
+
console.log(`Expires in ${data.expiresIn / 60} minutes.`);
|
|
1664
|
+
} catch (e) {
|
|
1665
|
+
if (e.message.includes("fetch failed") || e.message.includes("ECONNREFUSED")) {
|
|
1666
|
+
console.error("Server is not running. Start it with: gigai start");
|
|
1667
|
+
} else {
|
|
1668
|
+
console.error(`Error: ${e.message}`);
|
|
1669
|
+
}
|
|
1670
|
+
process.exitCode = 1;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
var execFileAsync3 = promisify3(execFile3);
|
|
1674
|
+
function getGigaiBin() {
|
|
1675
|
+
return process.argv[1] ?? "gigai";
|
|
1676
|
+
}
|
|
1677
|
+
function getNodeBin() {
|
|
1678
|
+
return process.execPath;
|
|
1679
|
+
}
|
|
1680
|
+
function getLaunchdPlist(configPath) {
|
|
1681
|
+
const nodeBin = getNodeBin();
|
|
1682
|
+
const bin = getGigaiBin();
|
|
1683
|
+
const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
1684
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1685
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1686
|
+
<plist version="1.0">
|
|
1687
|
+
<dict>
|
|
1688
|
+
<key>Label</key>
|
|
1689
|
+
<string>com.gigai.server</string>
|
|
1690
|
+
<key>EnvironmentVariables</key>
|
|
1691
|
+
<dict>
|
|
1692
|
+
<key>PATH</key>
|
|
1693
|
+
<string>${currentPath}</string>
|
|
1694
|
+
</dict>
|
|
1695
|
+
<key>ProgramArguments</key>
|
|
1696
|
+
<array>
|
|
1697
|
+
<string>${nodeBin}</string>
|
|
1698
|
+
<string>${bin}</string>
|
|
1699
|
+
<string>start</string>
|
|
1700
|
+
<string>--config</string>
|
|
1701
|
+
<string>${configPath}</string>
|
|
1702
|
+
</array>
|
|
1703
|
+
<key>RunAtLoad</key>
|
|
1704
|
+
<true/>
|
|
1705
|
+
<key>KeepAlive</key>
|
|
1706
|
+
<true/>
|
|
1707
|
+
<key>StandardOutPath</key>
|
|
1708
|
+
<string>${join3(homedir(), ".gigai", "server.log")}</string>
|
|
1709
|
+
<key>StandardErrorPath</key>
|
|
1710
|
+
<string>${join3(homedir(), ".gigai", "server.log")}</string>
|
|
1711
|
+
<key>WorkingDirectory</key>
|
|
1712
|
+
<string>${homedir()}</string>
|
|
1713
|
+
</dict>
|
|
1714
|
+
</plist>
|
|
1715
|
+
`;
|
|
1716
|
+
}
|
|
1717
|
+
function getSystemdUnit(configPath) {
|
|
1718
|
+
const bin = getGigaiBin();
|
|
1719
|
+
return `[Unit]
|
|
1720
|
+
Description=gigai server
|
|
1721
|
+
After=network.target
|
|
1722
|
+
|
|
1723
|
+
[Service]
|
|
1724
|
+
Type=simple
|
|
1725
|
+
ExecStart=${bin} start --config ${configPath}
|
|
1726
|
+
Restart=always
|
|
1727
|
+
RestartSec=5
|
|
1728
|
+
WorkingDirectory=${homedir()}
|
|
1729
|
+
|
|
1730
|
+
[Install]
|
|
1731
|
+
WantedBy=default.target
|
|
1732
|
+
`;
|
|
1733
|
+
}
|
|
1734
|
+
async function installDaemon(configPath) {
|
|
1735
|
+
const config = resolve6(configPath ?? "gigai.config.json");
|
|
1736
|
+
const os = platform();
|
|
1737
|
+
if (os === "darwin") {
|
|
1738
|
+
const plistPath = join3(homedir(), "Library", "LaunchAgents", "com.gigai.server.plist");
|
|
1739
|
+
await writeFile4(plistPath, getLaunchdPlist(config));
|
|
1740
|
+
console.log(` Wrote launchd plist: ${plistPath}`);
|
|
1741
|
+
try {
|
|
1742
|
+
await execFileAsync3("launchctl", ["load", plistPath]);
|
|
1743
|
+
console.log(" Service loaded and started.");
|
|
1744
|
+
} catch {
|
|
1745
|
+
console.log(` Load it with: launchctl load ${plistPath}`);
|
|
1746
|
+
}
|
|
1747
|
+
console.log(` Logs: ~/.gigai/server.log`);
|
|
1748
|
+
console.log(` Stop: launchctl unload ${plistPath}`);
|
|
1749
|
+
} else if (os === "linux") {
|
|
1750
|
+
const unitDir = join3(homedir(), ".config", "systemd", "user");
|
|
1751
|
+
const unitPath = join3(unitDir, "gigai.service");
|
|
1752
|
+
const { mkdir: mkdir2 } = await import("fs/promises");
|
|
1753
|
+
await mkdir2(unitDir, { recursive: true });
|
|
1754
|
+
await writeFile4(unitPath, getSystemdUnit(config));
|
|
1755
|
+
console.log(` Wrote systemd unit: ${unitPath}`);
|
|
1756
|
+
try {
|
|
1757
|
+
await execFileAsync3("systemctl", ["--user", "daemon-reload"]);
|
|
1758
|
+
await execFileAsync3("systemctl", ["--user", "enable", "--now", "gigai"]);
|
|
1759
|
+
console.log(" Service enabled and started.");
|
|
1760
|
+
} catch {
|
|
1761
|
+
console.log(" Enable it with: systemctl --user enable --now gigai");
|
|
1762
|
+
}
|
|
1763
|
+
console.log(` Logs: journalctl --user -u gigai -f`);
|
|
1764
|
+
console.log(` Stop: systemctl --user stop gigai`);
|
|
1765
|
+
console.log(` Remove: systemctl --user disable gigai`);
|
|
1766
|
+
} else {
|
|
1767
|
+
console.log(" Persistent daemon not supported on this platform.");
|
|
1768
|
+
console.log(" Run 'gigai start' manually.");
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
async function uninstallDaemon() {
|
|
1772
|
+
const os = platform();
|
|
1773
|
+
if (os === "darwin") {
|
|
1774
|
+
const plistPath = join3(homedir(), "Library", "LaunchAgents", "com.gigai.server.plist");
|
|
1775
|
+
try {
|
|
1776
|
+
await execFileAsync3("launchctl", ["unload", plistPath]);
|
|
1777
|
+
} catch {
|
|
1778
|
+
}
|
|
1779
|
+
const { unlink: unlink2 } = await import("fs/promises");
|
|
1780
|
+
try {
|
|
1781
|
+
await unlink2(plistPath);
|
|
1782
|
+
console.log(" Service removed.");
|
|
1783
|
+
} catch {
|
|
1784
|
+
console.log(" No service found.");
|
|
1785
|
+
}
|
|
1786
|
+
} else if (os === "linux") {
|
|
1787
|
+
try {
|
|
1788
|
+
await execFileAsync3("systemctl", ["--user", "disable", "--now", "gigai"]);
|
|
1789
|
+
} catch {
|
|
1790
|
+
}
|
|
1791
|
+
const unitPath = join3(homedir(), ".config", "systemd", "user", "gigai.service");
|
|
1792
|
+
const { unlink: unlink2 } = await import("fs/promises");
|
|
1793
|
+
try {
|
|
1794
|
+
await unlink2(unitPath);
|
|
1795
|
+
await execFileAsync3("systemctl", ["--user", "daemon-reload"]);
|
|
1796
|
+
console.log(" Service removed.");
|
|
1797
|
+
} catch {
|
|
1798
|
+
console.log(" No service found.");
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
async function stopServer() {
|
|
1803
|
+
const { execFileSync } = await import("child_process");
|
|
1804
|
+
let pids = [];
|
|
1805
|
+
try {
|
|
1806
|
+
const out = execFileSync("pgrep", ["-f", "gigai start"], { encoding: "utf8" });
|
|
1807
|
+
pids = out.trim().split("\n").map(Number).filter((pid) => pid && pid !== process.pid);
|
|
1808
|
+
} catch {
|
|
1809
|
+
}
|
|
1810
|
+
if (pids.length === 0) {
|
|
1811
|
+
console.log("No running gigai server found.");
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
for (const pid of pids) {
|
|
1815
|
+
try {
|
|
1816
|
+
process.kill(pid, "SIGTERM");
|
|
1817
|
+
console.log(`Stopped gigai server (PID ${pid})`);
|
|
1818
|
+
} catch (e) {
|
|
1819
|
+
console.error(`Failed to stop PID ${pid}: ${e.message}`);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
async function startServer() {
|
|
1824
|
+
const { values } = parseArgs({
|
|
1825
|
+
options: {
|
|
1826
|
+
config: { type: "string", short: "c" },
|
|
1827
|
+
dev: { type: "boolean", default: false }
|
|
1828
|
+
},
|
|
1829
|
+
strict: false
|
|
1830
|
+
});
|
|
1831
|
+
const config = await loadConfig(values.config);
|
|
1832
|
+
const server = await createServer({ config, dev: values.dev });
|
|
1833
|
+
const port = config.server.port;
|
|
1834
|
+
const host = config.server.host;
|
|
1835
|
+
await server.listen({ port, host });
|
|
1836
|
+
server.log.info(`gigai server listening on ${host}:${port}`);
|
|
1837
|
+
let cfTunnel;
|
|
1838
|
+
const httpsProvider = config.server.https?.provider;
|
|
1839
|
+
if (httpsProvider === "tailscale") {
|
|
1840
|
+
try {
|
|
1841
|
+
const funnelUrl = await enableFunnel(port);
|
|
1842
|
+
server.log.info(`Tailscale Funnel enabled: ${funnelUrl}`);
|
|
1843
|
+
} catch (e) {
|
|
1844
|
+
server.log.error(`Failed to enable Tailscale Funnel: ${e.message}`);
|
|
1845
|
+
}
|
|
1846
|
+
} else if (httpsProvider === "cloudflare") {
|
|
1847
|
+
try {
|
|
1848
|
+
const tunnelName = config.server.https.tunnelName;
|
|
1849
|
+
cfTunnel = runTunnel(tunnelName, port);
|
|
1850
|
+
server.log.info(`Cloudflare Tunnel started: ${tunnelName}`);
|
|
1851
|
+
} catch (e) {
|
|
1852
|
+
server.log.error(`Failed to start Cloudflare Tunnel: ${e.message}`);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
const shutdown = async () => {
|
|
1856
|
+
server.log.info("Shutting down...");
|
|
1857
|
+
if (httpsProvider === "tailscale") {
|
|
1858
|
+
try {
|
|
1859
|
+
await disableFunnel(port);
|
|
1860
|
+
} catch {
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
if (cfTunnel) {
|
|
1864
|
+
try {
|
|
1865
|
+
cfTunnel.kill();
|
|
1866
|
+
} catch {
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
await server.close();
|
|
1870
|
+
process.exit(0);
|
|
1871
|
+
};
|
|
1872
|
+
process.on("SIGTERM", shutdown);
|
|
1873
|
+
process.on("SIGINT", shutdown);
|
|
1874
|
+
}
|
|
1875
|
+
export {
|
|
1876
|
+
createServer,
|
|
1877
|
+
generateServerPairingCode,
|
|
1878
|
+
installDaemon,
|
|
1879
|
+
loadConfig,
|
|
1880
|
+
runInit,
|
|
1881
|
+
startServer,
|
|
1882
|
+
stopServer,
|
|
1883
|
+
uninstallDaemon,
|
|
1884
|
+
unwrapTool,
|
|
1885
|
+
wrapCli,
|
|
1886
|
+
wrapImport,
|
|
1887
|
+
wrapMcp,
|
|
1888
|
+
wrapScript
|
|
1889
|
+
};
|