@alfe.ai/gateway 0.9.4 → 0.9.5
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/health.js +1513 -1463
- package/dist/src/index.d.ts +3 -4
- package/dist/upgrade.js +1 -1
- package/package.json +4 -4
package/dist/health.js
CHANGED
|
@@ -2,20 +2,20 @@ import { n as logger$1 } from "./logger.js";
|
|
|
2
2
|
import { a as captureFatal, c as captureRuntimeCrash, d as initAgentSentry, f as setAgentContext, l as captureRuntimeErrorOutput, o as captureIntegrationFailure, s as captureMcpFailure, u as flushSentry } from "./sentry.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
-
import { execFile, execSync, spawn } from "node:child_process";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
5
|
import { dirname, join } from "node:path";
|
|
8
6
|
import { homedir } from "node:os";
|
|
9
7
|
import pino from "pino";
|
|
10
8
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
9
|
import { getEndpointFromToken, readConfig } from "@alfe.ai/config";
|
|
12
10
|
import crypto from "crypto";
|
|
11
|
+
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
13
12
|
import { parse } from "smol-toml";
|
|
14
13
|
import WebSocket from "ws";
|
|
15
|
-
import {
|
|
14
|
+
import { execFile, execSync, spawn } from "node:child_process";
|
|
16
15
|
import { ClaudeCodeApplier, ClaudeCodeMcpSync, HermesApplier, HermesMcpSync, IntegrationManager, IntegrationManagerAdapter, McpApplier, NoopOpenClawCliLock, OpenClawApplier, SerialOpenClawCliLock } from "@alfe.ai/integrations";
|
|
17
|
-
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
18
16
|
import { Manager, McpBundler, defaultConnect } from "@alfe.ai/mcp-bundler";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { createConnection, createServer } from "node:net";
|
|
19
19
|
import stream, { Readable } from "stream";
|
|
20
20
|
import util, { format } from "util";
|
|
21
21
|
import http from "http";
|
|
@@ -5204,63 +5204,54 @@ async function loadDaemonConfig() {
|
|
|
5204
5204
|
* Returns null if the agent has no template assigned or the fetch fails.
|
|
5205
5205
|
*/
|
|
5206
5206
|
async function fetchAgentConfig(apiKey, apiEndpoint) {
|
|
5207
|
+
const client = new AgentApiClient({
|
|
5208
|
+
apiKey,
|
|
5209
|
+
apiUrl: apiEndpoint
|
|
5210
|
+
});
|
|
5211
|
+
logger$1.debug({ apiEndpoint }, "Fetching agent workspace config...");
|
|
5212
|
+
let workspace;
|
|
5207
5213
|
try {
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
logger$1.debug({
|
|
5232
|
-
templateKey,
|
|
5233
|
-
version: pinnedVersion
|
|
5234
|
-
}, "Fetching template files...");
|
|
5235
|
-
const filesResponse = await fetch(filesUrl.toString(), {
|
|
5236
|
-
method: "GET",
|
|
5237
|
-
headers: { "Authorization": `Bearer ${apiKey}` }
|
|
5238
|
-
});
|
|
5239
|
-
if (!filesResponse.ok) {
|
|
5240
|
-
logger$1.debug({ status: filesResponse.status }, "Template files fetch failed");
|
|
5241
|
-
return {
|
|
5242
|
-
templateKey,
|
|
5243
|
-
defaultModel,
|
|
5244
|
-
files: {}
|
|
5245
|
-
};
|
|
5246
|
-
}
|
|
5247
|
-
const files = (await filesResponse.json()).data?.files ?? {};
|
|
5248
|
-
const fileCount = Object.keys(files).length;
|
|
5249
|
-
logger$1.debug({
|
|
5250
|
-
templateKey,
|
|
5251
|
-
version: pinnedVersion,
|
|
5252
|
-
fileCount
|
|
5253
|
-
}, "Template files fetched");
|
|
5214
|
+
workspace = await client.getWorkspace();
|
|
5215
|
+
} catch (err) {
|
|
5216
|
+
logger$1.debug({ err: err instanceof Error ? err.message : String(err) }, "Workspace config fetch failed");
|
|
5217
|
+
return null;
|
|
5218
|
+
}
|
|
5219
|
+
const { templateKey, installedFrom } = workspace;
|
|
5220
|
+
const defaultModel = workspace.defaultModel ?? void 0;
|
|
5221
|
+
if (!templateKey) {
|
|
5222
|
+
logger$1.debug("No templateKey in workspace response");
|
|
5223
|
+
return defaultModel ? {
|
|
5224
|
+
defaultModel,
|
|
5225
|
+
files: {}
|
|
5226
|
+
} : null;
|
|
5227
|
+
}
|
|
5228
|
+
const pinnedVersion = installedFrom?.templateKey === templateKey ? installedFrom.version : void 0;
|
|
5229
|
+
logger$1.debug({
|
|
5230
|
+
templateKey,
|
|
5231
|
+
version: pinnedVersion
|
|
5232
|
+
}, "Fetching template files...");
|
|
5233
|
+
let files;
|
|
5234
|
+
try {
|
|
5235
|
+
files = (await client.getTemplateFiles(templateKey, { version: pinnedVersion })).files;
|
|
5236
|
+
} catch (err) {
|
|
5237
|
+
logger$1.debug({ err: err instanceof Error ? err.message : String(err) }, "Template files fetch failed");
|
|
5254
5238
|
return {
|
|
5255
5239
|
templateKey,
|
|
5256
5240
|
defaultModel,
|
|
5257
|
-
files
|
|
5258
|
-
personaFiles: Object.keys(files)
|
|
5241
|
+
files: {}
|
|
5259
5242
|
};
|
|
5260
|
-
} catch (err) {
|
|
5261
|
-
logger$1.debug({ err: err instanceof Error ? err.message : String(err) }, "fetchAgentConfig failed");
|
|
5262
|
-
return null;
|
|
5263
5243
|
}
|
|
5244
|
+
logger$1.debug({
|
|
5245
|
+
templateKey,
|
|
5246
|
+
version: pinnedVersion,
|
|
5247
|
+
fileCount: Object.keys(files).length
|
|
5248
|
+
}, "Template files fetched");
|
|
5249
|
+
return {
|
|
5250
|
+
templateKey,
|
|
5251
|
+
defaultModel,
|
|
5252
|
+
files,
|
|
5253
|
+
personaFiles: Object.keys(files)
|
|
5254
|
+
};
|
|
5264
5255
|
}
|
|
5265
5256
|
//#endregion
|
|
5266
5257
|
//#region src/protocol.ts
|
|
@@ -6360,190 +6351,759 @@ var ConfigReconciler = class {
|
|
|
6360
6351
|
}
|
|
6361
6352
|
};
|
|
6362
6353
|
//#endregion
|
|
6363
|
-
//#region src/
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
*
|
|
6372
|
-
* Each connected plugin registers with its name/version and receives
|
|
6373
|
-
* commands from the daemon (forwarded from cloud).
|
|
6374
|
-
*/
|
|
6375
|
-
var IPCServer = class {
|
|
6376
|
-
server = null;
|
|
6377
|
-
connections = /* @__PURE__ */ new Map();
|
|
6378
|
-
socketPath;
|
|
6379
|
-
requestHandler = null;
|
|
6380
|
-
constructor(socketPath) {
|
|
6381
|
-
this.socketPath = socketPath;
|
|
6382
|
-
}
|
|
6383
|
-
/**
|
|
6384
|
-
* Set the handler for incoming IPC requests from plugins.
|
|
6385
|
-
*/
|
|
6386
|
-
setRequestHandler(handler) {
|
|
6387
|
-
this.requestHandler = handler;
|
|
6354
|
+
//#region src/command-queue.ts
|
|
6355
|
+
const DEFAULT_TTL_MS$1 = 300 * 1e3;
|
|
6356
|
+
var CommandQueue = class {
|
|
6357
|
+
queues = /* @__PURE__ */ new Map();
|
|
6358
|
+
ttlMs;
|
|
6359
|
+
gcTimer = null;
|
|
6360
|
+
constructor(ttlMs = DEFAULT_TTL_MS$1) {
|
|
6361
|
+
this.ttlMs = ttlMs;
|
|
6388
6362
|
}
|
|
6389
6363
|
/**
|
|
6390
|
-
* Start
|
|
6364
|
+
* Start periodic garbage collection of expired commands.
|
|
6391
6365
|
*/
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
} catch {}
|
|
6396
|
-
return new Promise((resolve, reject) => {
|
|
6397
|
-
this.server = createServer((socket) => {
|
|
6398
|
-
this.handleConnection(socket);
|
|
6399
|
-
});
|
|
6400
|
-
this.server.on("error", (err) => {
|
|
6401
|
-
logger$1.error({ err: err.message }, "IPC server error");
|
|
6402
|
-
reject(err);
|
|
6403
|
-
});
|
|
6404
|
-
this.server.listen(this.socketPath, () => {
|
|
6405
|
-
try {
|
|
6406
|
-
chmodSync(this.socketPath, 384);
|
|
6407
|
-
} catch (err) {
|
|
6408
|
-
logger$1.warn({ err }, "Failed to chmod socket");
|
|
6409
|
-
}
|
|
6410
|
-
logger$1.info({ path: this.socketPath }, "IPC server listening");
|
|
6411
|
-
resolve();
|
|
6412
|
-
});
|
|
6413
|
-
});
|
|
6366
|
+
startGC(intervalMs = 3e4) {
|
|
6367
|
+
this.stopGC();
|
|
6368
|
+
this.gcTimer = setInterval(() => this.purgeExpired(), intervalMs);
|
|
6414
6369
|
}
|
|
6415
6370
|
/**
|
|
6416
|
-
* Stop
|
|
6371
|
+
* Stop periodic garbage collection.
|
|
6417
6372
|
*/
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
this.
|
|
6421
|
-
|
|
6422
|
-
}
|
|
6423
|
-
return new Promise((resolve) => {
|
|
6424
|
-
if (!this.server) {
|
|
6425
|
-
resolve();
|
|
6426
|
-
return;
|
|
6427
|
-
}
|
|
6428
|
-
this.server.close(() => {
|
|
6429
|
-
try {
|
|
6430
|
-
unlinkSync(this.socketPath);
|
|
6431
|
-
} catch {}
|
|
6432
|
-
logger$1.info("IPC server stopped");
|
|
6433
|
-
resolve();
|
|
6434
|
-
});
|
|
6435
|
-
for (const conn of this.connections.values()) conn.socket.destroy();
|
|
6436
|
-
this.connections.clear();
|
|
6437
|
-
});
|
|
6373
|
+
stopGC() {
|
|
6374
|
+
if (this.gcTimer) {
|
|
6375
|
+
clearInterval(this.gcTimer);
|
|
6376
|
+
this.gcTimer = null;
|
|
6377
|
+
}
|
|
6438
6378
|
}
|
|
6439
6379
|
/**
|
|
6440
|
-
*
|
|
6380
|
+
* Enqueue a command for a specific service.
|
|
6441
6381
|
*/
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
if (!
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
const id = pluginConnectionId();
|
|
6453
|
-
const request = {
|
|
6454
|
-
type: "req",
|
|
6455
|
-
id,
|
|
6456
|
-
method,
|
|
6457
|
-
params
|
|
6458
|
-
};
|
|
6459
|
-
return new Promise((resolve, reject) => {
|
|
6460
|
-
const timer = setTimeout(() => {
|
|
6461
|
-
conn.pending.delete(id);
|
|
6462
|
-
resolve({
|
|
6463
|
-
id,
|
|
6464
|
-
ok: false,
|
|
6465
|
-
error: {
|
|
6466
|
-
code: "TIMEOUT",
|
|
6467
|
-
message: `Request ${method} timed out after ${String(timeoutMs)}ms`
|
|
6468
|
-
}
|
|
6469
|
-
});
|
|
6470
|
-
}, timeoutMs);
|
|
6471
|
-
conn.pending.set(id, {
|
|
6472
|
-
resolve,
|
|
6473
|
-
reject,
|
|
6474
|
-
timer
|
|
6475
|
-
});
|
|
6476
|
-
try {
|
|
6477
|
-
conn.socket.write(JSON.stringify(request) + "\n");
|
|
6478
|
-
} catch (err) {
|
|
6479
|
-
clearTimeout(timer);
|
|
6480
|
-
conn.pending.delete(id);
|
|
6481
|
-
resolve({
|
|
6482
|
-
id,
|
|
6483
|
-
ok: false,
|
|
6484
|
-
error: {
|
|
6485
|
-
code: "SEND_FAILED",
|
|
6486
|
-
message: `Failed to send to plugin: ${err instanceof Error ? err.message : String(err)}`
|
|
6487
|
-
}
|
|
6488
|
-
});
|
|
6489
|
-
}
|
|
6382
|
+
enqueue(serviceId, request, commandId) {
|
|
6383
|
+
let queue = this.queues.get(serviceId);
|
|
6384
|
+
if (!queue) {
|
|
6385
|
+
queue = [];
|
|
6386
|
+
this.queues.set(serviceId, queue);
|
|
6387
|
+
}
|
|
6388
|
+
queue.push({
|
|
6389
|
+
request,
|
|
6390
|
+
queuedAt: Date.now(),
|
|
6391
|
+
commandId
|
|
6490
6392
|
});
|
|
6491
6393
|
}
|
|
6492
6394
|
/**
|
|
6493
|
-
*
|
|
6494
|
-
* Returns
|
|
6395
|
+
* Drain all pending (non-expired) commands for a service.
|
|
6396
|
+
* Returns them in order and removes them from the queue.
|
|
6495
6397
|
*/
|
|
6496
|
-
|
|
6497
|
-
const
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
return results;
|
|
6398
|
+
drain(serviceId) {
|
|
6399
|
+
const queue = this.queues.get(serviceId);
|
|
6400
|
+
if (!queue || queue.length === 0) return [];
|
|
6401
|
+
const now = Date.now();
|
|
6402
|
+
const valid = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
|
|
6403
|
+
this.queues.delete(serviceId);
|
|
6404
|
+
return valid;
|
|
6504
6405
|
}
|
|
6505
6406
|
/**
|
|
6506
|
-
*
|
|
6407
|
+
* Get the number of pending commands for a service.
|
|
6507
6408
|
*/
|
|
6508
|
-
|
|
6509
|
-
const
|
|
6510
|
-
if (!
|
|
6511
|
-
|
|
6409
|
+
pendingCount(serviceId) {
|
|
6410
|
+
const queue = this.queues.get(serviceId);
|
|
6411
|
+
if (!queue) return 0;
|
|
6412
|
+
const now = Date.now();
|
|
6413
|
+
return queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs).length;
|
|
6512
6414
|
}
|
|
6513
6415
|
/**
|
|
6514
|
-
*
|
|
6416
|
+
* Get total pending commands across all services.
|
|
6515
6417
|
*/
|
|
6516
|
-
|
|
6517
|
-
|
|
6418
|
+
totalPending() {
|
|
6419
|
+
let total = 0;
|
|
6420
|
+
for (const [serviceId] of this.queues) total += this.pendingCount(serviceId);
|
|
6421
|
+
return total;
|
|
6518
6422
|
}
|
|
6519
6423
|
/**
|
|
6520
|
-
*
|
|
6424
|
+
* Remove expired commands from all queues.
|
|
6521
6425
|
*/
|
|
6522
|
-
|
|
6523
|
-
const
|
|
6524
|
-
|
|
6525
|
-
|
|
6426
|
+
purgeExpired() {
|
|
6427
|
+
const now = Date.now();
|
|
6428
|
+
let purged = 0;
|
|
6429
|
+
for (const [serviceId, queue] of this.queues) {
|
|
6430
|
+
const before = queue.length;
|
|
6431
|
+
const remaining = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
|
|
6432
|
+
if (remaining.length === 0) this.queues.delete(serviceId);
|
|
6433
|
+
else this.queues.set(serviceId, remaining);
|
|
6434
|
+
purged += before - remaining.length;
|
|
6435
|
+
}
|
|
6436
|
+
return purged;
|
|
6526
6437
|
}
|
|
6527
6438
|
/**
|
|
6528
|
-
*
|
|
6439
|
+
* Clear all queues.
|
|
6529
6440
|
*/
|
|
6530
|
-
|
|
6531
|
-
|
|
6441
|
+
clear() {
|
|
6442
|
+
this.queues.clear();
|
|
6532
6443
|
}
|
|
6533
6444
|
/**
|
|
6534
|
-
* Get
|
|
6445
|
+
* Get all service IDs that have pending commands.
|
|
6535
6446
|
*/
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
for (const conn of this.connections.values()) if (conn.info) count++;
|
|
6539
|
-
return count;
|
|
6447
|
+
serviceIds() {
|
|
6448
|
+
return Array.from(this.queues.keys());
|
|
6540
6449
|
}
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6450
|
+
};
|
|
6451
|
+
//#endregion
|
|
6452
|
+
//#region src/process-manager.ts
|
|
6453
|
+
/**
|
|
6454
|
+
* Process management — launchd/systemd service installation.
|
|
6455
|
+
*
|
|
6456
|
+
* Generates and installs user-space service units for auto-start on boot.
|
|
6457
|
+
* No root required — uses user agents (Mac) or user units (Linux).
|
|
6458
|
+
*/
|
|
6459
|
+
const LAUNCHD_LABEL = "ai.alfe.gateway";
|
|
6460
|
+
const SYSTEMD_SERVICE = "alfe-gateway";
|
|
6461
|
+
/**
|
|
6462
|
+
* On-disk path for the boot-time self-heal guard script (Linux only).
|
|
6463
|
+
* Written next to the systemd unit at setup time and invoked via
|
|
6464
|
+
* `ExecStartPre=` — see `writeGuardScript` and `generateSystemdUnit`.
|
|
6465
|
+
*/
|
|
6466
|
+
function getGuardScriptPath() {
|
|
6467
|
+
return isRootUser() ? "/usr/local/lib/alfe/alfe-cli-guard.sh" : join(homedir(), ".alfe", "alfe-cli-guard.sh");
|
|
6468
|
+
}
|
|
6469
|
+
function isRootUser() {
|
|
6470
|
+
return process.getuid?.() === 0;
|
|
6471
|
+
}
|
|
6472
|
+
function getSystemdSystemServicePath() {
|
|
6473
|
+
return `/etc/systemd/system/${SYSTEMD_SERVICE}.service`;
|
|
6474
|
+
}
|
|
6475
|
+
function getLaunchdPlistPath() {
|
|
6476
|
+
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
6477
|
+
}
|
|
6478
|
+
function getSystemdServicePath() {
|
|
6479
|
+
return join(homedir(), ".config", "systemd", "user", `${SYSTEMD_SERVICE}.service`);
|
|
6480
|
+
}
|
|
6481
|
+
/**
|
|
6482
|
+
* Resolve the alfe CLI path. Globally installed via npm, always in PATH.
|
|
6483
|
+
*/
|
|
6484
|
+
function getAlfeBinPath() {
|
|
6485
|
+
try {
|
|
6486
|
+
return execSync("which alfe", { encoding: "utf-8" }).trim();
|
|
6487
|
+
} catch {
|
|
6488
|
+
return "alfe";
|
|
6489
|
+
}
|
|
6490
|
+
}
|
|
6491
|
+
/**
|
|
6492
|
+
* Build the PATH for the launchd plist, ensuring the directory containing the
|
|
6493
|
+
* resolved alfe binary is included (e.g. /opt/homebrew/bin on Apple Silicon).
|
|
6494
|
+
*/
|
|
6495
|
+
function buildLaunchdPath(alfeBin) {
|
|
6496
|
+
const basePaths = [
|
|
6497
|
+
"/usr/local/bin",
|
|
6498
|
+
"/usr/bin",
|
|
6499
|
+
"/bin",
|
|
6500
|
+
join(homedir(), ".local", "bin")
|
|
6501
|
+
];
|
|
6502
|
+
const binDir = dirname(alfeBin);
|
|
6503
|
+
if (binDir && !basePaths.includes(binDir)) basePaths.unshift(binDir);
|
|
6504
|
+
return basePaths.join(":");
|
|
6505
|
+
}
|
|
6506
|
+
/**
|
|
6507
|
+
* Generate a launchd plist for macOS.
|
|
6508
|
+
*/
|
|
6509
|
+
function generateLaunchdPlist() {
|
|
6510
|
+
const alfeBin = getAlfeBinPath();
|
|
6511
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
6512
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
6513
|
+
<plist version="1.0">
|
|
6514
|
+
<dict>
|
|
6515
|
+
<key>Label</key>
|
|
6516
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
6517
|
+
<key>ProgramArguments</key>
|
|
6518
|
+
<array>
|
|
6519
|
+
<string>${alfeBin}</string>
|
|
6520
|
+
<string>gateway</string>
|
|
6521
|
+
<string>daemon</string>
|
|
6522
|
+
</array>
|
|
6523
|
+
<key>RunAtLoad</key>
|
|
6524
|
+
<true/>
|
|
6525
|
+
<key>KeepAlive</key>
|
|
6526
|
+
<true/>
|
|
6527
|
+
<key>ThrottleInterval</key>
|
|
6528
|
+
<integer>10</integer>
|
|
6529
|
+
<key>StandardOutPath</key>
|
|
6530
|
+
<string>${join(homedir(), ".alfe", "logs", "gateway.log")}</string>
|
|
6531
|
+
<key>StandardErrorPath</key>
|
|
6532
|
+
<string>${join(homedir(), ".alfe", "logs", "gateway.err.log")}</string>
|
|
6533
|
+
<key>EnvironmentVariables</key>
|
|
6534
|
+
<dict>
|
|
6535
|
+
<key>NODE_ENV</key>
|
|
6536
|
+
<string>production</string>
|
|
6537
|
+
<key>PATH</key>
|
|
6538
|
+
<string>${buildLaunchdPath(alfeBin)}</string>
|
|
6539
|
+
</dict>
|
|
6540
|
+
</dict>
|
|
6541
|
+
</plist>`;
|
|
6542
|
+
}
|
|
6543
|
+
/**
|
|
6544
|
+
* Boot-time self-heal guard (Linux only).
|
|
6545
|
+
*
|
|
6546
|
+
* Runs as an `ExecStartPre=` BEFORE the daemon launches, OUTSIDE the
|
|
6547
|
+
* (possibly-broken) `alfe` binary. It exists for the case defense #1
|
|
6548
|
+
* (verify-before-exit in `upgrade.ts`) structurally cannot cover: the daemon
|
|
6549
|
+
* is KILLED during `npm install -g @alfe.ai/cli` (reboot / OOM), leaving the
|
|
6550
|
+
* package with deps but no `dist/` and a dangling `/usr/bin/alfe` symlink.
|
|
6551
|
+
* systemd then execs a dangling binary forever → 203/EXEC crash-loop, a
|
|
6552
|
+
* permanent brick with no automatic recovery (real prod incident,
|
|
6553
|
+
* ~13,570 restarts over 2 days).
|
|
6554
|
+
*
|
|
6555
|
+
* The guard:
|
|
6556
|
+
* - checks the CLI is intact: the symlink target (`dist/index.js`) exists AND
|
|
6557
|
+
* `alfe --version` exits 0;
|
|
6558
|
+
* - if broken, reinstalls `@alfe.ai/cli@$ALFE_CLI_VERSION` (the exact version
|
|
6559
|
+
* baked into the unit's `Environment=`; falls back to `@latest` when
|
|
6560
|
+
* unset), so the daemon then execs a working binary;
|
|
6561
|
+
* - is idempotent + fast (no-op when healthy) and NEVER wedges boot: any of
|
|
6562
|
+
* its own failures log + `exit 0` so a human can still SSH in.
|
|
6563
|
+
*
|
|
6564
|
+
* POSIX sh (dash-safe): the box may not have bash. Keep the two intactness
|
|
6565
|
+
* checks in lockstep with `verifyCliInstall` in `verify-cli-install.ts`.
|
|
6566
|
+
*/
|
|
6567
|
+
/** @internal exported for unit tests; not re-exported from the barrel. */
|
|
6568
|
+
function generateGuardScript() {
|
|
6569
|
+
const version = process.env.ALFE_CLI_VERSION;
|
|
6570
|
+
return `#!/bin/sh
|
|
6571
|
+
# Alfe CLI boot-time self-heal guard. Auto-generated by 'alfe setup' — do not edit.
|
|
6572
|
+
# Repairs an interrupted 'npm install -g @alfe.ai/cli' before the daemon starts,
|
|
6573
|
+
# so a mid-install reboot can't brick the agent with a 203/EXEC crash-loop.
|
|
6574
|
+
# Must never wedge boot: every failure path logs and exits 0.
|
|
6575
|
+
set -u
|
|
6576
|
+
|
|
6577
|
+
TARGET='${version && version.length > 0 ? `@alfe.ai/cli@${version}` : "@alfe.ai/cli@latest"}'
|
|
6578
|
+
log() { echo "[alfe-cli-guard] $*" >&2; }
|
|
6579
|
+
|
|
6580
|
+
# Resolve the alfe bin and its real target (dist/index.js). readlink -f follows
|
|
6581
|
+
# the whole symlink chain; fall back to the bin path itself if unavailable.
|
|
6582
|
+
BIN="$(command -v alfe 2>/dev/null || true)"
|
|
6583
|
+
if [ -n "$BIN" ]; then
|
|
6584
|
+
ENTRY="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")"
|
|
6585
|
+
else
|
|
6586
|
+
ENTRY=""
|
|
6587
|
+
fi
|
|
6588
|
+
|
|
6589
|
+
healthy=1
|
|
6590
|
+
if [ -z "$ENTRY" ] || [ ! -f "$ENTRY" ]; then
|
|
6591
|
+
healthy=0
|
|
6592
|
+
log "CLI entry missing (bin=\${BIN:-<none>} entry=\${ENTRY:-<none>})"
|
|
6593
|
+
elif ! alfe --version >/dev/null 2>&1; then
|
|
6594
|
+
healthy=0
|
|
6595
|
+
log "alfe --version failed"
|
|
6596
|
+
fi
|
|
6597
|
+
|
|
6598
|
+
if [ "$healthy" -eq 1 ]; then
|
|
6599
|
+
exit 0
|
|
6600
|
+
fi
|
|
6601
|
+
|
|
6602
|
+
log "CLI install looks broken — reinstalling $TARGET"
|
|
6603
|
+
# Blow away a partial tree first so npm re-materialises dist/ cleanly. Best-effort.
|
|
6604
|
+
GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"
|
|
6605
|
+
if [ -n "$GLOBAL_ROOT" ] && [ -d "$GLOBAL_ROOT/@alfe.ai/cli" ]; then
|
|
6606
|
+
rm -rf "$GLOBAL_ROOT/@alfe.ai/cli" 2>/dev/null || true
|
|
6607
|
+
fi
|
|
6608
|
+
|
|
6609
|
+
# Bound the reinstall so a slow/unreachable registry at boot can't stall the
|
|
6610
|
+
# ExecStartPre up to systemd's DefaultTimeoutStartSec (~90s). Two layers:
|
|
6611
|
+
# - npm --fetch-timeout/--fetch-retries cap per-request/hung-socket time;
|
|
6612
|
+
# - a wall-clock 'timeout' wrapper caps the whole install when present
|
|
6613
|
+
# ('timeout' isn't guaranteed, so fall back to a bare install).
|
|
6614
|
+
# stdout->stderr ('>&2') so npm's chatter lands in the journal, not on fd1.
|
|
6615
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
6616
|
+
timeout 120 npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
|
|
6617
|
+
rc=$?
|
|
6618
|
+
else
|
|
6619
|
+
npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
|
|
6620
|
+
rc=$?
|
|
6621
|
+
fi
|
|
6622
|
+
if [ "$rc" -eq 0 ]; then
|
|
6623
|
+
log "reinstall succeeded"
|
|
6624
|
+
else
|
|
6625
|
+
log "reinstall FAILED (rc=$rc) — daemon start may still fail; a human can SSH in to repair"
|
|
6626
|
+
fi
|
|
6627
|
+
|
|
6628
|
+
# Always exit 0: even a failed repair must not block the unit from attempting
|
|
6629
|
+
# ExecStart (which then fails visibly) rather than wedging silently in pre-start.
|
|
6630
|
+
exit 0
|
|
6631
|
+
`;
|
|
6632
|
+
}
|
|
6633
|
+
/**
|
|
6634
|
+
* Generate a systemd unit for Linux.
|
|
6635
|
+
* Root users get a system-level unit; non-root get a user-level unit.
|
|
6636
|
+
*/
|
|
6637
|
+
/** @internal exported for unit tests; not re-exported from the barrel. */
|
|
6638
|
+
function generateSystemdUnit() {
|
|
6639
|
+
const alfeBin = getAlfeBinPath();
|
|
6640
|
+
const root = isRootUser();
|
|
6641
|
+
const envLines = [
|
|
6642
|
+
"ALFE_MANAGED",
|
|
6643
|
+
"ALFE_API_KEY",
|
|
6644
|
+
"LOG_LEVEL",
|
|
6645
|
+
"ALFE_CLI_VERSION"
|
|
6646
|
+
].filter((key) => process.env[key]).map((key) => `Environment=${key}=${process.env[key] ?? ""}`).join("\n");
|
|
6647
|
+
return `[Unit]
|
|
6648
|
+
Description=Alfe Gateway Daemon
|
|
6649
|
+
After=network-online.target
|
|
6650
|
+
Wants=network-online.target
|
|
6651
|
+
|
|
6652
|
+
[Service]
|
|
6653
|
+
Type=simple
|
|
6654
|
+
ExecStartPre=-/bin/sh ${getGuardScriptPath()}
|
|
6655
|
+
ExecStart=${alfeBin} gateway daemon
|
|
6656
|
+
Restart=always
|
|
6657
|
+
RestartSec=10
|
|
6658
|
+
# SIGTERM only the daemon on stop/restart; it closes its MCP/runtime children
|
|
6659
|
+
# itself. The default (control-group) SIGTERMs the children simultaneously, so
|
|
6660
|
+
# they die before the daemon's orderly dispose reaches them and every planned
|
|
6661
|
+
# restart reports MCP "server-crash" noise to Sentry. Stragglers still get
|
|
6662
|
+
# SIGKILL when the stop timeout expires.
|
|
6663
|
+
KillMode=mixed
|
|
6664
|
+
Environment=NODE_ENV=production${root ? "\nEnvironment=HOME=/root\nWorkingDirectory=/root" : ""}
|
|
6665
|
+
${envLines}
|
|
6666
|
+
|
|
6667
|
+
[Install]
|
|
6668
|
+
WantedBy=${root ? "multi-user.target" : "default.target"}`;
|
|
6669
|
+
}
|
|
6670
|
+
/**
|
|
6671
|
+
* Write the boot-time self-heal guard script to disk (Linux only) and make it
|
|
6672
|
+
* executable. Called from `installSystemd` before the unit is written.
|
|
6673
|
+
*/
|
|
6674
|
+
async function writeGuardScript() {
|
|
6675
|
+
const guardPath = getGuardScriptPath();
|
|
6676
|
+
await mkdir(dirname(guardPath), { recursive: true });
|
|
6677
|
+
await writeFile(guardPath, generateGuardScript(), {
|
|
6678
|
+
encoding: "utf-8",
|
|
6679
|
+
mode: 493
|
|
6680
|
+
});
|
|
6681
|
+
logger$1.info({ path: guardPath }, "Wrote CLI self-heal guard script");
|
|
6682
|
+
}
|
|
6683
|
+
/**
|
|
6684
|
+
* Install the service unit for the current platform.
|
|
6685
|
+
*/
|
|
6686
|
+
async function installService() {
|
|
6687
|
+
const platform = process.platform;
|
|
6688
|
+
if (platform === "darwin") return installLaunchd();
|
|
6689
|
+
if (platform === "linux") return installSystemd();
|
|
6690
|
+
throw new Error(`Unsupported platform: ${platform}. Only macOS and Linux are supported.`);
|
|
6691
|
+
}
|
|
6692
|
+
/**
|
|
6693
|
+
* Uninstall the service unit for the current platform.
|
|
6694
|
+
*/
|
|
6695
|
+
async function uninstallService() {
|
|
6696
|
+
const platform = process.platform;
|
|
6697
|
+
if (platform === "darwin") return uninstallLaunchd();
|
|
6698
|
+
if (platform === "linux") return uninstallSystemd();
|
|
6699
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
6700
|
+
}
|
|
6701
|
+
function getLaunchdUid() {
|
|
6702
|
+
return execSync("id -u", { encoding: "utf-8" }).trim();
|
|
6703
|
+
}
|
|
6704
|
+
async function installLaunchd() {
|
|
6705
|
+
const plistPath = getLaunchdPlistPath();
|
|
6706
|
+
const dir = join(homedir(), "Library", "LaunchAgents");
|
|
6707
|
+
const logsDir = join(homedir(), ".alfe", "logs");
|
|
6708
|
+
await mkdir(dir, { recursive: true });
|
|
6709
|
+
await mkdir(logsDir, { recursive: true });
|
|
6710
|
+
await writeFile(plistPath, generateLaunchdPlist(), "utf-8");
|
|
6711
|
+
logger$1.info({ path: plistPath }, "Wrote launchd plist");
|
|
6712
|
+
const uid = getLaunchdUid();
|
|
6713
|
+
try {
|
|
6714
|
+
execSync(`launchctl bootout gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
|
|
6715
|
+
} catch {}
|
|
6716
|
+
try {
|
|
6717
|
+
execSync(`launchctl bootstrap gui/${uid} ${plistPath}`, { stdio: "pipe" });
|
|
6718
|
+
} catch (err) {
|
|
6719
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6720
|
+
logger$1.error({
|
|
6721
|
+
err: msg,
|
|
6722
|
+
plistPath
|
|
6723
|
+
}, "launchctl bootstrap failed");
|
|
6724
|
+
return `Installed plist at ${plistPath}, but failed to bootstrap service: ${msg}`;
|
|
6725
|
+
}
|
|
6726
|
+
return `Installed: ${plistPath}\nService will start on boot and restart on crash.`;
|
|
6727
|
+
}
|
|
6728
|
+
async function uninstallLaunchd() {
|
|
6729
|
+
const plistPath = getLaunchdPlistPath();
|
|
6730
|
+
const uid = getLaunchdUid();
|
|
6731
|
+
try {
|
|
6732
|
+
execSync(`launchctl bootout gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
|
|
6733
|
+
} catch {}
|
|
6734
|
+
try {
|
|
6735
|
+
await unlink(plistPath);
|
|
6736
|
+
} catch {}
|
|
6737
|
+
return `Uninstalled: ${plistPath}`;
|
|
6738
|
+
}
|
|
6739
|
+
async function installSystemd() {
|
|
6740
|
+
const root = isRootUser();
|
|
6741
|
+
const unitPath = root ? getSystemdSystemServicePath() : getSystemdServicePath();
|
|
6742
|
+
const dir = root ? "/etc/systemd/system" : join(homedir(), ".config", "systemd", "user");
|
|
6743
|
+
const ctl = root ? "systemctl" : "systemctl --user";
|
|
6744
|
+
if (!root) await mkdir(dir, { recursive: true });
|
|
6745
|
+
await writeGuardScript();
|
|
6746
|
+
await writeFile(unitPath, generateSystemdUnit(), "utf-8");
|
|
6747
|
+
logger$1.info({
|
|
6748
|
+
path: unitPath,
|
|
6749
|
+
root
|
|
6750
|
+
}, "Wrote systemd unit");
|
|
6751
|
+
try {
|
|
6752
|
+
execSync(`${ctl} daemon-reload`, { stdio: "pipe" });
|
|
6753
|
+
execSync(`${ctl} enable ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
6754
|
+
} catch {}
|
|
6755
|
+
return `Installed: ${unitPath}\nService enabled${root ? " (system-level)" : " for user session"}.`;
|
|
6756
|
+
}
|
|
6757
|
+
async function uninstallSystemd() {
|
|
6758
|
+
const root = isRootUser();
|
|
6759
|
+
const unitPath = root ? getSystemdSystemServicePath() : getSystemdServicePath();
|
|
6760
|
+
const ctl = root ? "systemctl" : "systemctl --user";
|
|
6761
|
+
try {
|
|
6762
|
+
execSync(`${ctl} disable ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
6763
|
+
execSync(`${ctl} stop ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
6764
|
+
} catch {}
|
|
6765
|
+
try {
|
|
6766
|
+
await unlink(unitPath);
|
|
6767
|
+
} catch {}
|
|
6768
|
+
try {
|
|
6769
|
+
await unlink(getGuardScriptPath());
|
|
6770
|
+
} catch {}
|
|
6771
|
+
try {
|
|
6772
|
+
execSync(`${ctl} daemon-reload`, { stdio: "pipe" });
|
|
6773
|
+
} catch {}
|
|
6774
|
+
return `Uninstalled: ${unitPath}`;
|
|
6775
|
+
}
|
|
6776
|
+
/**
|
|
6777
|
+
* Bootstrap (load) the launchd service from its plist if it isn't already
|
|
6778
|
+
* loaded. Already-loaded is an error we ignore. Needed so `start`/`restart`
|
|
6779
|
+
* work after `stopService()` boots the service OUT.
|
|
6780
|
+
*/
|
|
6781
|
+
function ensureLaunchdLoaded(uid) {
|
|
6782
|
+
try {
|
|
6783
|
+
execSync(`launchctl bootstrap gui/${uid} ${getLaunchdPlistPath()}`, { stdio: "pipe" });
|
|
6784
|
+
} catch {}
|
|
6785
|
+
}
|
|
6786
|
+
/**
|
|
6787
|
+
* Start the installed service via systemctl/launchctl. Bootstraps the launchd
|
|
6788
|
+
* service first so this also works after `stopService()` booted it out.
|
|
6789
|
+
*/
|
|
6790
|
+
function startService() {
|
|
6791
|
+
const platform = process.platform;
|
|
6792
|
+
if (platform === "darwin") {
|
|
6793
|
+
const uid = getLaunchdUid();
|
|
6794
|
+
ensureLaunchdLoaded(uid);
|
|
6795
|
+
execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
|
|
6796
|
+
logger$1.info("Started launchd service");
|
|
6797
|
+
return;
|
|
6798
|
+
}
|
|
6799
|
+
if (platform === "linux") {
|
|
6800
|
+
execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} start ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
6801
|
+
logger$1.info("Started systemd service");
|
|
6802
|
+
return;
|
|
6803
|
+
}
|
|
6804
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
6805
|
+
}
|
|
6806
|
+
/**
|
|
6807
|
+
* Restart the installed service via systemctl/launchctl. This is the
|
|
6808
|
+
* service-manager-native restart (`kickstart -k` / `systemctl restart`) — the
|
|
6809
|
+
* CLI uses it instead of killing the process inline, which would fight launchd
|
|
6810
|
+
* `KeepAlive` / systemd `Restart=always`.
|
|
6811
|
+
*/
|
|
6812
|
+
function restartService() {
|
|
6813
|
+
const platform = process.platform;
|
|
6814
|
+
if (platform === "darwin") {
|
|
6815
|
+
const uid = getLaunchdUid();
|
|
6816
|
+
ensureLaunchdLoaded(uid);
|
|
6817
|
+
execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
|
|
6818
|
+
logger$1.info("Restarted launchd service");
|
|
6819
|
+
return;
|
|
6820
|
+
}
|
|
6821
|
+
if (platform === "linux") {
|
|
6822
|
+
execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} restart ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
6823
|
+
logger$1.info("Restarted systemd service");
|
|
6824
|
+
return;
|
|
6825
|
+
}
|
|
6826
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
6827
|
+
}
|
|
6828
|
+
/**
|
|
6829
|
+
* Stop the installed service via systemctl/launchctl. On macOS this boots the
|
|
6830
|
+
* service OUT (unloads it) so launchd's `KeepAlive` does NOT respawn it;
|
|
6831
|
+
* `startService()` bootstraps it again. On Linux the unit stays enabled (starts
|
|
6832
|
+
* on next boot); `systemctl stop` just halts the current run.
|
|
6833
|
+
*/
|
|
6834
|
+
function stopService() {
|
|
6835
|
+
const platform = process.platform;
|
|
6836
|
+
if (platform === "darwin") {
|
|
6837
|
+
execSync(`launchctl bootout gui/${getLaunchdUid()}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
|
|
6838
|
+
logger$1.info("Stopped launchd service");
|
|
6839
|
+
return;
|
|
6840
|
+
}
|
|
6841
|
+
if (platform === "linux") {
|
|
6842
|
+
execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} stop ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
6843
|
+
logger$1.info("Stopped systemd service");
|
|
6844
|
+
return;
|
|
6845
|
+
}
|
|
6846
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
6847
|
+
}
|
|
6848
|
+
/**
|
|
6849
|
+
* True when the gateway is installed as a system service (launchd plist /
|
|
6850
|
+
* systemd unit present). Lets the CLI restart/stop/start THROUGH the service
|
|
6851
|
+
* manager instead of driving the daemon inline via the PID file.
|
|
6852
|
+
*/
|
|
6853
|
+
function isServiceInstalled() {
|
|
6854
|
+
const platform = process.platform;
|
|
6855
|
+
if (platform === "darwin") return existsSync(getLaunchdPlistPath());
|
|
6856
|
+
if (platform === "linux") return existsSync(isRootUser() ? getSystemdSystemServicePath() : getSystemdServicePath());
|
|
6857
|
+
return false;
|
|
6858
|
+
}
|
|
6859
|
+
/**
|
|
6860
|
+
* Write the current process PID to the PID file.
|
|
6861
|
+
*/
|
|
6862
|
+
async function writePidFile() {
|
|
6863
|
+
await writeFile(PID_PATH, String(process.pid), "utf-8");
|
|
6864
|
+
}
|
|
6865
|
+
/**
|
|
6866
|
+
* Remove the PID file.
|
|
6867
|
+
*/
|
|
6868
|
+
async function removePidFile() {
|
|
6869
|
+
try {
|
|
6870
|
+
await unlink(PID_PATH);
|
|
6871
|
+
} catch {}
|
|
6872
|
+
}
|
|
6873
|
+
/**
|
|
6874
|
+
* Check if a daemon is already running.
|
|
6875
|
+
* Returns the PID if alive, null if not running.
|
|
6876
|
+
*/
|
|
6877
|
+
async function checkExistingDaemon() {
|
|
6878
|
+
if (!existsSync(PID_PATH)) return null;
|
|
6879
|
+
try {
|
|
6880
|
+
const pidStr = await readFile(PID_PATH, "utf-8");
|
|
6881
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
6882
|
+
if (isNaN(pid)) {
|
|
6883
|
+
await removePidFile();
|
|
6884
|
+
return null;
|
|
6885
|
+
}
|
|
6886
|
+
try {
|
|
6887
|
+
process.kill(pid, 0);
|
|
6888
|
+
return pid;
|
|
6889
|
+
} catch {
|
|
6890
|
+
await removePidFile();
|
|
6891
|
+
return null;
|
|
6892
|
+
}
|
|
6893
|
+
} catch {
|
|
6894
|
+
return null;
|
|
6895
|
+
}
|
|
6896
|
+
}
|
|
6897
|
+
/**
|
|
6898
|
+
* Send SIGTERM to an existing daemon process.
|
|
6899
|
+
*/
|
|
6900
|
+
async function stopExistingDaemon() {
|
|
6901
|
+
const pid = await checkExistingDaemon();
|
|
6902
|
+
if (!pid) return false;
|
|
6903
|
+
try {
|
|
6904
|
+
process.kill(pid, "SIGTERM");
|
|
6905
|
+
for (let i = 0; i < 50; i++) {
|
|
6906
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
6907
|
+
try {
|
|
6908
|
+
process.kill(pid, 0);
|
|
6909
|
+
} catch {
|
|
6910
|
+
await removePidFile();
|
|
6911
|
+
return true;
|
|
6912
|
+
}
|
|
6913
|
+
}
|
|
6914
|
+
process.kill(pid, "SIGKILL");
|
|
6915
|
+
await removePidFile();
|
|
6916
|
+
return true;
|
|
6917
|
+
} catch {
|
|
6918
|
+
await removePidFile();
|
|
6919
|
+
return false;
|
|
6920
|
+
}
|
|
6921
|
+
}
|
|
6922
|
+
//#endregion
|
|
6923
|
+
//#region src/ipc-server.ts
|
|
6924
|
+
/**
|
|
6925
|
+
* IPC Server — Unix socket server for local plugin connections.
|
|
6926
|
+
*
|
|
6927
|
+
* Plugins connect to ~/.alfe/gateway.sock and speak the IPC protocol:
|
|
6928
|
+
* Request: { type: 'req', id, method, params }
|
|
6929
|
+
* Response: { id, ok, payload?, error? }
|
|
6930
|
+
* Event: { type: 'event', event, payload }
|
|
6931
|
+
*
|
|
6932
|
+
* Each connected plugin registers with its name/version and receives
|
|
6933
|
+
* commands from the daemon (forwarded from cloud).
|
|
6934
|
+
*/
|
|
6935
|
+
var IPCServer = class {
|
|
6936
|
+
server = null;
|
|
6937
|
+
connections = /* @__PURE__ */ new Map();
|
|
6938
|
+
socketPath;
|
|
6939
|
+
requestHandler = null;
|
|
6940
|
+
constructor(socketPath) {
|
|
6941
|
+
this.socketPath = socketPath;
|
|
6942
|
+
}
|
|
6943
|
+
/**
|
|
6944
|
+
* Set the handler for incoming IPC requests from plugins.
|
|
6945
|
+
*/
|
|
6946
|
+
setRequestHandler(handler) {
|
|
6947
|
+
this.requestHandler = handler;
|
|
6948
|
+
}
|
|
6949
|
+
/**
|
|
6950
|
+
* Start listening on the Unix socket.
|
|
6951
|
+
*/
|
|
6952
|
+
async start() {
|
|
6953
|
+
try {
|
|
6954
|
+
unlinkSync(this.socketPath);
|
|
6955
|
+
} catch {}
|
|
6956
|
+
return new Promise((resolve, reject) => {
|
|
6957
|
+
this.server = createServer((socket) => {
|
|
6958
|
+
this.handleConnection(socket);
|
|
6959
|
+
});
|
|
6960
|
+
this.server.on("error", (err) => {
|
|
6961
|
+
logger$1.error({ err: err.message }, "IPC server error");
|
|
6962
|
+
reject(err);
|
|
6963
|
+
});
|
|
6964
|
+
this.server.listen(this.socketPath, () => {
|
|
6965
|
+
try {
|
|
6966
|
+
chmodSync(this.socketPath, 384);
|
|
6967
|
+
} catch (err) {
|
|
6968
|
+
logger$1.warn({ err }, "Failed to chmod socket");
|
|
6969
|
+
}
|
|
6970
|
+
logger$1.info({ path: this.socketPath }, "IPC server listening");
|
|
6971
|
+
resolve();
|
|
6972
|
+
});
|
|
6973
|
+
});
|
|
6974
|
+
}
|
|
6975
|
+
/**
|
|
6976
|
+
* Stop the IPC server and disconnect all plugins.
|
|
6977
|
+
*/
|
|
6978
|
+
async stop() {
|
|
6979
|
+
for (const conn of this.connections.values()) try {
|
|
6980
|
+
this.sendEvent(conn, "daemon.shutdown", { reason: "daemon stopping" });
|
|
6981
|
+
conn.socket.end();
|
|
6982
|
+
} catch {}
|
|
6983
|
+
return new Promise((resolve) => {
|
|
6984
|
+
if (!this.server) {
|
|
6985
|
+
resolve();
|
|
6986
|
+
return;
|
|
6987
|
+
}
|
|
6988
|
+
this.server.close(() => {
|
|
6989
|
+
try {
|
|
6990
|
+
unlinkSync(this.socketPath);
|
|
6991
|
+
} catch {}
|
|
6992
|
+
logger$1.info("IPC server stopped");
|
|
6993
|
+
resolve();
|
|
6994
|
+
});
|
|
6995
|
+
for (const conn of this.connections.values()) conn.socket.destroy();
|
|
6996
|
+
this.connections.clear();
|
|
6997
|
+
});
|
|
6998
|
+
}
|
|
6999
|
+
/**
|
|
7000
|
+
* Send an IPC request to a specific plugin and wait for response.
|
|
7001
|
+
*/
|
|
7002
|
+
async sendRequest(pluginId, method, params, timeoutMs = 3e4) {
|
|
7003
|
+
const conn = this.connections.get(pluginId);
|
|
7004
|
+
if (!conn?.info) return {
|
|
7005
|
+
id: "",
|
|
7006
|
+
ok: false,
|
|
7007
|
+
error: {
|
|
7008
|
+
code: "PLUGIN_NOT_CONNECTED",
|
|
7009
|
+
message: `Plugin ${pluginId} not connected`
|
|
7010
|
+
}
|
|
7011
|
+
};
|
|
7012
|
+
const id = pluginConnectionId();
|
|
7013
|
+
const request = {
|
|
7014
|
+
type: "req",
|
|
7015
|
+
id,
|
|
7016
|
+
method,
|
|
7017
|
+
params
|
|
7018
|
+
};
|
|
7019
|
+
return new Promise((resolve, reject) => {
|
|
7020
|
+
const timer = setTimeout(() => {
|
|
7021
|
+
conn.pending.delete(id);
|
|
7022
|
+
resolve({
|
|
7023
|
+
id,
|
|
7024
|
+
ok: false,
|
|
7025
|
+
error: {
|
|
7026
|
+
code: "TIMEOUT",
|
|
7027
|
+
message: `Request ${method} timed out after ${String(timeoutMs)}ms`
|
|
7028
|
+
}
|
|
7029
|
+
});
|
|
7030
|
+
}, timeoutMs);
|
|
7031
|
+
conn.pending.set(id, {
|
|
7032
|
+
resolve,
|
|
7033
|
+
reject,
|
|
7034
|
+
timer
|
|
7035
|
+
});
|
|
7036
|
+
try {
|
|
7037
|
+
conn.socket.write(JSON.stringify(request) + "\n");
|
|
7038
|
+
} catch (err) {
|
|
7039
|
+
clearTimeout(timer);
|
|
7040
|
+
conn.pending.delete(id);
|
|
7041
|
+
resolve({
|
|
7042
|
+
id,
|
|
7043
|
+
ok: false,
|
|
7044
|
+
error: {
|
|
7045
|
+
code: "SEND_FAILED",
|
|
7046
|
+
message: `Failed to send to plugin: ${err instanceof Error ? err.message : String(err)}`
|
|
7047
|
+
}
|
|
7048
|
+
});
|
|
7049
|
+
}
|
|
7050
|
+
});
|
|
7051
|
+
}
|
|
7052
|
+
/**
|
|
7053
|
+
* Send an IPC request to ALL registered plugins.
|
|
7054
|
+
* Returns responses keyed by plugin ID.
|
|
7055
|
+
*/
|
|
7056
|
+
async broadcastRequest(method, params, timeoutMs = 3e4) {
|
|
7057
|
+
const results = /* @__PURE__ */ new Map();
|
|
7058
|
+
const registered = this.getRegisteredPlugins();
|
|
7059
|
+
await Promise.all(registered.map(async ([pluginId]) => {
|
|
7060
|
+
const response = await this.sendRequest(pluginId, method, params, timeoutMs);
|
|
7061
|
+
results.set(pluginId, response);
|
|
7062
|
+
}));
|
|
7063
|
+
return results;
|
|
7064
|
+
}
|
|
7065
|
+
/**
|
|
7066
|
+
* Send an event to a specific plugin (fire-and-forget).
|
|
7067
|
+
*/
|
|
7068
|
+
sendEventToPlugin(pluginId, event, payload) {
|
|
7069
|
+
const conn = this.connections.get(pluginId);
|
|
7070
|
+
if (!conn) return false;
|
|
7071
|
+
return this.sendEvent(conn, event, payload);
|
|
7072
|
+
}
|
|
7073
|
+
/**
|
|
7074
|
+
* Broadcast an event to all connected plugins.
|
|
7075
|
+
*/
|
|
7076
|
+
broadcastEvent(event, payload) {
|
|
7077
|
+
for (const conn of this.connections.values()) if (conn.info) this.sendEvent(conn, event, payload);
|
|
7078
|
+
}
|
|
7079
|
+
/**
|
|
7080
|
+
* Get all registered plugins and their info.
|
|
7081
|
+
*/
|
|
7082
|
+
getRegisteredPlugins() {
|
|
7083
|
+
const plugins = [];
|
|
7084
|
+
for (const [id, conn] of this.connections) if (conn.info) plugins.push([id, conn.info]);
|
|
7085
|
+
return plugins;
|
|
7086
|
+
}
|
|
7087
|
+
/**
|
|
7088
|
+
* Get number of connected plugins (including unregistered).
|
|
7089
|
+
*/
|
|
7090
|
+
get connectionCount() {
|
|
7091
|
+
return this.connections.size;
|
|
7092
|
+
}
|
|
7093
|
+
/**
|
|
7094
|
+
* Get number of registered plugins.
|
|
7095
|
+
*/
|
|
7096
|
+
get registeredCount() {
|
|
7097
|
+
let count = 0;
|
|
7098
|
+
for (const conn of this.connections.values()) if (conn.info) count++;
|
|
7099
|
+
return count;
|
|
7100
|
+
}
|
|
7101
|
+
handleConnection(socket) {
|
|
7102
|
+
const connId = pluginConnectionId();
|
|
7103
|
+
const conn = {
|
|
7104
|
+
id: connId,
|
|
7105
|
+
socket,
|
|
7106
|
+
info: null,
|
|
6547
7107
|
buffer: "",
|
|
6548
7108
|
pending: /* @__PURE__ */ new Map()
|
|
6549
7109
|
};
|
|
@@ -6688,590 +7248,576 @@ var IPCServer = class {
|
|
|
6688
7248
|
sendResponse(conn, response) {
|
|
6689
7249
|
try {
|
|
6690
7250
|
conn.socket.write(JSON.stringify(response) + "\n");
|
|
6691
|
-
} catch (err) {
|
|
6692
|
-
logger$1.error({
|
|
6693
|
-
connId: conn.id,
|
|
6694
|
-
err
|
|
6695
|
-
}, "IPC: failed to send response");
|
|
6696
|
-
}
|
|
6697
|
-
}
|
|
6698
|
-
sendEvent(conn, event, payload) {
|
|
6699
|
-
try {
|
|
6700
|
-
const msg = createIPCEvent(event, payload);
|
|
6701
|
-
conn.socket.write(JSON.stringify(msg) + "\n");
|
|
6702
|
-
return true;
|
|
6703
|
-
} catch {
|
|
6704
|
-
return false;
|
|
6705
|
-
}
|
|
6706
|
-
}
|
|
6707
|
-
};
|
|
6708
|
-
//#endregion
|
|
6709
|
-
//#region src/command-queue.ts
|
|
6710
|
-
const DEFAULT_TTL_MS$1 = 300 * 1e3;
|
|
6711
|
-
var CommandQueue = class {
|
|
6712
|
-
queues = /* @__PURE__ */ new Map();
|
|
6713
|
-
ttlMs;
|
|
6714
|
-
gcTimer = null;
|
|
6715
|
-
constructor(ttlMs = DEFAULT_TTL_MS$1) {
|
|
6716
|
-
this.ttlMs = ttlMs;
|
|
6717
|
-
}
|
|
6718
|
-
/**
|
|
6719
|
-
* Start periodic garbage collection of expired commands.
|
|
6720
|
-
*/
|
|
6721
|
-
startGC(intervalMs = 3e4) {
|
|
6722
|
-
this.stopGC();
|
|
6723
|
-
this.gcTimer = setInterval(() => this.purgeExpired(), intervalMs);
|
|
6724
|
-
}
|
|
6725
|
-
/**
|
|
6726
|
-
* Stop periodic garbage collection.
|
|
6727
|
-
*/
|
|
6728
|
-
stopGC() {
|
|
6729
|
-
if (this.gcTimer) {
|
|
6730
|
-
clearInterval(this.gcTimer);
|
|
6731
|
-
this.gcTimer = null;
|
|
6732
|
-
}
|
|
6733
|
-
}
|
|
6734
|
-
/**
|
|
6735
|
-
* Enqueue a command for a specific service.
|
|
6736
|
-
*/
|
|
6737
|
-
enqueue(serviceId, request, commandId) {
|
|
6738
|
-
let queue = this.queues.get(serviceId);
|
|
6739
|
-
if (!queue) {
|
|
6740
|
-
queue = [];
|
|
6741
|
-
this.queues.set(serviceId, queue);
|
|
6742
|
-
}
|
|
6743
|
-
queue.push({
|
|
6744
|
-
request,
|
|
6745
|
-
queuedAt: Date.now(),
|
|
6746
|
-
commandId
|
|
6747
|
-
});
|
|
6748
|
-
}
|
|
6749
|
-
/**
|
|
6750
|
-
* Drain all pending (non-expired) commands for a service.
|
|
6751
|
-
* Returns them in order and removes them from the queue.
|
|
6752
|
-
*/
|
|
6753
|
-
drain(serviceId) {
|
|
6754
|
-
const queue = this.queues.get(serviceId);
|
|
6755
|
-
if (!queue || queue.length === 0) return [];
|
|
6756
|
-
const now = Date.now();
|
|
6757
|
-
const valid = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
|
|
6758
|
-
this.queues.delete(serviceId);
|
|
6759
|
-
return valid;
|
|
6760
|
-
}
|
|
6761
|
-
/**
|
|
6762
|
-
* Get the number of pending commands for a service.
|
|
6763
|
-
*/
|
|
6764
|
-
pendingCount(serviceId) {
|
|
6765
|
-
const queue = this.queues.get(serviceId);
|
|
6766
|
-
if (!queue) return 0;
|
|
6767
|
-
const now = Date.now();
|
|
6768
|
-
return queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs).length;
|
|
6769
|
-
}
|
|
6770
|
-
/**
|
|
6771
|
-
* Get total pending commands across all services.
|
|
6772
|
-
*/
|
|
6773
|
-
totalPending() {
|
|
6774
|
-
let total = 0;
|
|
6775
|
-
for (const [serviceId] of this.queues) total += this.pendingCount(serviceId);
|
|
6776
|
-
return total;
|
|
6777
|
-
}
|
|
6778
|
-
/**
|
|
6779
|
-
* Remove expired commands from all queues.
|
|
6780
|
-
*/
|
|
6781
|
-
purgeExpired() {
|
|
6782
|
-
const now = Date.now();
|
|
6783
|
-
let purged = 0;
|
|
6784
|
-
for (const [serviceId, queue] of this.queues) {
|
|
6785
|
-
const before = queue.length;
|
|
6786
|
-
const remaining = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
|
|
6787
|
-
if (remaining.length === 0) this.queues.delete(serviceId);
|
|
6788
|
-
else this.queues.set(serviceId, remaining);
|
|
6789
|
-
purged += before - remaining.length;
|
|
6790
|
-
}
|
|
6791
|
-
return purged;
|
|
6792
|
-
}
|
|
6793
|
-
/**
|
|
6794
|
-
* Clear all queues.
|
|
6795
|
-
*/
|
|
6796
|
-
clear() {
|
|
6797
|
-
this.queues.clear();
|
|
7251
|
+
} catch (err) {
|
|
7252
|
+
logger$1.error({
|
|
7253
|
+
connId: conn.id,
|
|
7254
|
+
err
|
|
7255
|
+
}, "IPC: failed to send response");
|
|
7256
|
+
}
|
|
6798
7257
|
}
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
7258
|
+
sendEvent(conn, event, payload) {
|
|
7259
|
+
try {
|
|
7260
|
+
const msg = createIPCEvent(event, payload);
|
|
7261
|
+
conn.socket.write(JSON.stringify(msg) + "\n");
|
|
7262
|
+
return true;
|
|
7263
|
+
} catch {
|
|
7264
|
+
return false;
|
|
7265
|
+
}
|
|
6804
7266
|
}
|
|
6805
7267
|
};
|
|
6806
7268
|
//#endregion
|
|
6807
|
-
//#region src/
|
|
7269
|
+
//#region src/daemon-bootstrap.ts
|
|
6808
7270
|
/**
|
|
6809
|
-
*
|
|
7271
|
+
* Daemon bootstrap helpers — startup ordering, config/version resolution,
|
|
7272
|
+
* AI-proxy + IPC server wiring.
|
|
6810
7273
|
*
|
|
6811
|
-
*
|
|
6812
|
-
*
|
|
6813
|
-
|
|
6814
|
-
const LAUNCHD_LABEL = "ai.alfe.gateway";
|
|
6815
|
-
const SYSTEMD_SERVICE = "alfe-gateway";
|
|
6816
|
-
/**
|
|
6817
|
-
* On-disk path for the boot-time self-heal guard script (Linux only).
|
|
6818
|
-
* Written next to the systemd unit at setup time and invoked via
|
|
6819
|
-
* `ExecStartPre=` — see `writeGuardScript` and `generateSystemdUnit`.
|
|
6820
|
-
*/
|
|
6821
|
-
function getGuardScriptPath() {
|
|
6822
|
-
return isRootUser() ? "/usr/local/lib/alfe/alfe-cli-guard.sh" : join(homedir(), ".alfe", "alfe-cli-guard.sh");
|
|
6823
|
-
}
|
|
6824
|
-
function isRootUser() {
|
|
6825
|
-
return process.getuid?.() === 0;
|
|
6826
|
-
}
|
|
6827
|
-
function getSystemdSystemServicePath() {
|
|
6828
|
-
return `/etc/systemd/system/${SYSTEMD_SERVICE}.service`;
|
|
6829
|
-
}
|
|
6830
|
-
function getLaunchdPlistPath() {
|
|
6831
|
-
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
6832
|
-
}
|
|
6833
|
-
function getSystemdServicePath() {
|
|
6834
|
-
return join(homedir(), ".config", "systemd", "user", `${SYSTEMD_SERVICE}.service`);
|
|
6835
|
-
}
|
|
6836
|
-
/**
|
|
6837
|
-
* Resolve the alfe CLI path. Globally installed via npm, always in PATH.
|
|
6838
|
-
*/
|
|
6839
|
-
function getAlfeBinPath() {
|
|
6840
|
-
try {
|
|
6841
|
-
return execSync("which alfe", { encoding: "utf-8" }).trim();
|
|
6842
|
-
} catch {
|
|
6843
|
-
return "alfe";
|
|
6844
|
-
}
|
|
6845
|
-
}
|
|
6846
|
-
/**
|
|
6847
|
-
* Build the PATH for the launchd plist, ensuring the directory containing the
|
|
6848
|
-
* resolved alfe binary is included (e.g. /opt/homebrew/bin on Apple Silicon).
|
|
6849
|
-
*/
|
|
6850
|
-
function buildLaunchdPath(alfeBin) {
|
|
6851
|
-
const basePaths = [
|
|
6852
|
-
"/usr/local/bin",
|
|
6853
|
-
"/usr/bin",
|
|
6854
|
-
"/bin",
|
|
6855
|
-
join(homedir(), ".local", "bin")
|
|
6856
|
-
];
|
|
6857
|
-
const binDir = dirname(alfeBin);
|
|
6858
|
-
if (binDir && !basePaths.includes(binDir)) basePaths.unshift(binDir);
|
|
6859
|
-
return basePaths.join(":");
|
|
6860
|
-
}
|
|
6861
|
-
/**
|
|
6862
|
-
* Generate a launchd plist for macOS.
|
|
7274
|
+
* Extracted from daemon.ts (which remains the orchestrator). These are the
|
|
7275
|
+
* standalone pieces of `startDaemon()`: version detection, the one-shot
|
|
7276
|
+
* persona-file migration, the AI proxy listener, and the IPC server start.
|
|
6863
7277
|
*/
|
|
6864
|
-
|
|
6865
|
-
const alfeBin = getAlfeBinPath();
|
|
6866
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
6867
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
6868
|
-
<plist version="1.0">
|
|
6869
|
-
<dict>
|
|
6870
|
-
<key>Label</key>
|
|
6871
|
-
<string>${LAUNCHD_LABEL}</string>
|
|
6872
|
-
<key>ProgramArguments</key>
|
|
6873
|
-
<array>
|
|
6874
|
-
<string>${alfeBin}</string>
|
|
6875
|
-
<string>gateway</string>
|
|
6876
|
-
<string>daemon</string>
|
|
6877
|
-
</array>
|
|
6878
|
-
<key>RunAtLoad</key>
|
|
6879
|
-
<true/>
|
|
6880
|
-
<key>KeepAlive</key>
|
|
6881
|
-
<true/>
|
|
6882
|
-
<key>ThrottleInterval</key>
|
|
6883
|
-
<integer>10</integer>
|
|
6884
|
-
<key>StandardOutPath</key>
|
|
6885
|
-
<string>${join(homedir(), ".alfe", "logs", "gateway.log")}</string>
|
|
6886
|
-
<key>StandardErrorPath</key>
|
|
6887
|
-
<string>${join(homedir(), ".alfe", "logs", "gateway.err.log")}</string>
|
|
6888
|
-
<key>EnvironmentVariables</key>
|
|
6889
|
-
<dict>
|
|
6890
|
-
<key>NODE_ENV</key>
|
|
6891
|
-
<string>production</string>
|
|
6892
|
-
<key>PATH</key>
|
|
6893
|
-
<string>${buildLaunchdPath(alfeBin)}</string>
|
|
6894
|
-
</dict>
|
|
6895
|
-
</dict>
|
|
6896
|
-
</plist>`;
|
|
6897
|
-
}
|
|
7278
|
+
const execFileAsync$2 = promisify(execFile);
|
|
6898
7279
|
/**
|
|
6899
|
-
*
|
|
6900
|
-
*
|
|
6901
|
-
* Runs as an `ExecStartPre=` BEFORE the daemon launches, OUTSIDE the
|
|
6902
|
-
* (possibly-broken) `alfe` binary. It exists for the case defense #1
|
|
6903
|
-
* (verify-before-exit in `upgrade.ts`) structurally cannot cover: the daemon
|
|
6904
|
-
* is KILLED during `npm install -g @alfe.ai/cli` (reboot / OOM), leaving the
|
|
6905
|
-
* package with deps but no `dist/` and a dangling `/usr/bin/alfe` symlink.
|
|
6906
|
-
* systemd then execs a dangling binary forever → 203/EXEC crash-loop, a
|
|
6907
|
-
* permanent brick with no automatic recovery (real prod incident,
|
|
6908
|
-
* ~13,570 restarts over 2 days).
|
|
6909
|
-
*
|
|
6910
|
-
* The guard:
|
|
6911
|
-
* - checks the CLI is intact: the symlink target (`dist/index.js`) exists AND
|
|
6912
|
-
* `alfe --version` exits 0;
|
|
6913
|
-
* - if broken, reinstalls `@alfe.ai/cli@$ALFE_CLI_VERSION` (the exact version
|
|
6914
|
-
* baked into the unit's `Environment=`; falls back to `@latest` when
|
|
6915
|
-
* unset), so the daemon then execs a working binary;
|
|
6916
|
-
* - is idempotent + fast (no-op when healthy) and NEVER wedges boot: any of
|
|
6917
|
-
* its own failures log + `exit 0` so a human can still SSH in.
|
|
7280
|
+
* Resolve the installed @alfe.ai/cli version.
|
|
6918
7281
|
*
|
|
6919
|
-
*
|
|
6920
|
-
*
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
const version = process.env.ALFE_CLI_VERSION;
|
|
6925
|
-
return `#!/bin/sh
|
|
6926
|
-
# Alfe CLI boot-time self-heal guard. Auto-generated by 'alfe setup' — do not edit.
|
|
6927
|
-
# Repairs an interrupted 'npm install -g @alfe.ai/cli' before the daemon starts,
|
|
6928
|
-
# so a mid-install reboot can't brick the agent with a 203/EXEC crash-loop.
|
|
6929
|
-
# Must never wedge boot: every failure path logs and exits 0.
|
|
6930
|
-
set -u
|
|
6931
|
-
|
|
6932
|
-
TARGET='${version && version.length > 0 ? `@alfe.ai/cli@${version}` : "@alfe.ai/cli@latest"}'
|
|
6933
|
-
log() { echo "[alfe-cli-guard] $*" >&2; }
|
|
6934
|
-
|
|
6935
|
-
# Resolve the alfe bin and its real target (dist/index.js). readlink -f follows
|
|
6936
|
-
# the whole symlink chain; fall back to the bin path itself if unavailable.
|
|
6937
|
-
BIN="$(command -v alfe 2>/dev/null || true)"
|
|
6938
|
-
if [ -n "$BIN" ]; then
|
|
6939
|
-
ENTRY="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")"
|
|
6940
|
-
else
|
|
6941
|
-
ENTRY=""
|
|
6942
|
-
fi
|
|
6943
|
-
|
|
6944
|
-
healthy=1
|
|
6945
|
-
if [ -z "$ENTRY" ] || [ ! -f "$ENTRY" ]; then
|
|
6946
|
-
healthy=0
|
|
6947
|
-
log "CLI entry missing (bin=\${BIN:-<none>} entry=\${ENTRY:-<none>})"
|
|
6948
|
-
elif ! alfe --version >/dev/null 2>&1; then
|
|
6949
|
-
healthy=0
|
|
6950
|
-
log "alfe --version failed"
|
|
6951
|
-
fi
|
|
6952
|
-
|
|
6953
|
-
if [ "$healthy" -eq 1 ]; then
|
|
6954
|
-
exit 0
|
|
6955
|
-
fi
|
|
6956
|
-
|
|
6957
|
-
log "CLI install looks broken — reinstalling $TARGET"
|
|
6958
|
-
# Blow away a partial tree first so npm re-materialises dist/ cleanly. Best-effort.
|
|
6959
|
-
GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"
|
|
6960
|
-
if [ -n "$GLOBAL_ROOT" ] && [ -d "$GLOBAL_ROOT/@alfe.ai/cli" ]; then
|
|
6961
|
-
rm -rf "$GLOBAL_ROOT/@alfe.ai/cli" 2>/dev/null || true
|
|
6962
|
-
fi
|
|
6963
|
-
|
|
6964
|
-
# Bound the reinstall so a slow/unreachable registry at boot can't stall the
|
|
6965
|
-
# ExecStartPre up to systemd's DefaultTimeoutStartSec (~90s). Two layers:
|
|
6966
|
-
# - npm --fetch-timeout/--fetch-retries cap per-request/hung-socket time;
|
|
6967
|
-
# - a wall-clock 'timeout' wrapper caps the whole install when present
|
|
6968
|
-
# ('timeout' isn't guaranteed, so fall back to a bare install).
|
|
6969
|
-
# stdout->stderr ('>&2') so npm's chatter lands in the journal, not on fd1.
|
|
6970
|
-
if command -v timeout >/dev/null 2>&1; then
|
|
6971
|
-
timeout 120 npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
|
|
6972
|
-
rc=$?
|
|
6973
|
-
else
|
|
6974
|
-
npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
|
|
6975
|
-
rc=$?
|
|
6976
|
-
fi
|
|
6977
|
-
if [ "$rc" -eq 0 ]; then
|
|
6978
|
-
log "reinstall succeeded"
|
|
6979
|
-
else
|
|
6980
|
-
log "reinstall FAILED (rc=$rc) — daemon start may still fail; a human can SSH in to repair"
|
|
6981
|
-
fi
|
|
6982
|
-
|
|
6983
|
-
# Always exit 0: even a failed repair must not block the unit from attempting
|
|
6984
|
-
# ExecStart (which then fails visibly) rather than wedging silently in pre-start.
|
|
6985
|
-
exit 0
|
|
6986
|
-
`;
|
|
6987
|
-
}
|
|
6988
|
-
/**
|
|
6989
|
-
* Generate a systemd unit for Linux.
|
|
6990
|
-
* Root users get a system-level unit; non-root get a user-level unit.
|
|
7282
|
+
* Strategy (in order):
|
|
7283
|
+
* 1. ALFE_CLI_VERSION env var (set by CLI entry point when run directly)
|
|
7284
|
+
* 2. Walk up from this file to find @alfe.ai/cli/package.json
|
|
7285
|
+
* (works when systemd runs the gateway binary directly, since
|
|
7286
|
+
* the path is .../cli/node_modules/@alfe.ai/gateway/dist/...)
|
|
6991
7287
|
*/
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
Restart=always
|
|
7012
|
-
RestartSec=10
|
|
7013
|
-
# SIGTERM only the daemon on stop/restart; it closes its MCP/runtime children
|
|
7014
|
-
# itself. The default (control-group) SIGTERMs the children simultaneously, so
|
|
7015
|
-
# they die before the daemon's orderly dispose reaches them and every planned
|
|
7016
|
-
# restart reports MCP "server-crash" noise to Sentry. Stragglers still get
|
|
7017
|
-
# SIGKILL when the stop timeout expires.
|
|
7018
|
-
KillMode=mixed
|
|
7019
|
-
Environment=NODE_ENV=production${root ? "\nEnvironment=HOME=/root\nWorkingDirectory=/root" : ""}
|
|
7020
|
-
${envLines}
|
|
7021
|
-
|
|
7022
|
-
[Install]
|
|
7023
|
-
WantedBy=${root ? "multi-user.target" : "default.target"}`;
|
|
7288
|
+
async function getCliVersion() {
|
|
7289
|
+
if (process.env.ALFE_CLI_VERSION) return process.env.ALFE_CLI_VERSION;
|
|
7290
|
+
try {
|
|
7291
|
+
const { fileURLToPath } = await import("node:url");
|
|
7292
|
+
const { dirname } = await import("node:path");
|
|
7293
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
7294
|
+
for (let i = 0; i < 10; i++) {
|
|
7295
|
+
const candidate = join(dir, "package.json");
|
|
7296
|
+
try {
|
|
7297
|
+
const raw = await readFile(candidate, "utf-8");
|
|
7298
|
+
const pkg = JSON.parse(raw);
|
|
7299
|
+
if (pkg.name === "@alfe.ai/cli") return pkg.version;
|
|
7300
|
+
} catch {}
|
|
7301
|
+
const parent = dirname(dir);
|
|
7302
|
+
if (parent === dir) break;
|
|
7303
|
+
dir = parent;
|
|
7304
|
+
}
|
|
7305
|
+
} catch {}
|
|
7306
|
+
logger$1.debug("Could not resolve @alfe.ai/cli version");
|
|
7024
7307
|
}
|
|
7025
7308
|
/**
|
|
7026
|
-
*
|
|
7027
|
-
*
|
|
7309
|
+
* Per-runtime "print installed version" commands. Local to the gateway — we do
|
|
7310
|
+
* NOT import the CLI's `detectRuntime` (`@alfe.ai/gateway` must not depend on
|
|
7311
|
+
* `@alfe.ai/cli`). Unknown runtimes resolve to `undefined` (never throw) so the
|
|
7312
|
+
* connection-status report degrades gracefully instead of crashing.
|
|
7028
7313
|
*/
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7314
|
+
const RUNTIME_VERSION_COMMANDS = {
|
|
7315
|
+
openclaw: {
|
|
7316
|
+
command: "openclaw",
|
|
7317
|
+
args: ["--version"]
|
|
7318
|
+
},
|
|
7319
|
+
hermes: {
|
|
7320
|
+
command: "hermes",
|
|
7321
|
+
args: ["version"]
|
|
7322
|
+
},
|
|
7323
|
+
"claude-code": {
|
|
7324
|
+
command: "alfe-claude-host",
|
|
7325
|
+
args: ["--version"]
|
|
7326
|
+
}
|
|
7327
|
+
};
|
|
7328
|
+
/**
|
|
7329
|
+
* Pure resolver (exported for tests) — the per-runtime version command, or
|
|
7330
|
+
* `undefined` for an unknown runtime.
|
|
7331
|
+
*/
|
|
7332
|
+
function resolveRuntimeVersionCommand(runtime) {
|
|
7333
|
+
return RUNTIME_VERSION_COMMANDS[runtime];
|
|
7037
7334
|
}
|
|
7038
7335
|
/**
|
|
7039
|
-
*
|
|
7336
|
+
* Resolve the installed runtime version by running the per-runtime version
|
|
7337
|
+
* command and returning the trimmed stdout. An unknown runtime (no command in
|
|
7338
|
+
* the map) returns `undefined` without spawning — it must never throw.
|
|
7040
7339
|
*/
|
|
7041
|
-
async function
|
|
7042
|
-
const
|
|
7043
|
-
if (
|
|
7044
|
-
|
|
7045
|
-
|
|
7340
|
+
async function getRuntimeVersion(runtime) {
|
|
7341
|
+
const cmd = resolveRuntimeVersionCommand(runtime);
|
|
7342
|
+
if (!cmd) {
|
|
7343
|
+
logger$1.debug({ runtime }, "No version command for runtime — skipping version detection");
|
|
7344
|
+
return;
|
|
7345
|
+
}
|
|
7346
|
+
try {
|
|
7347
|
+
const { stdout } = await execFileAsync$2(cmd.command, cmd.args);
|
|
7348
|
+
return stdout.trim() || void 0;
|
|
7349
|
+
} catch {
|
|
7350
|
+
logger$1.debug({ runtime }, "Could not resolve runtime version");
|
|
7351
|
+
return;
|
|
7352
|
+
}
|
|
7046
7353
|
}
|
|
7047
7354
|
/**
|
|
7048
|
-
*
|
|
7355
|
+
* Flush pino's async transport and exit.
|
|
7356
|
+
* process.exit() can drop buffered log lines — this ensures they're written first.
|
|
7049
7357
|
*/
|
|
7050
|
-
async function
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7358
|
+
async function flushAndExit(code) {
|
|
7359
|
+
await flushSentry();
|
|
7360
|
+
await new Promise((resolve) => {
|
|
7361
|
+
logger$1.flush();
|
|
7362
|
+
setTimeout(resolve, 500);
|
|
7363
|
+
});
|
|
7364
|
+
process.exit(code);
|
|
7055
7365
|
}
|
|
7056
|
-
|
|
7057
|
-
|
|
7366
|
+
/**
|
|
7367
|
+
* One-shot migration for VMs provisioned before the persona-files-go-in-agent-workspace
|
|
7368
|
+
* fix landed. Old behavior wrote SOUL.md/IDENTITY.md/BOOTSTRAP.md/AGENTS.md at the
|
|
7369
|
+
* OpenClaw home (e.g. `~/.openclaw/`); the agent reads from the agent workspace
|
|
7370
|
+
* (e.g. `~/.openclaw/workspace/`). Move stale copies into place; skip if the
|
|
7371
|
+
* workspace already has the file. Idempotent and safe to run on every daemon start.
|
|
7372
|
+
*/
|
|
7373
|
+
const PERSONA_FILES = [
|
|
7374
|
+
"SOUL.md",
|
|
7375
|
+
"IDENTITY.md",
|
|
7376
|
+
"BOOTSTRAP.md",
|
|
7377
|
+
"AGENTS.md"
|
|
7378
|
+
];
|
|
7379
|
+
async function migrateLegacyPersonaFiles(home, agentWorkspace) {
|
|
7380
|
+
if (home === agentWorkspace) return;
|
|
7381
|
+
let moved = 0;
|
|
7382
|
+
let skipped = 0;
|
|
7383
|
+
for (const filename of PERSONA_FILES) {
|
|
7384
|
+
const src = join(home, filename);
|
|
7385
|
+
const dst = join(agentWorkspace, filename);
|
|
7386
|
+
try {
|
|
7387
|
+
await stat(src);
|
|
7388
|
+
} catch {
|
|
7389
|
+
continue;
|
|
7390
|
+
}
|
|
7391
|
+
try {
|
|
7392
|
+
await stat(dst);
|
|
7393
|
+
skipped++;
|
|
7394
|
+
continue;
|
|
7395
|
+
} catch {}
|
|
7396
|
+
try {
|
|
7397
|
+
await mkdir(agentWorkspace, { recursive: true });
|
|
7398
|
+
await rename(src, dst);
|
|
7399
|
+
moved++;
|
|
7400
|
+
} catch (err) {
|
|
7401
|
+
logger$1.warn({
|
|
7402
|
+
src,
|
|
7403
|
+
dst,
|
|
7404
|
+
err: err instanceof Error ? err.message : String(err)
|
|
7405
|
+
}, "Persona file migration: rename failed");
|
|
7406
|
+
}
|
|
7407
|
+
}
|
|
7408
|
+
if (moved > 0 || skipped > 0) logger$1.info({
|
|
7409
|
+
home,
|
|
7410
|
+
agentWorkspace,
|
|
7411
|
+
moved,
|
|
7412
|
+
skipped
|
|
7413
|
+
}, "Persona file backfill migration complete");
|
|
7058
7414
|
}
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
}
|
|
7415
|
+
/**
|
|
7416
|
+
* Start the local AI proxy (before the runtime so LLM requests work
|
|
7417
|
+
* immediately). Failure is non-fatal — the daemon starts anyway and LLM
|
|
7418
|
+
* requests fail until the proxy is fixed.
|
|
7419
|
+
*/
|
|
7420
|
+
async function startAiProxy(apiKey) {
|
|
7421
|
+
logger$1.debug("Starting AI proxy...");
|
|
7422
|
+
const handle = {
|
|
7423
|
+
server: null,
|
|
7424
|
+
url: null,
|
|
7425
|
+
running: false
|
|
7426
|
+
};
|
|
7071
7427
|
try {
|
|
7072
|
-
|
|
7428
|
+
const { createProxyServer, DEFAULT_AI_PROXY_PORT } = await import("@alfe.ai/ai-proxy-local");
|
|
7429
|
+
const { getAiServiceUrlFromToken } = await import("@alfe.ai/config");
|
|
7430
|
+
const proxyUrl = getAiServiceUrlFromToken(apiKey);
|
|
7431
|
+
const port = DEFAULT_AI_PROXY_PORT ?? 18193;
|
|
7432
|
+
handle.server = createProxyServer({
|
|
7433
|
+
port,
|
|
7434
|
+
apiKey,
|
|
7435
|
+
proxyUrl
|
|
7436
|
+
});
|
|
7437
|
+
const server = handle.server;
|
|
7438
|
+
await new Promise((resolve, reject) => {
|
|
7439
|
+
server.listen(port, "127.0.0.1", () => {
|
|
7440
|
+
handle.running = true;
|
|
7441
|
+
handle.url = `http://127.0.0.1:${String(port)}`;
|
|
7442
|
+
logger$1.info({
|
|
7443
|
+
port,
|
|
7444
|
+
upstream: proxyUrl
|
|
7445
|
+
}, "AI proxy started");
|
|
7446
|
+
resolve();
|
|
7447
|
+
});
|
|
7448
|
+
server.on("error", reject);
|
|
7449
|
+
});
|
|
7073
7450
|
} catch (err) {
|
|
7074
|
-
const
|
|
7451
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7452
|
+
const stack = err instanceof Error ? err.stack : void 0;
|
|
7075
7453
|
logger$1.error({
|
|
7076
|
-
err:
|
|
7077
|
-
|
|
7078
|
-
}, "
|
|
7079
|
-
return `Installed plist at ${plistPath}, but failed to bootstrap service: ${msg}`;
|
|
7454
|
+
err: message,
|
|
7455
|
+
stack
|
|
7456
|
+
}, "Failed to start AI proxy — LLM requests will fail");
|
|
7080
7457
|
}
|
|
7081
|
-
return
|
|
7458
|
+
return handle;
|
|
7082
7459
|
}
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
7460
|
+
/**
|
|
7461
|
+
* Start the IPC server (runtime plugins connect via this socket). A failure
|
|
7462
|
+
* here is fatal — plugins have no other path to the daemon — so this flushes
|
|
7463
|
+
* logs and exits instead of returning.
|
|
7464
|
+
*/
|
|
7465
|
+
async function startIpcServer(socketPath, requestHandler) {
|
|
7466
|
+
logger$1.debug({ socketPath }, "Starting IPC server...");
|
|
7467
|
+
const ipcServer = new IPCServer(socketPath);
|
|
7468
|
+
ipcServer.setRequestHandler(requestHandler);
|
|
7089
7469
|
try {
|
|
7090
|
-
await
|
|
7091
|
-
|
|
7092
|
-
|
|
7470
|
+
await ipcServer.start();
|
|
7471
|
+
logger$1.debug("IPC server started");
|
|
7472
|
+
} catch (err) {
|
|
7473
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7474
|
+
const stack = err instanceof Error ? err.stack : void 0;
|
|
7475
|
+
logger$1.error({
|
|
7476
|
+
err: message,
|
|
7477
|
+
stack
|
|
7478
|
+
}, "Failed to start IPC server");
|
|
7479
|
+
await flushAndExit(1);
|
|
7480
|
+
}
|
|
7481
|
+
return ipcServer;
|
|
7093
7482
|
}
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
7483
|
+
//#endregion
|
|
7484
|
+
//#region src/mcp-handlers.ts
|
|
7485
|
+
/**
|
|
7486
|
+
* Return the daemon-hosted MCP bundler's current namespaced tool catalog.
|
|
7487
|
+
* Called by the openclaw-mcp-bundler plugin on every tool-factory invocation;
|
|
7488
|
+
* cheap (in-memory snapshot, no I/O).
|
|
7489
|
+
*/
|
|
7490
|
+
function handleMcpListTools(bundler) {
|
|
7491
|
+
if (!bundler) return {
|
|
7492
|
+
ok: false,
|
|
7493
|
+
error: {
|
|
7494
|
+
code: "MCP_BUNDLER_UNAVAILABLE",
|
|
7495
|
+
message: "MCP bundler not initialized"
|
|
7496
|
+
}
|
|
7497
|
+
};
|
|
7498
|
+
return {
|
|
7499
|
+
ok: true,
|
|
7500
|
+
payload: { tools: bundler.listTools() }
|
|
7501
|
+
};
|
|
7111
7502
|
}
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7503
|
+
/**
|
|
7504
|
+
* Observability backstop for the INTEGRATION warm path. `applyForIntegration`
|
|
7505
|
+
* registers MCP servers into the store and the daemon warms them silently via
|
|
7506
|
+
* the store-change `onChange` hook — so a server that fails its connect (e.g.
|
|
7507
|
+
* an MCP server whose backing account/credential wasn't resolvable at warm
|
|
7508
|
+
* time, like ctrader-mcp `exit(1)`-ing on empty accounts) leaves NO trace,
|
|
7509
|
+
* making the black hole undiagnosable. After a warm, read `bundler.statuses()`
|
|
7510
|
+
* and warn once per still-failed server so the failure is visible. These
|
|
7511
|
+
* self-heal on the bundler's background retry sweep once the dependency
|
|
7512
|
+
* appears. Returns the servers it warned about (for tests / callers).
|
|
7513
|
+
*/
|
|
7514
|
+
function warnFailedMcpServers(bundler, log, reason) {
|
|
7515
|
+
if (!bundler) return [];
|
|
7516
|
+
const failed = bundler.statuses().filter((s) => !s.connected && s.lastError !== void 0);
|
|
7517
|
+
for (const status of failed) log.warn({
|
|
7518
|
+
server: status.name,
|
|
7519
|
+
reason,
|
|
7520
|
+
consecutiveFailures: status.consecutiveFailures,
|
|
7521
|
+
lastError: status.lastError
|
|
7522
|
+
}, `MCP server "${status.name}" failed to connect: ${status.lastError ?? ""}`);
|
|
7523
|
+
return failed;
|
|
7130
7524
|
}
|
|
7131
7525
|
/**
|
|
7132
|
-
*
|
|
7133
|
-
*
|
|
7134
|
-
*
|
|
7526
|
+
* Route a tool call to the appropriate MCP child via the daemon-hosted
|
|
7527
|
+
* bundler. `name` is the prefixed (`mcp__<server>__<tool>`) name; args is
|
|
7528
|
+
* the raw JSON object the LLM produced.
|
|
7135
7529
|
*/
|
|
7136
|
-
function ensureLaunchdLoaded(uid) {
|
|
7137
|
-
try {
|
|
7138
|
-
execSync(`launchctl bootstrap gui/${uid} ${getLaunchdPlistPath()}`, { stdio: "pipe" });
|
|
7139
|
-
} catch {}
|
|
7140
|
-
}
|
|
7141
7530
|
/**
|
|
7142
|
-
*
|
|
7143
|
-
*
|
|
7531
|
+
* List every server entry in the alfe bundler store. Lets agents inspect
|
|
7532
|
+
* what they've already registered before adding a new one.
|
|
7144
7533
|
*/
|
|
7145
|
-
function
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7534
|
+
function handleMcpListServers(manager) {
|
|
7535
|
+
if (!manager) return {
|
|
7536
|
+
ok: false,
|
|
7537
|
+
error: {
|
|
7538
|
+
code: "MCP_MANAGER_UNAVAILABLE",
|
|
7539
|
+
message: "MCP manager not initialized"
|
|
7540
|
+
}
|
|
7541
|
+
};
|
|
7542
|
+
const statuses = manager.serverStatuses ? manager.serverStatuses() : [];
|
|
7543
|
+
const byName = new Map(statuses.map((s) => [s.name, s]));
|
|
7544
|
+
return {
|
|
7545
|
+
ok: true,
|
|
7546
|
+
payload: { servers: manager.listServers().map(({ id, entry }) => ({
|
|
7547
|
+
id,
|
|
7548
|
+
entry,
|
|
7549
|
+
status: byName.get(id) ?? {
|
|
7550
|
+
name: id,
|
|
7551
|
+
connected: false,
|
|
7552
|
+
toolCount: 0,
|
|
7553
|
+
consecutiveFailures: 0
|
|
7554
|
+
}
|
|
7555
|
+
})) }
|
|
7556
|
+
};
|
|
7160
7557
|
}
|
|
7161
7558
|
/**
|
|
7162
|
-
*
|
|
7163
|
-
*
|
|
7164
|
-
*
|
|
7165
|
-
*
|
|
7559
|
+
* How long the add-confirm probe waits for the freshly-added server to
|
|
7560
|
+
* connect before replying. This is effectively the whole budget for
|
|
7561
|
+
* `bundler.warmServer`: `Manager.addServer` now reconciles the bundler
|
|
7562
|
+
* BEFORE resolving, so the Connection object already exists and
|
|
7563
|
+
* `warmServer`'s pre-reconcile is a no-diff cheap pass. Kept well under the
|
|
7564
|
+
* plugin caller's 30s IPC timeout. A slower server still connects in the
|
|
7565
|
+
* background and surfaces on the next `alfe_mcp_list_tools` — the probe just
|
|
7566
|
+
* reports what it saw within the window; a missed connect is re-attempted by
|
|
7567
|
+
* the bundler's `retryNeverConnected` sweep.
|
|
7166
7568
|
*/
|
|
7167
|
-
|
|
7168
|
-
const platform = process.platform;
|
|
7169
|
-
if (platform === "darwin") {
|
|
7170
|
-
const uid = getLaunchdUid();
|
|
7171
|
-
ensureLaunchdLoaded(uid);
|
|
7172
|
-
execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
|
|
7173
|
-
logger$1.info("Restarted launchd service");
|
|
7174
|
-
return;
|
|
7175
|
-
}
|
|
7176
|
-
if (platform === "linux") {
|
|
7177
|
-
execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} restart ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
|
|
7178
|
-
logger$1.info("Restarted systemd service");
|
|
7179
|
-
return;
|
|
7180
|
-
}
|
|
7181
|
-
throw new Error(`Unsupported platform: ${platform}`);
|
|
7182
|
-
}
|
|
7569
|
+
const MCP_ADD_WARM_TIMEOUT_MS = 12e3;
|
|
7183
7570
|
/**
|
|
7184
|
-
*
|
|
7185
|
-
*
|
|
7186
|
-
*
|
|
7187
|
-
*
|
|
7571
|
+
* Register a new MCP server in the alfe bundler store on behalf of the agent,
|
|
7572
|
+
* then CONFIRM the connect before replying so the agent learns whether the
|
|
7573
|
+
* server actually works. Owned as `'manual'` so the agent can later remove it
|
|
7574
|
+
* without an expectedOwner conflict — matches what `alfe mcp add` does from the
|
|
7575
|
+
* CLI. Registration is durable regardless of the probe outcome: a probe
|
|
7576
|
+
* failure (or a runtime with no daemon bundler, e.g. hermes) still returns
|
|
7577
|
+
* `ok` with `connected: false`; the store watcher / runtime picks the entry up.
|
|
7188
7578
|
*/
|
|
7189
|
-
function
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7579
|
+
async function handleMcpAddServer(params, manager) {
|
|
7580
|
+
if (!manager) return {
|
|
7581
|
+
ok: false,
|
|
7582
|
+
error: {
|
|
7583
|
+
code: "MCP_MANAGER_UNAVAILABLE",
|
|
7584
|
+
message: "MCP manager not initialized"
|
|
7585
|
+
}
|
|
7586
|
+
};
|
|
7587
|
+
const p = params;
|
|
7588
|
+
if (typeof p.id !== "string" || p.id.length === 0) return {
|
|
7589
|
+
ok: false,
|
|
7590
|
+
error: {
|
|
7591
|
+
code: "INVALID_PARAMS",
|
|
7592
|
+
message: "id is required (string)"
|
|
7593
|
+
}
|
|
7594
|
+
};
|
|
7595
|
+
const config = buildServerConfig(p);
|
|
7596
|
+
if (!config) return {
|
|
7597
|
+
ok: false,
|
|
7598
|
+
error: {
|
|
7599
|
+
code: "INVALID_PARAMS",
|
|
7600
|
+
message: "expected either { command, args?, env?, cwd? } for stdio or { url, transport, headers? } for remote"
|
|
7601
|
+
}
|
|
7602
|
+
};
|
|
7603
|
+
try {
|
|
7604
|
+
await manager.addServer(config, {
|
|
7605
|
+
id: p.id,
|
|
7606
|
+
owner: "manual"
|
|
7607
|
+
});
|
|
7608
|
+
} catch (err) {
|
|
7609
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7610
|
+
logger$1.warn({
|
|
7611
|
+
id: p.id,
|
|
7612
|
+
err: message
|
|
7613
|
+
}, "mcp.add_server failed");
|
|
7614
|
+
return {
|
|
7615
|
+
ok: false,
|
|
7616
|
+
error: {
|
|
7617
|
+
code: "MCP_ADD_FAILED",
|
|
7618
|
+
message
|
|
7619
|
+
}
|
|
7620
|
+
};
|
|
7195
7621
|
}
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7622
|
+
let status = null;
|
|
7623
|
+
if (manager.warmServer) try {
|
|
7624
|
+
status = await manager.warmServer(p.id, MCP_ADD_WARM_TIMEOUT_MS);
|
|
7625
|
+
} catch (err) {
|
|
7626
|
+
logger$1.warn({
|
|
7627
|
+
id: p.id,
|
|
7628
|
+
err: err instanceof Error ? err.message : String(err)
|
|
7629
|
+
}, "mcp.add_server warm probe threw");
|
|
7200
7630
|
}
|
|
7201
|
-
|
|
7631
|
+
return {
|
|
7632
|
+
ok: true,
|
|
7633
|
+
payload: {
|
|
7634
|
+
id: p.id,
|
|
7635
|
+
connected: status?.connected ?? false,
|
|
7636
|
+
toolCount: status?.toolCount ?? 0,
|
|
7637
|
+
...status?.lastError !== void 0 ? { error: status.lastError } : {}
|
|
7638
|
+
}
|
|
7639
|
+
};
|
|
7202
7640
|
}
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7641
|
+
function buildServerConfig(p) {
|
|
7642
|
+
if (typeof p.command === "string" && p.command.length > 0) {
|
|
7643
|
+
const cfg = { command: p.command };
|
|
7644
|
+
if (Array.isArray(p.args) && p.args.every((a) => typeof a === "string")) cfg.args = p.args;
|
|
7645
|
+
if (p.env && typeof p.env === "object" && !Array.isArray(p.env)) {
|
|
7646
|
+
const env = {};
|
|
7647
|
+
for (const [k, v] of Object.entries(p.env)) if (typeof v === "string") env[k] = v;
|
|
7648
|
+
cfg.env = env;
|
|
7649
|
+
}
|
|
7650
|
+
if (typeof p.cwd === "string") cfg.cwd = p.cwd;
|
|
7651
|
+
return cfg;
|
|
7652
|
+
}
|
|
7653
|
+
if (typeof p.url === "string" && p.url.length > 0) {
|
|
7654
|
+
const transport = p.transport === "streamable-http" ? "streamable-http" : "sse";
|
|
7655
|
+
const cfg = {
|
|
7656
|
+
url: p.url,
|
|
7657
|
+
transport
|
|
7658
|
+
};
|
|
7659
|
+
if (p.headers && typeof p.headers === "object" && !Array.isArray(p.headers)) {
|
|
7660
|
+
const headers = {};
|
|
7661
|
+
for (const [k, v] of Object.entries(p.headers)) if (typeof v === "string") headers[k] = v;
|
|
7662
|
+
cfg.headers = headers;
|
|
7663
|
+
}
|
|
7664
|
+
return cfg;
|
|
7665
|
+
}
|
|
7666
|
+
return null;
|
|
7213
7667
|
}
|
|
7214
7668
|
/**
|
|
7215
|
-
*
|
|
7669
|
+
* Drop a server entry the agent previously registered. Restricted to
|
|
7670
|
+
* `manual`-owned entries so the agent can't accidentally clobber
|
|
7671
|
+
* integration-installed or CLI-installed servers (the daemon owns
|
|
7672
|
+
* those; the agent can ask the user to uninstall an integration via
|
|
7673
|
+
* the dashboard).
|
|
7216
7674
|
*/
|
|
7217
|
-
async function
|
|
7218
|
-
|
|
7675
|
+
async function handleMcpRemoveServer(params, manager) {
|
|
7676
|
+
if (!manager) return {
|
|
7677
|
+
ok: false,
|
|
7678
|
+
error: {
|
|
7679
|
+
code: "MCP_MANAGER_UNAVAILABLE",
|
|
7680
|
+
message: "MCP manager not initialized"
|
|
7681
|
+
}
|
|
7682
|
+
};
|
|
7683
|
+
const { id } = params;
|
|
7684
|
+
if (typeof id !== "string" || id.length === 0) return {
|
|
7685
|
+
ok: false,
|
|
7686
|
+
error: {
|
|
7687
|
+
code: "INVALID_PARAMS",
|
|
7688
|
+
message: "id is required (string)"
|
|
7689
|
+
}
|
|
7690
|
+
};
|
|
7691
|
+
try {
|
|
7692
|
+
return {
|
|
7693
|
+
ok: true,
|
|
7694
|
+
payload: { removed: await manager.removeServer(id, { expectedOwner: "manual" }) }
|
|
7695
|
+
};
|
|
7696
|
+
} catch (err) {
|
|
7697
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7698
|
+
if (message.includes("owned by")) return {
|
|
7699
|
+
ok: false,
|
|
7700
|
+
error: {
|
|
7701
|
+
code: "MCP_OWNER_MISMATCH",
|
|
7702
|
+
message
|
|
7703
|
+
}
|
|
7704
|
+
};
|
|
7705
|
+
logger$1.warn({
|
|
7706
|
+
id,
|
|
7707
|
+
err: message
|
|
7708
|
+
}, "mcp.remove_server failed");
|
|
7709
|
+
return {
|
|
7710
|
+
ok: false,
|
|
7711
|
+
error: {
|
|
7712
|
+
code: "MCP_REMOVE_FAILED",
|
|
7713
|
+
message
|
|
7714
|
+
}
|
|
7715
|
+
};
|
|
7716
|
+
}
|
|
7219
7717
|
}
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
|
|
7223
|
-
|
|
7718
|
+
async function handleMcpCallTool(bundler, params) {
|
|
7719
|
+
if (!bundler) return {
|
|
7720
|
+
ok: false,
|
|
7721
|
+
error: {
|
|
7722
|
+
code: "MCP_BUNDLER_UNAVAILABLE",
|
|
7723
|
+
message: "MCP bundler not initialized"
|
|
7724
|
+
}
|
|
7725
|
+
};
|
|
7726
|
+
const { name, args } = params;
|
|
7727
|
+
if (typeof name !== "string" || name.length === 0) return {
|
|
7728
|
+
ok: false,
|
|
7729
|
+
error: {
|
|
7730
|
+
code: "INVALID_PARAMS",
|
|
7731
|
+
message: "name is required (string)"
|
|
7732
|
+
}
|
|
7733
|
+
};
|
|
7224
7734
|
try {
|
|
7225
|
-
|
|
7226
|
-
|
|
7735
|
+
return {
|
|
7736
|
+
ok: true,
|
|
7737
|
+
payload: await bundler.callTool(name, args)
|
|
7738
|
+
};
|
|
7739
|
+
} catch (err) {
|
|
7740
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7741
|
+
logger$1.warn({
|
|
7742
|
+
tool: name,
|
|
7743
|
+
err: message
|
|
7744
|
+
}, "mcp.call_tool failed");
|
|
7745
|
+
return {
|
|
7746
|
+
ok: false,
|
|
7747
|
+
error: {
|
|
7748
|
+
code: "MCP_CALL_FAILED",
|
|
7749
|
+
message
|
|
7750
|
+
}
|
|
7751
|
+
};
|
|
7752
|
+
}
|
|
7227
7753
|
}
|
|
7754
|
+
//#endregion
|
|
7755
|
+
//#region src/integration-bootstrap.ts
|
|
7228
7756
|
/**
|
|
7229
|
-
*
|
|
7230
|
-
*
|
|
7757
|
+
* Adapter from `AgentApiClient`'s per-provider methods to the
|
|
7758
|
+
* `CredentialsResolver` shape the MCP applier expects. Returns
|
|
7759
|
+
* `undefined` for unknown providers and on 404/network error so the
|
|
7760
|
+
* applier can skip registration silently (its documented contract).
|
|
7231
7761
|
*/
|
|
7232
|
-
async function
|
|
7233
|
-
if (
|
|
7234
|
-
|
|
7235
|
-
const
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7762
|
+
async function fetchProviderCredentials(agentApi, provider, connectionId) {
|
|
7763
|
+
if (connectionId) try {
|
|
7764
|
+
const raw = await agentApi.getConnectionCredentials(connectionId);
|
|
7765
|
+
const fields = { ...raw.providerMetadata ?? {} };
|
|
7766
|
+
if (typeof raw.accessToken === "string" && raw.accessToken.length > 0) try {
|
|
7767
|
+
const bundle = JSON.parse(raw.accessToken);
|
|
7768
|
+
if (bundle && typeof bundle === "object" && !Array.isArray(bundle)) Object.assign(fields, bundle);
|
|
7769
|
+
} catch (err) {
|
|
7770
|
+
logger$1.warn({
|
|
7771
|
+
connectionId,
|
|
7772
|
+
err: err instanceof Error ? err.message : String(err)
|
|
7773
|
+
}, "Custom connection accessToken is not a JSON bundle — exposing as accessToken field");
|
|
7774
|
+
fields.accessToken = raw.accessToken;
|
|
7240
7775
|
}
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7776
|
+
return fields;
|
|
7777
|
+
} catch (err) {
|
|
7778
|
+
logger$1.warn({
|
|
7779
|
+
connectionId,
|
|
7780
|
+
err: err instanceof Error ? err.message : String(err)
|
|
7781
|
+
}, "Failed to resolve connection-scoped credentials — MCP server will be skipped");
|
|
7782
|
+
return;
|
|
7783
|
+
}
|
|
7784
|
+
const key = provider.toLowerCase();
|
|
7785
|
+
try {
|
|
7786
|
+
switch (key) {
|
|
7787
|
+
case "atlassian": return await agentApi.getAtlassianCredentials();
|
|
7788
|
+
case "github": return await agentApi.getGithubCredentials();
|
|
7789
|
+
case "xero": return await agentApi.getXeroCredentials();
|
|
7790
|
+
case "notion": return await agentApi.getNotionCredentials();
|
|
7791
|
+
case "myob": return await agentApi.getMYOBCredentials();
|
|
7792
|
+
case "google": return await agentApi.getGoogleCredentials();
|
|
7793
|
+
default:
|
|
7794
|
+
logger$1.warn({ provider }, "Unknown OAuth provider for requires_credentials — MCP server will be skipped");
|
|
7795
|
+
return;
|
|
7247
7796
|
}
|
|
7248
7797
|
} catch {
|
|
7249
|
-
return
|
|
7798
|
+
return;
|
|
7250
7799
|
}
|
|
7251
7800
|
}
|
|
7252
7801
|
/**
|
|
7253
|
-
*
|
|
7802
|
+
* Post-reconcile trigger: rebuild the command registry from the manager's
|
|
7803
|
+
* active integrations and report the available commands to the cloud
|
|
7804
|
+
* gateway. Wired by daemon.ts as the cloud client's
|
|
7805
|
+
* `onReconciliationComplete` callback.
|
|
7254
7806
|
*/
|
|
7255
|
-
|
|
7256
|
-
const
|
|
7257
|
-
if (!pid) return false;
|
|
7807
|
+
function reportAvailableCommands(deps) {
|
|
7808
|
+
const { integrationManager, commandRegistry, cloudClient } = deps;
|
|
7258
7809
|
try {
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
await removePidFile();
|
|
7271
|
-
return true;
|
|
7272
|
-
} catch {
|
|
7273
|
-
await removePidFile();
|
|
7274
|
-
return false;
|
|
7810
|
+
const activeCommands = integrationManager.getActiveCommands();
|
|
7811
|
+
commandRegistry.clear();
|
|
7812
|
+
for (const { integrationId, commands } of activeCommands) for (const cmd of commands) commandRegistry.register(integrationId, cmd.name, cmd.resolvedPath, cmd.method, cmd.timeoutMs);
|
|
7813
|
+
const commandsMsg = {
|
|
7814
|
+
type: "COMMANDS_AVAILABLE",
|
|
7815
|
+
commands: commandRegistry.listCommands()
|
|
7816
|
+
};
|
|
7817
|
+
cloudClient.sendMessage(commandsMsg);
|
|
7818
|
+
logger$1.info({ commandCount: commandsMsg.commands.length }, "Reported available commands to cloud");
|
|
7819
|
+
} catch (err) {
|
|
7820
|
+
logger$1.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to rebuild command registry");
|
|
7275
7821
|
}
|
|
7276
7822
|
}
|
|
7277
7823
|
//#endregion
|
|
@@ -23151,7 +23697,7 @@ var CommandDedupe = class {
|
|
|
23151
23697
|
* the gateway when needed and uses `callerScopes: ["operator.admin"]`.
|
|
23152
23698
|
* 3. Skip iteration if `pending.json` mtime hasn't changed (cheap fast path)
|
|
23153
23699
|
*/
|
|
23154
|
-
const execFileAsync$
|
|
23700
|
+
const execFileAsync$1 = promisify(execFile);
|
|
23155
23701
|
const APPROVE_TIMEOUT_MS = 1e4;
|
|
23156
23702
|
function resolveStateDir(override) {
|
|
23157
23703
|
if (override) return override;
|
|
@@ -23250,7 +23796,7 @@ function startPairingApprovalPoller(opts) {
|
|
|
23250
23796
|
const intervalMs = opts.intervalMs ?? 3e4;
|
|
23251
23797
|
const cliLock = opts.cliLock ?? new NoopOpenClawCliLock();
|
|
23252
23798
|
const rawExec = opts.exec ?? (async (file, args, { timeout }) => {
|
|
23253
|
-
const { stdout, stderr } = await execFileAsync$
|
|
23799
|
+
const { stdout, stderr } = await execFileAsync$1(file, args, { timeout });
|
|
23254
23800
|
return {
|
|
23255
23801
|
stdout,
|
|
23256
23802
|
stderr
|
|
@@ -23324,12 +23870,12 @@ function startPairingApprovalPoller(opts) {
|
|
|
23324
23870
|
* Sentinel-gated at `~/.alfe/.openclaw-mirror-migrated` — runs exactly
|
|
23325
23871
|
* once per agent.
|
|
23326
23872
|
*/
|
|
23327
|
-
const execFileAsync
|
|
23873
|
+
const execFileAsync = promisify(execFile);
|
|
23328
23874
|
const DEFAULT_SENTINEL_PATH = join(homedir(), ".alfe", ".openclaw-mirror-migrated");
|
|
23329
23875
|
const defaultOpenclaw = {
|
|
23330
23876
|
async listServers() {
|
|
23331
23877
|
try {
|
|
23332
|
-
const { stdout } = await execFileAsync
|
|
23878
|
+
const { stdout } = await execFileAsync("openclaw", [
|
|
23333
23879
|
"config",
|
|
23334
23880
|
"get",
|
|
23335
23881
|
"mcp.servers"
|
|
@@ -23346,7 +23892,7 @@ const defaultOpenclaw = {
|
|
|
23346
23892
|
}
|
|
23347
23893
|
},
|
|
23348
23894
|
async unsetServer(key) {
|
|
23349
|
-
await execFileAsync
|
|
23895
|
+
await execFileAsync("openclaw", [
|
|
23350
23896
|
"config",
|
|
23351
23897
|
"unset",
|
|
23352
23898
|
`mcp.servers.${key}`
|
|
@@ -23528,8 +24074,12 @@ function createMcpErrorHooks() {
|
|
|
23528
24074
|
* 7. Start agent runtime (managed mode only — e.g. OpenClaw)
|
|
23529
24075
|
* 8. Write PID file (non-managed only)
|
|
23530
24076
|
* 9. Handle graceful shutdown
|
|
24077
|
+
*
|
|
24078
|
+
* Split along seams (this file stays the orchestrator):
|
|
24079
|
+
* - daemon-bootstrap.ts — version resolution, persona migration, AI-proxy + IPC start
|
|
24080
|
+
* - mcp-handlers.ts — `mcp.*` IPC request handlers
|
|
24081
|
+
* - integration-bootstrap.ts — provider-credential fetch + post-reconcile command reporting
|
|
23531
24082
|
*/
|
|
23532
|
-
const execFileAsync = promisify(execFile);
|
|
23533
24083
|
let config;
|
|
23534
24084
|
let cloudClient;
|
|
23535
24085
|
let ipcServer = null;
|
|
@@ -23561,202 +24111,21 @@ let cloudConnected = false;
|
|
|
23561
24111
|
* Dedupe for at-least-once durable command delivery (Phase B). The cloud may
|
|
23562
24112
|
* push a command over the live WS AND re-drain the same commandId on the next
|
|
23563
24113
|
* SERVICE_REGISTER; both hit handleCloudCommand. This remembers recent outcomes
|
|
23564
|
-
* so a duplicate is ACKed with the prior result without re-executing.
|
|
23565
|
-
*/
|
|
23566
|
-
const commandDedupe = new CommandDedupe();
|
|
23567
|
-
let shuttingDown = false;
|
|
23568
|
-
let commandRegistry;
|
|
23569
|
-
let resolvedCliVersion;
|
|
23570
|
-
let resolvedRuntimeVersion;
|
|
23571
|
-
let upgradingRuntime = false;
|
|
23572
|
-
let stopPairingApprovalPoller = null;
|
|
23573
|
-
/**
|
|
23574
|
-
* Module-level handle on the runtime appliers built during start(), so the
|
|
23575
|
-
* module-scoped command handler can route `alfe.config_set` to the active
|
|
23576
|
-
* runtime's applier (mirrors `mcpManagerRef`). Null until start() builds it.
|
|
23577
|
-
*/
|
|
23578
|
-
let runtimeAppliersRef = null;
|
|
23579
|
-
/**
|
|
23580
|
-
* Resolve the installed @alfe.ai/cli version.
|
|
23581
|
-
*
|
|
23582
|
-
* Strategy (in order):
|
|
23583
|
-
* 1. ALFE_CLI_VERSION env var (set by CLI entry point when run directly)
|
|
23584
|
-
* 2. Walk up from this file to find @alfe.ai/cli/package.json
|
|
23585
|
-
* (works when systemd runs the gateway binary directly, since
|
|
23586
|
-
* the path is .../cli/node_modules/@alfe.ai/gateway/dist/...)
|
|
23587
|
-
*/
|
|
23588
|
-
/**
|
|
23589
|
-
* Adapter from `AgentApiClient`'s per-provider methods to the
|
|
23590
|
-
* `CredentialsResolver` shape the MCP applier expects. Returns
|
|
23591
|
-
* `undefined` for unknown providers and on 404/network error so the
|
|
23592
|
-
* applier can skip registration silently (its documented contract).
|
|
23593
|
-
*/
|
|
23594
|
-
async function fetchProviderCredentials(agentApi, provider, connectionId) {
|
|
23595
|
-
if (connectionId) try {
|
|
23596
|
-
const raw = await agentApi.getConnectionCredentials(connectionId);
|
|
23597
|
-
const fields = { ...raw.providerMetadata ?? {} };
|
|
23598
|
-
if (typeof raw.accessToken === "string" && raw.accessToken.length > 0) try {
|
|
23599
|
-
const bundle = JSON.parse(raw.accessToken);
|
|
23600
|
-
if (bundle && typeof bundle === "object" && !Array.isArray(bundle)) Object.assign(fields, bundle);
|
|
23601
|
-
} catch (err) {
|
|
23602
|
-
logger$1.warn({
|
|
23603
|
-
connectionId,
|
|
23604
|
-
err: err instanceof Error ? err.message : String(err)
|
|
23605
|
-
}, "Custom connection accessToken is not a JSON bundle — exposing as accessToken field");
|
|
23606
|
-
fields.accessToken = raw.accessToken;
|
|
23607
|
-
}
|
|
23608
|
-
return fields;
|
|
23609
|
-
} catch (err) {
|
|
23610
|
-
logger$1.warn({
|
|
23611
|
-
connectionId,
|
|
23612
|
-
err: err instanceof Error ? err.message : String(err)
|
|
23613
|
-
}, "Failed to resolve connection-scoped credentials — MCP server will be skipped");
|
|
23614
|
-
return;
|
|
23615
|
-
}
|
|
23616
|
-
const key = provider.toLowerCase();
|
|
23617
|
-
try {
|
|
23618
|
-
switch (key) {
|
|
23619
|
-
case "atlassian": return await agentApi.getAtlassianCredentials();
|
|
23620
|
-
case "github": return await agentApi.getGithubCredentials();
|
|
23621
|
-
case "xero": return await agentApi.getXeroCredentials();
|
|
23622
|
-
case "notion": return await agentApi.getNotionCredentials();
|
|
23623
|
-
case "myob": return await agentApi.getMYOBCredentials();
|
|
23624
|
-
case "google": return await agentApi.getGoogleCredentials();
|
|
23625
|
-
default:
|
|
23626
|
-
logger$1.warn({ provider }, "Unknown OAuth provider for requires_credentials — MCP server will be skipped");
|
|
23627
|
-
return;
|
|
23628
|
-
}
|
|
23629
|
-
} catch {
|
|
23630
|
-
return;
|
|
23631
|
-
}
|
|
23632
|
-
}
|
|
23633
|
-
async function getCliVersion() {
|
|
23634
|
-
if (process.env.ALFE_CLI_VERSION) return process.env.ALFE_CLI_VERSION;
|
|
23635
|
-
try {
|
|
23636
|
-
const { fileURLToPath } = await import("node:url");
|
|
23637
|
-
const { dirname } = await import("node:path");
|
|
23638
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
23639
|
-
for (let i = 0; i < 10; i++) {
|
|
23640
|
-
const candidate = join(dir, "package.json");
|
|
23641
|
-
try {
|
|
23642
|
-
const raw = await readFile(candidate, "utf-8");
|
|
23643
|
-
const pkg = JSON.parse(raw);
|
|
23644
|
-
if (pkg.name === "@alfe.ai/cli") return pkg.version;
|
|
23645
|
-
} catch {}
|
|
23646
|
-
const parent = dirname(dir);
|
|
23647
|
-
if (parent === dir) break;
|
|
23648
|
-
dir = parent;
|
|
23649
|
-
}
|
|
23650
|
-
} catch {}
|
|
23651
|
-
logger$1.debug("Could not resolve @alfe.ai/cli version");
|
|
23652
|
-
}
|
|
23653
|
-
/**
|
|
23654
|
-
* Per-runtime "print installed version" commands. Local to the gateway — we do
|
|
23655
|
-
* NOT import the CLI's `detectRuntime` (`@alfe.ai/gateway` must not depend on
|
|
23656
|
-
* `@alfe.ai/cli`). Unknown runtimes resolve to `undefined` (never throw) so the
|
|
23657
|
-
* connection-status report degrades gracefully instead of crashing.
|
|
23658
|
-
*/
|
|
23659
|
-
const RUNTIME_VERSION_COMMANDS = {
|
|
23660
|
-
openclaw: {
|
|
23661
|
-
command: "openclaw",
|
|
23662
|
-
args: ["--version"]
|
|
23663
|
-
},
|
|
23664
|
-
hermes: {
|
|
23665
|
-
command: "hermes",
|
|
23666
|
-
args: ["version"]
|
|
23667
|
-
},
|
|
23668
|
-
"claude-code": {
|
|
23669
|
-
command: "alfe-claude-host",
|
|
23670
|
-
args: ["--version"]
|
|
23671
|
-
}
|
|
23672
|
-
};
|
|
23673
|
-
/**
|
|
23674
|
-
* Pure resolver (exported for tests) — the per-runtime version command, or
|
|
23675
|
-
* `undefined` for an unknown runtime.
|
|
23676
|
-
*/
|
|
23677
|
-
function resolveRuntimeVersionCommand(runtime) {
|
|
23678
|
-
return RUNTIME_VERSION_COMMANDS[runtime];
|
|
23679
|
-
}
|
|
23680
|
-
/**
|
|
23681
|
-
* Resolve the installed runtime version by running the per-runtime version
|
|
23682
|
-
* command and returning the trimmed stdout. An unknown runtime (no command in
|
|
23683
|
-
* the map) returns `undefined` without spawning — it must never throw.
|
|
23684
|
-
*/
|
|
23685
|
-
async function getRuntimeVersion(runtime) {
|
|
23686
|
-
const cmd = resolveRuntimeVersionCommand(runtime);
|
|
23687
|
-
if (!cmd) {
|
|
23688
|
-
logger$1.debug({ runtime }, "No version command for runtime — skipping version detection");
|
|
23689
|
-
return;
|
|
23690
|
-
}
|
|
23691
|
-
try {
|
|
23692
|
-
const { stdout } = await execFileAsync(cmd.command, cmd.args);
|
|
23693
|
-
return stdout.trim() || void 0;
|
|
23694
|
-
} catch {
|
|
23695
|
-
logger$1.debug({ runtime }, "Could not resolve runtime version");
|
|
23696
|
-
return;
|
|
23697
|
-
}
|
|
23698
|
-
}
|
|
23699
|
-
/**
|
|
23700
|
-
* Flush pino's async transport and exit.
|
|
23701
|
-
* process.exit() can drop buffered log lines — this ensures they're written first.
|
|
24114
|
+
* so a duplicate is ACKed with the prior result without re-executing.
|
|
23702
24115
|
*/
|
|
23703
|
-
|
|
23704
|
-
|
|
23705
|
-
|
|
23706
|
-
|
|
23707
|
-
|
|
23708
|
-
|
|
23709
|
-
|
|
23710
|
-
}
|
|
24116
|
+
const commandDedupe = new CommandDedupe();
|
|
24117
|
+
let shuttingDown = false;
|
|
24118
|
+
let commandRegistry;
|
|
24119
|
+
let resolvedCliVersion;
|
|
24120
|
+
let resolvedRuntimeVersion;
|
|
24121
|
+
let upgradingRuntime = false;
|
|
24122
|
+
let stopPairingApprovalPoller = null;
|
|
23711
24123
|
/**
|
|
23712
|
-
*
|
|
23713
|
-
*
|
|
23714
|
-
*
|
|
23715
|
-
* (e.g. `~/.openclaw/workspace/`). Move stale copies into place; skip if the
|
|
23716
|
-
* workspace already has the file. Idempotent and safe to run on every daemon start.
|
|
24124
|
+
* Module-level handle on the runtime appliers built during start(), so the
|
|
24125
|
+
* module-scoped command handler can route `alfe.config_set` to the active
|
|
24126
|
+
* runtime's applier (mirrors `mcpManagerRef`). Null until start() builds it.
|
|
23717
24127
|
*/
|
|
23718
|
-
|
|
23719
|
-
"SOUL.md",
|
|
23720
|
-
"IDENTITY.md",
|
|
23721
|
-
"BOOTSTRAP.md",
|
|
23722
|
-
"AGENTS.md"
|
|
23723
|
-
];
|
|
23724
|
-
async function migrateLegacyPersonaFiles(home, agentWorkspace) {
|
|
23725
|
-
if (home === agentWorkspace) return;
|
|
23726
|
-
let moved = 0;
|
|
23727
|
-
let skipped = 0;
|
|
23728
|
-
for (const filename of PERSONA_FILES) {
|
|
23729
|
-
const src = join(home, filename);
|
|
23730
|
-
const dst = join(agentWorkspace, filename);
|
|
23731
|
-
try {
|
|
23732
|
-
await stat(src);
|
|
23733
|
-
} catch {
|
|
23734
|
-
continue;
|
|
23735
|
-
}
|
|
23736
|
-
try {
|
|
23737
|
-
await stat(dst);
|
|
23738
|
-
skipped++;
|
|
23739
|
-
continue;
|
|
23740
|
-
} catch {}
|
|
23741
|
-
try {
|
|
23742
|
-
await mkdir(agentWorkspace, { recursive: true });
|
|
23743
|
-
await rename(src, dst);
|
|
23744
|
-
moved++;
|
|
23745
|
-
} catch (err) {
|
|
23746
|
-
logger$1.warn({
|
|
23747
|
-
src,
|
|
23748
|
-
dst,
|
|
23749
|
-
err: err instanceof Error ? err.message : String(err)
|
|
23750
|
-
}, "Persona file migration: rename failed");
|
|
23751
|
-
}
|
|
23752
|
-
}
|
|
23753
|
-
if (moved > 0 || skipped > 0) logger$1.info({
|
|
23754
|
-
home,
|
|
23755
|
-
agentWorkspace,
|
|
23756
|
-
moved,
|
|
23757
|
-
skipped
|
|
23758
|
-
}, "Persona file backfill migration complete");
|
|
23759
|
-
}
|
|
24128
|
+
let runtimeAppliersRef = null;
|
|
23760
24129
|
async function startDaemon() {
|
|
23761
24130
|
startedAt = Date.now();
|
|
23762
24131
|
const managed = isManagedMode();
|
|
@@ -23816,54 +24185,12 @@ async function startDaemon() {
|
|
|
23816
24185
|
commandQueue = new CommandQueue();
|
|
23817
24186
|
commandQueue.startGC();
|
|
23818
24187
|
commandRegistry = new CommandRegistry();
|
|
23819
|
-
|
|
23820
|
-
|
|
23821
|
-
|
|
23822
|
-
|
|
23823
|
-
|
|
23824
|
-
const port = DEFAULT_AI_PROXY_PORT ?? 18193;
|
|
23825
|
-
aiProxyServer = createProxyServer({
|
|
23826
|
-
port,
|
|
23827
|
-
apiKey: config.apiKey,
|
|
23828
|
-
proxyUrl
|
|
23829
|
-
});
|
|
23830
|
-
const server = aiProxyServer;
|
|
23831
|
-
await new Promise((resolve, reject) => {
|
|
23832
|
-
server.listen(port, "127.0.0.1", () => {
|
|
23833
|
-
aiProxyRunning = true;
|
|
23834
|
-
aiProxyUrl = `http://127.0.0.1:${String(port)}`;
|
|
23835
|
-
logger$1.info({
|
|
23836
|
-
port,
|
|
23837
|
-
upstream: proxyUrl
|
|
23838
|
-
}, "AI proxy started");
|
|
23839
|
-
resolve();
|
|
23840
|
-
});
|
|
23841
|
-
server.on("error", reject);
|
|
23842
|
-
});
|
|
23843
|
-
} catch (err) {
|
|
23844
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
23845
|
-
const stack = err instanceof Error ? err.stack : void 0;
|
|
23846
|
-
logger$1.error({
|
|
23847
|
-
err: message,
|
|
23848
|
-
stack
|
|
23849
|
-
}, "Failed to start AI proxy — LLM requests will fail");
|
|
23850
|
-
}
|
|
23851
|
-
logger$1.debug({ socketPath: config.socketPath }, "Starting IPC server...");
|
|
23852
|
-
ipcServer = new IPCServer(config.socketPath);
|
|
23853
|
-
ipcServer.setRequestHandler(handlePluginRequest);
|
|
24188
|
+
const aiProxy = await startAiProxy(config.apiKey);
|
|
24189
|
+
aiProxyServer = aiProxy.server;
|
|
24190
|
+
aiProxyUrl = aiProxy.url;
|
|
24191
|
+
aiProxyRunning = aiProxy.running;
|
|
24192
|
+
ipcServer = await startIpcServer(config.socketPath, handlePluginRequest);
|
|
23854
24193
|
turnActivityProbe = new IpcTurnActivityProbe(() => ipcServer);
|
|
23855
|
-
try {
|
|
23856
|
-
await ipcServer.start();
|
|
23857
|
-
logger$1.debug("IPC server started");
|
|
23858
|
-
} catch (err) {
|
|
23859
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
23860
|
-
const stack = err instanceof Error ? err.stack : void 0;
|
|
23861
|
-
logger$1.error({
|
|
23862
|
-
err: message,
|
|
23863
|
-
stack
|
|
23864
|
-
}, "Failed to start IPC server");
|
|
23865
|
-
await flushAndExit(1);
|
|
23866
|
-
}
|
|
23867
24194
|
resolvedCliVersion = await getCliVersion();
|
|
23868
24195
|
resolvedRuntimeVersion = await getRuntimeVersion(config.runtime);
|
|
23869
24196
|
logger$1.info({
|
|
@@ -24007,19 +24334,11 @@ async function startDaemon() {
|
|
|
24007
24334
|
}
|
|
24008
24335
|
}
|
|
24009
24336
|
cloudClient.setOnReconciliationComplete(() => {
|
|
24010
|
-
|
|
24011
|
-
|
|
24012
|
-
commandRegistry
|
|
24013
|
-
|
|
24014
|
-
|
|
24015
|
-
type: "COMMANDS_AVAILABLE",
|
|
24016
|
-
commands: commandRegistry.listCommands()
|
|
24017
|
-
};
|
|
24018
|
-
cloudClient.sendMessage(commandsMsg);
|
|
24019
|
-
logger$1.info({ commandCount: commandsMsg.commands.length }, "Reported available commands to cloud");
|
|
24020
|
-
} catch (err) {
|
|
24021
|
-
logger$1.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to rebuild command registry");
|
|
24022
|
-
}
|
|
24337
|
+
reportAvailableCommands({
|
|
24338
|
+
integrationManager,
|
|
24339
|
+
commandRegistry,
|
|
24340
|
+
cloudClient
|
|
24341
|
+
});
|
|
24023
24342
|
});
|
|
24024
24343
|
cloudClient.start();
|
|
24025
24344
|
logger$1.debug("Cloud client started");
|
|
@@ -24311,513 +24630,244 @@ async function executeCloudCommand(command) {
|
|
|
24311
24630
|
}
|
|
24312
24631
|
const integrations = integrationManager.list();
|
|
24313
24632
|
return {
|
|
24314
|
-
type: "COMMAND_ACK",
|
|
24315
|
-
commandId: command.commandId,
|
|
24316
|
-
status: "ok",
|
|
24317
|
-
result: { integrations }
|
|
24318
|
-
};
|
|
24319
|
-
} catch (err) {
|
|
24320
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
24321
|
-
logger$1.error({ err: message }, "Integration status failed");
|
|
24322
|
-
return {
|
|
24323
|
-
type: "COMMAND_ACK",
|
|
24324
|
-
commandId: command.commandId,
|
|
24325
|
-
status: "error",
|
|
24326
|
-
result: {
|
|
24327
|
-
code: "STATUS_FAILED",
|
|
24328
|
-
message
|
|
24329
|
-
}
|
|
24330
|
-
};
|
|
24331
|
-
}
|
|
24332
|
-
}
|
|
24333
|
-
if (command.command === "alfe.config_set") {
|
|
24334
|
-
const payload = command.payload;
|
|
24335
|
-
const key = payload?.key;
|
|
24336
|
-
const value = payload?.value;
|
|
24337
|
-
if (!key || value === void 0) return {
|
|
24338
|
-
type: "COMMAND_ACK",
|
|
24339
|
-
commandId: command.commandId,
|
|
24340
|
-
status: "error",
|
|
24341
|
-
result: {
|
|
24342
|
-
code: "INVALID_PAYLOAD",
|
|
24343
|
-
message: "alfe.config_set requires key and value"
|
|
24344
|
-
}
|
|
24345
|
-
};
|
|
24346
|
-
const runtime = config.runtime;
|
|
24347
|
-
const applier = runtimeAppliersRef?.get(runtime);
|
|
24348
|
-
if (!applier || typeof applier.setConfigRaw !== "function") {
|
|
24349
|
-
const message = `No runtime applier supports config_set for runtime "${runtime}"`;
|
|
24350
|
-
logger$1.warn({
|
|
24351
|
-
runtime,
|
|
24352
|
-
key
|
|
24353
|
-
}, message);
|
|
24354
|
-
return {
|
|
24355
|
-
type: "COMMAND_ACK",
|
|
24356
|
-
commandId: command.commandId,
|
|
24357
|
-
status: "error",
|
|
24358
|
-
result: {
|
|
24359
|
-
code: "CONFIG_SET_UNSUPPORTED",
|
|
24360
|
-
message
|
|
24361
|
-
}
|
|
24362
|
-
};
|
|
24363
|
-
}
|
|
24364
|
-
try {
|
|
24365
|
-
await applier.setConfigRaw(key, value);
|
|
24366
|
-
logger$1.info({
|
|
24367
|
-
runtime,
|
|
24368
|
-
key
|
|
24369
|
-
}, "Applied config via runtime applier setConfigRaw");
|
|
24370
|
-
return {
|
|
24371
|
-
type: "COMMAND_ACK",
|
|
24372
|
-
commandId: command.commandId,
|
|
24373
|
-
status: "ok",
|
|
24374
|
-
result: {
|
|
24375
|
-
key,
|
|
24376
|
-
value
|
|
24377
|
-
}
|
|
24378
|
-
};
|
|
24379
|
-
} catch (err) {
|
|
24380
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
24381
|
-
logger$1.error({
|
|
24382
|
-
err: message,
|
|
24383
|
-
runtime,
|
|
24384
|
-
key
|
|
24385
|
-
}, "Failed to apply config via runtime applier");
|
|
24386
|
-
return {
|
|
24387
|
-
type: "COMMAND_ACK",
|
|
24388
|
-
commandId: command.commandId,
|
|
24389
|
-
status: "error",
|
|
24390
|
-
result: {
|
|
24391
|
-
code: "CONFIG_SET_FAILED",
|
|
24392
|
-
message
|
|
24393
|
-
}
|
|
24394
|
-
};
|
|
24395
|
-
}
|
|
24396
|
-
}
|
|
24397
|
-
if (commandRegistry.has(command.command)) try {
|
|
24398
|
-
const ctx = buildCommandContext();
|
|
24399
|
-
const result = await commandRegistry.execute(command.command, typeof command.payload === "object" && command.payload !== null ? command.payload : {}, ctx);
|
|
24400
|
-
return {
|
|
24401
|
-
type: "COMMAND_ACK",
|
|
24402
|
-
commandId: command.commandId,
|
|
24403
|
-
status: result.status,
|
|
24404
|
-
result: result.result
|
|
24405
|
-
};
|
|
24406
|
-
} catch (err) {
|
|
24407
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
24408
|
-
logger$1.error({
|
|
24409
|
-
err: message,
|
|
24410
|
-
command: command.command
|
|
24411
|
-
}, "Command registry execution failed");
|
|
24412
|
-
return {
|
|
24413
|
-
type: "COMMAND_ACK",
|
|
24414
|
-
commandId: command.commandId,
|
|
24415
|
-
status: "error",
|
|
24416
|
-
result: {
|
|
24417
|
-
code: "REGISTRY_ERROR",
|
|
24418
|
-
message
|
|
24419
|
-
}
|
|
24420
|
-
};
|
|
24421
|
-
}
|
|
24422
|
-
const ipcRequest = cloudCommandToIPCRequest(command);
|
|
24423
|
-
if (!ipcRequest) {
|
|
24424
|
-
logger$1.warn({ command: command.command }, "Unrecognized cloud command");
|
|
24425
|
-
return {
|
|
24426
|
-
type: "COMMAND_ACK",
|
|
24427
|
-
commandId: command.commandId,
|
|
24428
|
-
status: "error",
|
|
24429
|
-
result: {
|
|
24430
|
-
code: "UNKNOWN_COMMAND",
|
|
24431
|
-
message: `Unrecognized command: ${command.command}`
|
|
24432
|
-
}
|
|
24433
|
-
};
|
|
24434
|
-
}
|
|
24435
|
-
const plugins = ipcServer?.getRegisteredPlugins() ?? [];
|
|
24436
|
-
if (plugins.length === 0) {
|
|
24437
|
-
logger$1.info({
|
|
24438
|
-
commandId: command.commandId,
|
|
24439
|
-
command: command.command
|
|
24440
|
-
}, "No plugins connected — queuing command");
|
|
24441
|
-
commandQueue.enqueue("_default", ipcRequest, command.commandId);
|
|
24442
|
-
return {
|
|
24443
|
-
type: "COMMAND_ACK",
|
|
24444
|
-
commandId: command.commandId,
|
|
24445
|
-
status: "ok",
|
|
24446
|
-
result: {
|
|
24447
|
-
queued: true,
|
|
24448
|
-
message: "Command queued — no plugins connected"
|
|
24449
|
-
}
|
|
24450
|
-
};
|
|
24451
|
-
}
|
|
24452
|
-
const [pluginId] = plugins[0];
|
|
24453
|
-
if (!ipcServer) return {
|
|
24454
|
-
type: "COMMAND_ACK",
|
|
24455
|
-
commandId: command.commandId,
|
|
24456
|
-
status: "error",
|
|
24457
|
-
result: {
|
|
24458
|
-
code: "NO_IPC",
|
|
24459
|
-
message: "IPC server not available"
|
|
24633
|
+
type: "COMMAND_ACK",
|
|
24634
|
+
commandId: command.commandId,
|
|
24635
|
+
status: "ok",
|
|
24636
|
+
result: { integrations }
|
|
24637
|
+
};
|
|
24638
|
+
} catch (err) {
|
|
24639
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24640
|
+
logger$1.error({ err: message }, "Integration status failed");
|
|
24641
|
+
return {
|
|
24642
|
+
type: "COMMAND_ACK",
|
|
24643
|
+
commandId: command.commandId,
|
|
24644
|
+
status: "error",
|
|
24645
|
+
result: {
|
|
24646
|
+
code: "STATUS_FAILED",
|
|
24647
|
+
message
|
|
24648
|
+
}
|
|
24649
|
+
};
|
|
24460
24650
|
}
|
|
24461
|
-
}
|
|
24462
|
-
|
|
24463
|
-
const
|
|
24464
|
-
|
|
24465
|
-
|
|
24466
|
-
|
|
24467
|
-
return {
|
|
24651
|
+
}
|
|
24652
|
+
if (command.command === "alfe.config_set") {
|
|
24653
|
+
const payload = command.payload;
|
|
24654
|
+
const key = payload?.key;
|
|
24655
|
+
const value = payload?.value;
|
|
24656
|
+
if (!key || value === void 0) return {
|
|
24468
24657
|
type: "COMMAND_ACK",
|
|
24469
24658
|
commandId: command.commandId,
|
|
24470
24659
|
status: "error",
|
|
24471
24660
|
result: {
|
|
24472
|
-
code: "
|
|
24473
|
-
message
|
|
24661
|
+
code: "INVALID_PAYLOAD",
|
|
24662
|
+
message: "alfe.config_set requires key and value"
|
|
24474
24663
|
}
|
|
24475
24664
|
};
|
|
24476
|
-
|
|
24477
|
-
|
|
24478
|
-
|
|
24479
|
-
|
|
24480
|
-
|
|
24481
|
-
|
|
24482
|
-
|
|
24483
|
-
|
|
24484
|
-
apiKey: config.apiKey,
|
|
24485
|
-
async exec(cmd, opts) {
|
|
24486
|
-
const { exec: execCb } = await import("child_process");
|
|
24487
|
-
const { promisify } = await import("util");
|
|
24488
|
-
const { stdout, stderr } = await promisify(execCb)(cmd, {
|
|
24489
|
-
cwd: workspacePath,
|
|
24490
|
-
timeout: opts?.timeoutMs ?? 25e3,
|
|
24491
|
-
maxBuffer: opts?.maxBuffer ?? 512 * 1024
|
|
24492
|
-
});
|
|
24665
|
+
const runtime = config.runtime;
|
|
24666
|
+
const applier = runtimeAppliersRef?.get(runtime);
|
|
24667
|
+
if (!applier || typeof applier.setConfigRaw !== "function") {
|
|
24668
|
+
const message = `No runtime applier supports config_set for runtime "${runtime}"`;
|
|
24669
|
+
logger$1.warn({
|
|
24670
|
+
runtime,
|
|
24671
|
+
key
|
|
24672
|
+
}, message);
|
|
24493
24673
|
return {
|
|
24494
|
-
|
|
24495
|
-
|
|
24674
|
+
type: "COMMAND_ACK",
|
|
24675
|
+
commandId: command.commandId,
|
|
24676
|
+
status: "error",
|
|
24677
|
+
result: {
|
|
24678
|
+
code: "CONFIG_SET_UNSUPPORTED",
|
|
24679
|
+
message
|
|
24680
|
+
}
|
|
24496
24681
|
};
|
|
24497
24682
|
}
|
|
24498
|
-
|
|
24499
|
-
|
|
24500
|
-
|
|
24501
|
-
|
|
24502
|
-
|
|
24503
|
-
|
|
24504
|
-
|
|
24505
|
-
|
|
24506
|
-
|
|
24507
|
-
|
|
24508
|
-
|
|
24509
|
-
|
|
24510
|
-
|
|
24511
|
-
|
|
24512
|
-
|
|
24513
|
-
|
|
24514
|
-
|
|
24515
|
-
|
|
24516
|
-
|
|
24517
|
-
|
|
24518
|
-
|
|
24519
|
-
|
|
24520
|
-
|
|
24521
|
-
|
|
24522
|
-
|
|
24523
|
-
|
|
24524
|
-
|
|
24525
|
-
|
|
24526
|
-
|
|
24527
|
-
|
|
24528
|
-
|
|
24529
|
-
},
|
|
24530
|
-
cloud: {
|
|
24531
|
-
status: cloudConnected ? "connected" : "disconnected",
|
|
24532
|
-
latencyMs: cloudClient.getLatencyMs()
|
|
24533
|
-
},
|
|
24534
|
-
aiProxy: { status: aiProxyRunning ? "running" : "stopped" },
|
|
24535
|
-
plugins: (ipcServer?.getRegisteredPlugins() ?? []).map(([, info]) => ({
|
|
24536
|
-
name: info.name,
|
|
24537
|
-
version: info.version,
|
|
24538
|
-
capabilities: info.capabilities,
|
|
24539
|
-
connectedAt: info.connectedAt,
|
|
24540
|
-
lastSeen: info.lastSeen
|
|
24541
|
-
})),
|
|
24542
|
-
commandQueue: { totalPending: commandQueue.totalPending() }
|
|
24543
|
-
}
|
|
24544
|
-
};
|
|
24545
|
-
}
|
|
24546
|
-
function handleIntegrationList() {
|
|
24547
|
-
return {
|
|
24548
|
-
ok: true,
|
|
24549
|
-
payload: { integrations: integrationManager.list() }
|
|
24550
|
-
};
|
|
24551
|
-
}
|
|
24552
|
-
/**
|
|
24553
|
-
* Return the daemon-hosted MCP bundler's current namespaced tool catalog.
|
|
24554
|
-
* Called by the openclaw-mcp-bundler plugin on every tool-factory invocation;
|
|
24555
|
-
* cheap (in-memory snapshot, no I/O).
|
|
24556
|
-
*/
|
|
24557
|
-
function handleMcpListTools(bundler) {
|
|
24558
|
-
if (!bundler) return {
|
|
24559
|
-
ok: false,
|
|
24560
|
-
error: {
|
|
24561
|
-
code: "MCP_BUNDLER_UNAVAILABLE",
|
|
24562
|
-
message: "MCP bundler not initialized"
|
|
24563
|
-
}
|
|
24564
|
-
};
|
|
24565
|
-
return {
|
|
24566
|
-
ok: true,
|
|
24567
|
-
payload: { tools: bundler.listTools() }
|
|
24568
|
-
};
|
|
24569
|
-
}
|
|
24570
|
-
/**
|
|
24571
|
-
* Observability backstop for the INTEGRATION warm path. `applyForIntegration`
|
|
24572
|
-
* registers MCP servers into the store and the daemon warms them silently via
|
|
24573
|
-
* the store-change `onChange` hook — so a server that fails its connect (e.g.
|
|
24574
|
-
* an MCP server whose backing account/credential wasn't resolvable at warm
|
|
24575
|
-
* time, like ctrader-mcp `exit(1)`-ing on empty accounts) leaves NO trace,
|
|
24576
|
-
* making the black hole undiagnosable. After a warm, read `bundler.statuses()`
|
|
24577
|
-
* and warn once per still-failed server so the failure is visible. These
|
|
24578
|
-
* self-heal on the bundler's background retry sweep once the dependency
|
|
24579
|
-
* appears. Returns the servers it warned about (for tests / callers).
|
|
24580
|
-
*/
|
|
24581
|
-
function warnFailedMcpServers(bundler, log, reason) {
|
|
24582
|
-
if (!bundler) return [];
|
|
24583
|
-
const failed = bundler.statuses().filter((s) => !s.connected && s.lastError !== void 0);
|
|
24584
|
-
for (const status of failed) log.warn({
|
|
24585
|
-
server: status.name,
|
|
24586
|
-
reason,
|
|
24587
|
-
consecutiveFailures: status.consecutiveFailures,
|
|
24588
|
-
lastError: status.lastError
|
|
24589
|
-
}, `MCP server "${status.name}" failed to connect: ${status.lastError ?? ""}`);
|
|
24590
|
-
return failed;
|
|
24591
|
-
}
|
|
24592
|
-
/**
|
|
24593
|
-
* Route a tool call to the appropriate MCP child via the daemon-hosted
|
|
24594
|
-
* bundler. `name` is the prefixed (`mcp__<server>__<tool>`) name; args is
|
|
24595
|
-
* the raw JSON object the LLM produced.
|
|
24596
|
-
*/
|
|
24597
|
-
/**
|
|
24598
|
-
* List every server entry in the alfe bundler store. Lets agents inspect
|
|
24599
|
-
* what they've already registered before adding a new one.
|
|
24600
|
-
*/
|
|
24601
|
-
function handleMcpListServers(manager = mcpManagerRef) {
|
|
24602
|
-
if (!manager) return {
|
|
24603
|
-
ok: false,
|
|
24604
|
-
error: {
|
|
24605
|
-
code: "MCP_MANAGER_UNAVAILABLE",
|
|
24606
|
-
message: "MCP manager not initialized"
|
|
24607
|
-
}
|
|
24608
|
-
};
|
|
24609
|
-
const statuses = manager.serverStatuses ? manager.serverStatuses() : [];
|
|
24610
|
-
const byName = new Map(statuses.map((s) => [s.name, s]));
|
|
24611
|
-
return {
|
|
24612
|
-
ok: true,
|
|
24613
|
-
payload: { servers: manager.listServers().map(({ id, entry }) => ({
|
|
24614
|
-
id,
|
|
24615
|
-
entry,
|
|
24616
|
-
status: byName.get(id) ?? {
|
|
24617
|
-
name: id,
|
|
24618
|
-
connected: false,
|
|
24619
|
-
toolCount: 0,
|
|
24620
|
-
consecutiveFailures: 0
|
|
24621
|
-
}
|
|
24622
|
-
})) }
|
|
24623
|
-
};
|
|
24624
|
-
}
|
|
24625
|
-
/**
|
|
24626
|
-
* How long the add-confirm probe waits for the freshly-added server to
|
|
24627
|
-
* connect before replying. This is effectively the whole budget for
|
|
24628
|
-
* `bundler.warmServer`: `Manager.addServer` now reconciles the bundler
|
|
24629
|
-
* BEFORE resolving, so the Connection object already exists and
|
|
24630
|
-
* `warmServer`'s pre-reconcile is a no-diff cheap pass. Kept well under the
|
|
24631
|
-
* plugin caller's 30s IPC timeout. A slower server still connects in the
|
|
24632
|
-
* background and surfaces on the next `alfe_mcp_list_tools` — the probe just
|
|
24633
|
-
* reports what it saw within the window; a missed connect is re-attempted by
|
|
24634
|
-
* the bundler's `retryNeverConnected` sweep.
|
|
24635
|
-
*/
|
|
24636
|
-
const MCP_ADD_WARM_TIMEOUT_MS = 12e3;
|
|
24637
|
-
/**
|
|
24638
|
-
* Register a new MCP server in the alfe bundler store on behalf of the agent,
|
|
24639
|
-
* then CONFIRM the connect before replying so the agent learns whether the
|
|
24640
|
-
* server actually works. Owned as `'manual'` so the agent can later remove it
|
|
24641
|
-
* without an expectedOwner conflict — matches what `alfe mcp add` does from the
|
|
24642
|
-
* CLI. Registration is durable regardless of the probe outcome: a probe
|
|
24643
|
-
* failure (or a runtime with no daemon bundler, e.g. hermes) still returns
|
|
24644
|
-
* `ok` with `connected: false`; the store watcher / runtime picks the entry up.
|
|
24645
|
-
*/
|
|
24646
|
-
async function handleMcpAddServer(params, manager = mcpManagerRef) {
|
|
24647
|
-
if (!manager) return {
|
|
24648
|
-
ok: false,
|
|
24649
|
-
error: {
|
|
24650
|
-
code: "MCP_MANAGER_UNAVAILABLE",
|
|
24651
|
-
message: "MCP manager not initialized"
|
|
24652
|
-
}
|
|
24653
|
-
};
|
|
24654
|
-
const p = params;
|
|
24655
|
-
if (typeof p.id !== "string" || p.id.length === 0) return {
|
|
24656
|
-
ok: false,
|
|
24657
|
-
error: {
|
|
24658
|
-
code: "INVALID_PARAMS",
|
|
24659
|
-
message: "id is required (string)"
|
|
24660
|
-
}
|
|
24661
|
-
};
|
|
24662
|
-
const config = buildServerConfig(p);
|
|
24663
|
-
if (!config) return {
|
|
24664
|
-
ok: false,
|
|
24665
|
-
error: {
|
|
24666
|
-
code: "INVALID_PARAMS",
|
|
24667
|
-
message: "expected either { command, args?, env?, cwd? } for stdio or { url, transport, headers? } for remote"
|
|
24683
|
+
try {
|
|
24684
|
+
await applier.setConfigRaw(key, value);
|
|
24685
|
+
logger$1.info({
|
|
24686
|
+
runtime,
|
|
24687
|
+
key
|
|
24688
|
+
}, "Applied config via runtime applier setConfigRaw");
|
|
24689
|
+
return {
|
|
24690
|
+
type: "COMMAND_ACK",
|
|
24691
|
+
commandId: command.commandId,
|
|
24692
|
+
status: "ok",
|
|
24693
|
+
result: {
|
|
24694
|
+
key,
|
|
24695
|
+
value
|
|
24696
|
+
}
|
|
24697
|
+
};
|
|
24698
|
+
} catch (err) {
|
|
24699
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24700
|
+
logger$1.error({
|
|
24701
|
+
err: message,
|
|
24702
|
+
runtime,
|
|
24703
|
+
key
|
|
24704
|
+
}, "Failed to apply config via runtime applier");
|
|
24705
|
+
return {
|
|
24706
|
+
type: "COMMAND_ACK",
|
|
24707
|
+
commandId: command.commandId,
|
|
24708
|
+
status: "error",
|
|
24709
|
+
result: {
|
|
24710
|
+
code: "CONFIG_SET_FAILED",
|
|
24711
|
+
message
|
|
24712
|
+
}
|
|
24713
|
+
};
|
|
24668
24714
|
}
|
|
24669
|
-
}
|
|
24670
|
-
try {
|
|
24671
|
-
|
|
24672
|
-
|
|
24673
|
-
|
|
24674
|
-
|
|
24715
|
+
}
|
|
24716
|
+
if (commandRegistry.has(command.command)) try {
|
|
24717
|
+
const ctx = buildCommandContext();
|
|
24718
|
+
const result = await commandRegistry.execute(command.command, typeof command.payload === "object" && command.payload !== null ? command.payload : {}, ctx);
|
|
24719
|
+
return {
|
|
24720
|
+
type: "COMMAND_ACK",
|
|
24721
|
+
commandId: command.commandId,
|
|
24722
|
+
status: result.status,
|
|
24723
|
+
result: result.result
|
|
24724
|
+
};
|
|
24675
24725
|
} catch (err) {
|
|
24676
24726
|
const message = err instanceof Error ? err.message : String(err);
|
|
24677
|
-
logger$1.
|
|
24678
|
-
|
|
24679
|
-
|
|
24680
|
-
}, "
|
|
24727
|
+
logger$1.error({
|
|
24728
|
+
err: message,
|
|
24729
|
+
command: command.command
|
|
24730
|
+
}, "Command registry execution failed");
|
|
24681
24731
|
return {
|
|
24682
|
-
|
|
24683
|
-
|
|
24684
|
-
|
|
24732
|
+
type: "COMMAND_ACK",
|
|
24733
|
+
commandId: command.commandId,
|
|
24734
|
+
status: "error",
|
|
24735
|
+
result: {
|
|
24736
|
+
code: "REGISTRY_ERROR",
|
|
24685
24737
|
message
|
|
24686
24738
|
}
|
|
24687
24739
|
};
|
|
24688
24740
|
}
|
|
24689
|
-
|
|
24690
|
-
if (
|
|
24691
|
-
|
|
24692
|
-
|
|
24693
|
-
|
|
24694
|
-
|
|
24695
|
-
|
|
24696
|
-
|
|
24697
|
-
|
|
24698
|
-
|
|
24699
|
-
|
|
24700
|
-
|
|
24701
|
-
id: p.id,
|
|
24702
|
-
connected: status?.connected ?? false,
|
|
24703
|
-
toolCount: status?.toolCount ?? 0,
|
|
24704
|
-
...status?.lastError !== void 0 ? { error: status.lastError } : {}
|
|
24705
|
-
}
|
|
24706
|
-
};
|
|
24707
|
-
}
|
|
24708
|
-
function buildServerConfig(p) {
|
|
24709
|
-
if (typeof p.command === "string" && p.command.length > 0) {
|
|
24710
|
-
const cfg = { command: p.command };
|
|
24711
|
-
if (Array.isArray(p.args) && p.args.every((a) => typeof a === "string")) cfg.args = p.args;
|
|
24712
|
-
if (p.env && typeof p.env === "object" && !Array.isArray(p.env)) {
|
|
24713
|
-
const env = {};
|
|
24714
|
-
for (const [k, v] of Object.entries(p.env)) if (typeof v === "string") env[k] = v;
|
|
24715
|
-
cfg.env = env;
|
|
24716
|
-
}
|
|
24717
|
-
if (typeof p.cwd === "string") cfg.cwd = p.cwd;
|
|
24718
|
-
return cfg;
|
|
24741
|
+
const ipcRequest = cloudCommandToIPCRequest(command);
|
|
24742
|
+
if (!ipcRequest) {
|
|
24743
|
+
logger$1.warn({ command: command.command }, "Unrecognized cloud command");
|
|
24744
|
+
return {
|
|
24745
|
+
type: "COMMAND_ACK",
|
|
24746
|
+
commandId: command.commandId,
|
|
24747
|
+
status: "error",
|
|
24748
|
+
result: {
|
|
24749
|
+
code: "UNKNOWN_COMMAND",
|
|
24750
|
+
message: `Unrecognized command: ${command.command}`
|
|
24751
|
+
}
|
|
24752
|
+
};
|
|
24719
24753
|
}
|
|
24720
|
-
|
|
24721
|
-
|
|
24722
|
-
|
|
24723
|
-
|
|
24724
|
-
|
|
24754
|
+
const plugins = ipcServer?.getRegisteredPlugins() ?? [];
|
|
24755
|
+
if (plugins.length === 0) {
|
|
24756
|
+
logger$1.info({
|
|
24757
|
+
commandId: command.commandId,
|
|
24758
|
+
command: command.command
|
|
24759
|
+
}, "No plugins connected — queuing command");
|
|
24760
|
+
commandQueue.enqueue("_default", ipcRequest, command.commandId);
|
|
24761
|
+
return {
|
|
24762
|
+
type: "COMMAND_ACK",
|
|
24763
|
+
commandId: command.commandId,
|
|
24764
|
+
status: "ok",
|
|
24765
|
+
result: {
|
|
24766
|
+
queued: true,
|
|
24767
|
+
message: "Command queued — no plugins connected"
|
|
24768
|
+
}
|
|
24725
24769
|
};
|
|
24726
|
-
if (p.headers && typeof p.headers === "object" && !Array.isArray(p.headers)) {
|
|
24727
|
-
const headers = {};
|
|
24728
|
-
for (const [k, v] of Object.entries(p.headers)) if (typeof v === "string") headers[k] = v;
|
|
24729
|
-
cfg.headers = headers;
|
|
24730
|
-
}
|
|
24731
|
-
return cfg;
|
|
24732
24770
|
}
|
|
24733
|
-
|
|
24734
|
-
|
|
24735
|
-
|
|
24736
|
-
|
|
24737
|
-
|
|
24738
|
-
|
|
24739
|
-
|
|
24740
|
-
|
|
24741
|
-
*/
|
|
24742
|
-
async function handleMcpRemoveServer(params, manager = mcpManagerRef) {
|
|
24743
|
-
if (!manager) return {
|
|
24744
|
-
ok: false,
|
|
24745
|
-
error: {
|
|
24746
|
-
code: "MCP_MANAGER_UNAVAILABLE",
|
|
24747
|
-
message: "MCP manager not initialized"
|
|
24748
|
-
}
|
|
24749
|
-
};
|
|
24750
|
-
const { id } = params;
|
|
24751
|
-
if (typeof id !== "string" || id.length === 0) return {
|
|
24752
|
-
ok: false,
|
|
24753
|
-
error: {
|
|
24754
|
-
code: "INVALID_PARAMS",
|
|
24755
|
-
message: "id is required (string)"
|
|
24771
|
+
const [pluginId] = plugins[0];
|
|
24772
|
+
if (!ipcServer) return {
|
|
24773
|
+
type: "COMMAND_ACK",
|
|
24774
|
+
commandId: command.commandId,
|
|
24775
|
+
status: "error",
|
|
24776
|
+
result: {
|
|
24777
|
+
code: "NO_IPC",
|
|
24778
|
+
message: "IPC server not available"
|
|
24756
24779
|
}
|
|
24757
24780
|
};
|
|
24758
24781
|
try {
|
|
24759
|
-
|
|
24760
|
-
|
|
24761
|
-
payload: { removed: await manager.removeServer(id, { expectedOwner: "manual" }) }
|
|
24762
|
-
};
|
|
24782
|
+
const response = await ipcServer.sendRequest(pluginId, ipcRequest.method, ipcRequest.params, 3e4);
|
|
24783
|
+
return ipcResponseToCloudAck(command.commandId, response);
|
|
24763
24784
|
} catch (err) {
|
|
24764
24785
|
const message = err instanceof Error ? err.message : String(err);
|
|
24765
|
-
if (message.includes("owned by")) return {
|
|
24766
|
-
ok: false,
|
|
24767
|
-
error: {
|
|
24768
|
-
code: "MCP_OWNER_MISMATCH",
|
|
24769
|
-
message
|
|
24770
|
-
}
|
|
24771
|
-
};
|
|
24772
|
-
logger$1.warn({
|
|
24773
|
-
id,
|
|
24774
|
-
err: message
|
|
24775
|
-
}, "mcp.remove_server failed");
|
|
24776
24786
|
return {
|
|
24777
|
-
|
|
24778
|
-
|
|
24779
|
-
|
|
24787
|
+
type: "COMMAND_ACK",
|
|
24788
|
+
commandId: command.commandId,
|
|
24789
|
+
status: "error",
|
|
24790
|
+
result: {
|
|
24791
|
+
code: "PLUGIN_ERROR",
|
|
24780
24792
|
message
|
|
24781
24793
|
}
|
|
24782
24794
|
};
|
|
24783
24795
|
}
|
|
24784
24796
|
}
|
|
24785
|
-
|
|
24786
|
-
|
|
24787
|
-
|
|
24788
|
-
|
|
24789
|
-
|
|
24790
|
-
|
|
24791
|
-
|
|
24792
|
-
|
|
24793
|
-
|
|
24794
|
-
|
|
24795
|
-
|
|
24796
|
-
|
|
24797
|
-
|
|
24798
|
-
|
|
24797
|
+
function buildCommandContext() {
|
|
24798
|
+
const workspacePath = Object.values(config.runtimes)[0]?.workspace ?? "~/.openclaw";
|
|
24799
|
+
return {
|
|
24800
|
+
workspacePath,
|
|
24801
|
+
aiProxyUrl: aiProxyUrl ?? void 0,
|
|
24802
|
+
aiProxyRunning,
|
|
24803
|
+
apiKey: config.apiKey,
|
|
24804
|
+
async exec(cmd, opts) {
|
|
24805
|
+
const { exec: execCb } = await import("child_process");
|
|
24806
|
+
const { promisify } = await import("util");
|
|
24807
|
+
const { stdout, stderr } = await promisify(execCb)(cmd, {
|
|
24808
|
+
cwd: workspacePath,
|
|
24809
|
+
timeout: opts?.timeoutMs ?? 25e3,
|
|
24810
|
+
maxBuffer: opts?.maxBuffer ?? 512 * 1024
|
|
24811
|
+
});
|
|
24812
|
+
return {
|
|
24813
|
+
stdout: stdout.trim(),
|
|
24814
|
+
stderr: stderr.trim()
|
|
24815
|
+
};
|
|
24799
24816
|
}
|
|
24800
24817
|
};
|
|
24801
|
-
|
|
24802
|
-
|
|
24803
|
-
|
|
24804
|
-
|
|
24805
|
-
|
|
24806
|
-
|
|
24807
|
-
|
|
24808
|
-
|
|
24809
|
-
|
|
24810
|
-
|
|
24811
|
-
|
|
24812
|
-
return {
|
|
24818
|
+
}
|
|
24819
|
+
function handlePluginRequest(method, params, pluginId) {
|
|
24820
|
+
switch (method) {
|
|
24821
|
+
case "status": return Promise.resolve(handleStatus());
|
|
24822
|
+
case "integration.list": return Promise.resolve(handleIntegrationList());
|
|
24823
|
+
case "integration.report": return Promise.resolve(handleIntegrationReport(params, pluginId));
|
|
24824
|
+
case "mcp.list_tools": return Promise.resolve(handleMcpListTools(mcpBundler));
|
|
24825
|
+
case "mcp.call_tool": return handleMcpCallTool(mcpBundler, params);
|
|
24826
|
+
case "mcp.list_servers": return Promise.resolve(handleMcpListServers(mcpManagerRef));
|
|
24827
|
+
case "mcp.add_server": return handleMcpAddServer(params, mcpManagerRef);
|
|
24828
|
+
case "mcp.remove_server": return handleMcpRemoveServer(params, mcpManagerRef);
|
|
24829
|
+
default: return Promise.resolve({
|
|
24813
24830
|
ok: false,
|
|
24814
24831
|
error: {
|
|
24815
|
-
code: "
|
|
24816
|
-
message
|
|
24832
|
+
code: "UNKNOWN_METHOD",
|
|
24833
|
+
message: `Unknown method: ${method}`
|
|
24817
24834
|
}
|
|
24818
|
-
};
|
|
24835
|
+
});
|
|
24819
24836
|
}
|
|
24820
24837
|
}
|
|
24838
|
+
function handleStatus() {
|
|
24839
|
+
return {
|
|
24840
|
+
ok: true,
|
|
24841
|
+
payload: {
|
|
24842
|
+
daemon: {
|
|
24843
|
+
status: "running",
|
|
24844
|
+
pid: process.pid,
|
|
24845
|
+
uptime: (Date.now() - startedAt) / 1e3,
|
|
24846
|
+
version: "0.1.0",
|
|
24847
|
+
runtimeVersion: resolvedRuntimeVersion
|
|
24848
|
+
},
|
|
24849
|
+
cloud: {
|
|
24850
|
+
status: cloudConnected ? "connected" : "disconnected",
|
|
24851
|
+
latencyMs: cloudClient.getLatencyMs()
|
|
24852
|
+
},
|
|
24853
|
+
aiProxy: { status: aiProxyRunning ? "running" : "stopped" },
|
|
24854
|
+
plugins: (ipcServer?.getRegisteredPlugins() ?? []).map(([, info]) => ({
|
|
24855
|
+
name: info.name,
|
|
24856
|
+
version: info.version,
|
|
24857
|
+
capabilities: info.capabilities,
|
|
24858
|
+
connectedAt: info.connectedAt,
|
|
24859
|
+
lastSeen: info.lastSeen
|
|
24860
|
+
})),
|
|
24861
|
+
commandQueue: { totalPending: commandQueue.totalPending() }
|
|
24862
|
+
}
|
|
24863
|
+
};
|
|
24864
|
+
}
|
|
24865
|
+
function handleIntegrationList() {
|
|
24866
|
+
return {
|
|
24867
|
+
ok: true,
|
|
24868
|
+
payload: { integrations: integrationManager.list() }
|
|
24869
|
+
};
|
|
24870
|
+
}
|
|
24821
24871
|
function handleIntegrationReport(params, pluginId) {
|
|
24822
24872
|
const { name, status, detail } = params;
|
|
24823
24873
|
if (!name || !status) return {
|