u-foo 3.0.18 → 3.0.19
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/OPTIONAL_SKILLS/ufoo-bus-poll/SKILL.md +10 -6
- package/README.md +37 -17
- package/README.zh-CN.md +30 -19
- package/SKILLS/ufoo/SKILL.md +16 -8
- package/SKILLS/ufoo-bus/SKILL.md +16 -7
- package/bin/ufoo.js +19 -5
- package/package.json +2 -1
- package/src/app/chat/commandExecutor.js +11 -5
- package/src/app/cli/run.js +2 -3
- package/src/config.js +10 -0
- package/src/coordination/bus/subscriber.js +32 -0
- package/src/coordination/state/paths.js +4 -0
- package/src/runtime/contracts/eventContract.js +5 -0
- package/src/runtime/daemon/controlPlaneService.js +130 -12
- package/src/runtime/daemon/index.js +65 -0
- package/src/runtime/daemon/mcpConfigure.js +175 -0
- package/src/runtime/daemon/mcpControl.js +123 -0
- package/src/runtime/daemon/mcpHttpServer.js +412 -0
- package/src/runtime/daemon/mcpServer.js +94 -103
- package/src/runtime/daemon/mcpStdioProxy.js +247 -0
- package/src/runtime/daemon/projectRuntimeControlPlane.js +115 -0
- package/src/runtime/daemon/projectRuntimeGateway.js +358 -0
|
@@ -22,6 +22,8 @@ const WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
|
22
22
|
const WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS = 600;
|
|
23
23
|
const WAIT_FOR_MESSAGE_POLL_INTERVAL_MS = 1000;
|
|
24
24
|
const WAIT_FOR_MESSAGE_HEARTBEAT_INTERVAL_MS = 15000;
|
|
25
|
+
const MCP_AGENT_LEASE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const MCP_AGENT_RECENT_HEARTBEAT_MS = 30 * 1000;
|
|
25
27
|
|
|
26
28
|
function nowIso() {
|
|
27
29
|
return new Date().toISOString();
|
|
@@ -70,6 +72,61 @@ function createCryptoSessionId() {
|
|
|
70
72
|
return crypto.randomBytes(4).toString("hex");
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
function createAgentHandle() {
|
|
76
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hashAgentHandle(handle = "") {
|
|
80
|
+
return crypto.createHash("sha256").update(String(handle || ""), "utf8").digest("hex");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function leaseExpiryIso(nowMs = Date.now()) {
|
|
84
|
+
return new Date(nowMs + MCP_AGENT_LEASE_TTL_MS).toISOString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extendMcpAgentLease(meta, nowMs = Date.now()) {
|
|
88
|
+
meta.mcp_lease_expires_at = leaseExpiryIso(nowMs);
|
|
89
|
+
delete meta.mcp_revoked_at;
|
|
90
|
+
return meta.mcp_lease_expires_at;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assertAgentHandle(bus, subscriber, args = {}, options = {}) {
|
|
94
|
+
const meta = assertSubscriberExists(bus, subscriber);
|
|
95
|
+
if (meta.mcp_bridge !== true || !meta.mcp_agent_handle_hash) {
|
|
96
|
+
const err = new Error(`subscriber is not an MCP-registered Agent: ${subscriber}`);
|
|
97
|
+
err.code = "agent_handle_not_available";
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
const handle = String(args.agent_handle || args.agentHandle || "").trim();
|
|
101
|
+
if (!handle) {
|
|
102
|
+
const err = new Error("agent_handle is required");
|
|
103
|
+
err.code = "agent_handle_required";
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
const actual = Buffer.from(hashAgentHandle(handle), "hex");
|
|
107
|
+
const expected = Buffer.from(String(meta.mcp_agent_handle_hash || ""), "hex");
|
|
108
|
+
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
|
|
109
|
+
const err = new Error("agent_handle does not own this subscriber");
|
|
110
|
+
err.code = "invalid_agent_handle";
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
if (options.allowInactive !== true && meta.status !== "active") {
|
|
114
|
+
const err = new Error(`Agent registration is inactive: ${subscriber}`);
|
|
115
|
+
err.code = "agent_lease_inactive";
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
const expiresAtMs = Date.parse(String(meta.mcp_lease_expires_at || ""));
|
|
119
|
+
if (
|
|
120
|
+
options.allowExpired !== true
|
|
121
|
+
&& (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now())
|
|
122
|
+
) {
|
|
123
|
+
const err = new Error(`Agent lease expired: ${subscriber}`);
|
|
124
|
+
err.code = "agent_lease_expired";
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
return meta;
|
|
128
|
+
}
|
|
129
|
+
|
|
73
130
|
function notifyDaemonRefresh(projectRoot) {
|
|
74
131
|
if (!isRunning(projectRoot)) return;
|
|
75
132
|
const sock = socketPath(projectRoot);
|
|
@@ -99,6 +156,10 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
|
|
|
99
156
|
const hostCapabilities = args.hostCapabilities && typeof args.hostCapabilities === "object"
|
|
100
157
|
? args.hostCapabilities
|
|
101
158
|
: capabilities;
|
|
159
|
+
const clientInstanceId = String(
|
|
160
|
+
args.client_instance_id || args.clientInstanceId || ""
|
|
161
|
+
).trim();
|
|
162
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
102
163
|
|
|
103
164
|
// Session ID: explicit > reuse > generate
|
|
104
165
|
let sessionId;
|
|
@@ -113,7 +174,22 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
|
|
|
113
174
|
const reuseProviderSessionId = typeof reuseSession?.providerSessionId === "string"
|
|
114
175
|
? reuseSession.providerSessionId.trim() : "";
|
|
115
176
|
|
|
116
|
-
|
|
177
|
+
const recoveredEntry = !validateParentPid && clientInstanceId
|
|
178
|
+
? Object.entries(bus.busData.agents || {}).find(([, meta]) => (
|
|
179
|
+
meta
|
|
180
|
+
&& meta.mcp_bridge === true
|
|
181
|
+
&& meta.agent_type === agentType
|
|
182
|
+
&& meta.mcp_client_instance_id === clientInstanceId
|
|
183
|
+
))
|
|
184
|
+
: null;
|
|
185
|
+
const recoveredSubscriber = recoveredEntry ? recoveredEntry[0] : "";
|
|
186
|
+
const recoveredSessionId = recoveredSubscriber.startsWith(`${agentType}:`)
|
|
187
|
+
? recoveredSubscriber.slice(agentType.length + 1)
|
|
188
|
+
: "";
|
|
189
|
+
|
|
190
|
+
if (recoveredSessionId) {
|
|
191
|
+
sessionId = recoveredSessionId;
|
|
192
|
+
} else if (explicitSessionId) {
|
|
117
193
|
sessionId = explicitSessionId;
|
|
118
194
|
} else if (reuseSessionId && reuseSubscriberId === `${agentType}:${reuseSessionId}`) {
|
|
119
195
|
sessionId = reuseSessionId;
|
|
@@ -165,7 +241,18 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
|
|
|
165
241
|
if (reuseSessionId) joinOptions.reuseSessionId = reuseSessionId;
|
|
166
242
|
if (reuseProviderSessionId) joinOptions.reuseProviderSessionId = reuseProviderSessionId;
|
|
167
243
|
|
|
168
|
-
const
|
|
244
|
+
const candidateSubscriber = `${agentType}:${sessionId}`;
|
|
245
|
+
const existingCandidate = bus.subscriberManager.getSubscriber(candidateSubscriber);
|
|
246
|
+
if (
|
|
247
|
+
!validateParentPid
|
|
248
|
+
&& existingCandidate
|
|
249
|
+
&& existingCandidate.mcp_agent_handle_hash
|
|
250
|
+
&& (!clientInstanceId || existingCandidate.mcp_client_instance_id !== clientInstanceId)
|
|
251
|
+
) {
|
|
252
|
+
const err = new Error(`subscriber is already registered: ${candidateSubscriber}`);
|
|
253
|
+
err.code = "subscriber_already_registered";
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
169
256
|
const result = await bus.subscriberManager.join(sessionId, agentType, finalNickname, joinOptions);
|
|
170
257
|
const subscriber = result.subscriber;
|
|
171
258
|
if (finalNickname) {
|
|
@@ -175,6 +262,14 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
|
|
|
175
262
|
meta.activity_state = String(args.activity_state || "ready");
|
|
176
263
|
meta.activity_since = nowIso();
|
|
177
264
|
meta.mcp_bridge = !validateParentPid;
|
|
265
|
+
let agentHandle = "";
|
|
266
|
+
if (!validateParentPid) {
|
|
267
|
+
agentHandle = createAgentHandle();
|
|
268
|
+
meta.mcp_agent_handle_hash = hashAgentHandle(agentHandle);
|
|
269
|
+
meta.mcp_client_instance_id = clientInstanceId;
|
|
270
|
+
meta.mcp_registered_at = meta.mcp_registered_at || nowIso();
|
|
271
|
+
extendMcpAgentLease(meta);
|
|
272
|
+
}
|
|
178
273
|
if (hostCapabilities) meta.mcp_capabilities = hostCapabilities;
|
|
179
274
|
bus.saveBusData();
|
|
180
275
|
notifyDaemonRefresh(projectRoot);
|
|
@@ -188,6 +283,12 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
|
|
|
188
283
|
nickname: meta.nickname || result.nickname || finalNickname || "",
|
|
189
284
|
scoped_nickname: meta.scoped_nickname || result.scopedNickname || scopedNickname || "",
|
|
190
285
|
launch_mode: launchMode,
|
|
286
|
+
...(agentHandle ? {
|
|
287
|
+
agent_handle: agentHandle,
|
|
288
|
+
lease_expires_at: meta.mcp_lease_expires_at,
|
|
289
|
+
client_instance_id: clientInstanceId,
|
|
290
|
+
recovered: Boolean(recoveredSubscriber),
|
|
291
|
+
} : {}),
|
|
191
292
|
reuseProviderSessionId,
|
|
192
293
|
skipSessionResolve: !!args.skipSessionResolve,
|
|
193
294
|
};
|
|
@@ -203,9 +304,10 @@ async function registerAgent(projectRoot, args = {}) {
|
|
|
203
304
|
async function heartbeatAgent(projectRoot, args = {}) {
|
|
204
305
|
const subscriber = resolveSubscriberArg(args);
|
|
205
306
|
const bus = ensureBusLoaded(projectRoot);
|
|
206
|
-
const meta =
|
|
307
|
+
const meta = assertAgentHandle(bus, subscriber, args);
|
|
207
308
|
bus.subscriberManager.updateLastSeen(subscriber);
|
|
208
309
|
meta.status = "active";
|
|
310
|
+
const leaseExpiresAt = extendMcpAgentLease(meta);
|
|
209
311
|
bus.saveBusData();
|
|
210
312
|
notifyDaemonRefresh(projectRoot);
|
|
211
313
|
return {
|
|
@@ -213,6 +315,7 @@ async function heartbeatAgent(projectRoot, args = {}) {
|
|
|
213
315
|
project_root: projectRoot,
|
|
214
316
|
subscriber,
|
|
215
317
|
last_seen: meta.last_seen,
|
|
318
|
+
lease_expires_at: leaseExpiresAt,
|
|
216
319
|
};
|
|
217
320
|
}
|
|
218
321
|
|
|
@@ -225,7 +328,7 @@ async function publishActivityState(projectRoot, args = {}) {
|
|
|
225
328
|
throw err;
|
|
226
329
|
}
|
|
227
330
|
const bus = ensureBusLoaded(projectRoot);
|
|
228
|
-
const meta =
|
|
331
|
+
const meta = assertAgentHandle(bus, subscriber, args);
|
|
229
332
|
bus.subscriberManager.updateLastSeen(subscriber);
|
|
230
333
|
meta.status = "active";
|
|
231
334
|
meta.activity_state = activityState;
|
|
@@ -246,7 +349,7 @@ async function publishActivityState(projectRoot, args = {}) {
|
|
|
246
349
|
async function updateAgentMetadata(projectRoot, args = {}) {
|
|
247
350
|
const subscriber = resolveSubscriberArg(args);
|
|
248
351
|
const bus = ensureBusLoaded(projectRoot);
|
|
249
|
-
const meta =
|
|
352
|
+
const meta = assertAgentHandle(bus, subscriber, args);
|
|
250
353
|
const nickname = String(args.nickname || "").trim();
|
|
251
354
|
if (nickname) {
|
|
252
355
|
await bus.subscriberManager.rename(subscriber, nickname);
|
|
@@ -278,7 +381,7 @@ async function pollInbox(projectRoot, args = {}) {
|
|
|
278
381
|
? Math.floor(Number(args.limit))
|
|
279
382
|
: 50;
|
|
280
383
|
const bus = ensureBusLoaded(projectRoot);
|
|
281
|
-
|
|
384
|
+
assertAgentHandle(bus, subscriber, args);
|
|
282
385
|
bus.subscriberManager.updateLastSeen(subscriber);
|
|
283
386
|
bus.saveBusData();
|
|
284
387
|
const pending = await bus.messageManager.check(subscriber);
|
|
@@ -365,14 +468,15 @@ function eventSeq(event = {}) {
|
|
|
365
468
|
return Number.isInteger(seq) && seq > 0 ? seq : 0;
|
|
366
469
|
}
|
|
367
470
|
|
|
368
|
-
function touchWaitingSubscriber(bus, subscriber) {
|
|
471
|
+
function touchWaitingSubscriber(bus, subscriber, args = {}) {
|
|
369
472
|
// Long waits span concurrent metadata updates from other agents. Reload
|
|
370
473
|
// before writing heartbeat state so a stale in-memory registry cannot
|
|
371
474
|
// overwrite those updates.
|
|
372
475
|
bus.loadBusData();
|
|
373
|
-
const meta =
|
|
476
|
+
const meta = assertAgentHandle(bus, subscriber, args);
|
|
374
477
|
meta.status = "active";
|
|
375
478
|
bus.subscriberManager.updateLastSeen(subscriber);
|
|
479
|
+
extendMcpAgentLease(meta);
|
|
376
480
|
bus.saveBusData();
|
|
377
481
|
}
|
|
378
482
|
|
|
@@ -393,7 +497,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
|
|
|
393
497
|
const timeoutMs = timeoutSeconds * 1000;
|
|
394
498
|
|
|
395
499
|
const bus = ensureBusLoaded(projectRoot);
|
|
396
|
-
touchWaitingSubscriber(bus, subscriber);
|
|
500
|
+
touchWaitingSubscriber(bus, subscriber, args);
|
|
397
501
|
const lease = acquirePollLease(path.join(
|
|
398
502
|
bus.busDir,
|
|
399
503
|
"pids",
|
|
@@ -418,7 +522,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
|
|
|
418
522
|
if (unseen.length > 0) {
|
|
419
523
|
const messages = unseen.slice(0, limit);
|
|
420
524
|
const lastSeq = Math.max(afterSeq, ...messages.map(eventSeq));
|
|
421
|
-
touchWaitingSubscriber(bus, subscriber);
|
|
525
|
+
touchWaitingSubscriber(bus, subscriber, args);
|
|
422
526
|
return {
|
|
423
527
|
ok: true,
|
|
424
528
|
project_root: projectRoot,
|
|
@@ -436,7 +540,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
|
|
|
436
540
|
|
|
437
541
|
const current = now();
|
|
438
542
|
if (current >= deadline) {
|
|
439
|
-
touchWaitingSubscriber(bus, subscriber);
|
|
543
|
+
touchWaitingSubscriber(bus, subscriber, args);
|
|
440
544
|
return {
|
|
441
545
|
ok: true,
|
|
442
546
|
project_root: projectRoot,
|
|
@@ -453,7 +557,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
|
|
|
453
557
|
}
|
|
454
558
|
|
|
455
559
|
if (current >= nextHeartbeatAt) {
|
|
456
|
-
touchWaitingSubscriber(bus, subscriber);
|
|
560
|
+
touchWaitingSubscriber(bus, subscriber, args);
|
|
457
561
|
nextHeartbeatAt = current + heartbeatIntervalMs;
|
|
458
562
|
}
|
|
459
563
|
|
|
@@ -469,6 +573,8 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
|
|
|
469
573
|
|
|
470
574
|
async function reportAgentStatus(projectRoot, args = {}) {
|
|
471
575
|
const subscriber = resolveSubscriberArg(args);
|
|
576
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
577
|
+
assertAgentHandle(bus, subscriber, args);
|
|
472
578
|
const report = normalizeReportInput({
|
|
473
579
|
...args,
|
|
474
580
|
agent_id: subscriber,
|
|
@@ -488,6 +594,12 @@ async function reportAgentStatus(projectRoot, args = {}) {
|
|
|
488
594
|
async function unregisterAgent(projectRoot, args = {}) {
|
|
489
595
|
const subscriber = resolveSubscriberArg(args);
|
|
490
596
|
const bus = ensureBusLoaded(projectRoot);
|
|
597
|
+
const meta = assertAgentHandle(bus, subscriber, args, {
|
|
598
|
+
allowExpired: true,
|
|
599
|
+
allowInactive: true,
|
|
600
|
+
});
|
|
601
|
+
meta.mcp_revoked_at = nowIso();
|
|
602
|
+
meta.mcp_lease_expires_at = meta.mcp_revoked_at;
|
|
491
603
|
const ok = await bus.subscriberManager.leave(subscriber);
|
|
492
604
|
bus.saveBusData();
|
|
493
605
|
notifyDaemonRefresh(projectRoot);
|
|
@@ -504,6 +616,10 @@ module.exports = {
|
|
|
504
616
|
assertSubscriberExists,
|
|
505
617
|
resolveSubscriberArg,
|
|
506
618
|
createSessionId,
|
|
619
|
+
createAgentHandle,
|
|
620
|
+
hashAgentHandle,
|
|
621
|
+
assertAgentHandle,
|
|
622
|
+
extendMcpAgentLease,
|
|
507
623
|
notifyDaemonRefresh,
|
|
508
624
|
registerAgentFull,
|
|
509
625
|
registerAgent,
|
|
@@ -517,4 +633,6 @@ module.exports = {
|
|
|
517
633
|
normalizeWaitForMessageArgs,
|
|
518
634
|
WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS,
|
|
519
635
|
WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS,
|
|
636
|
+
MCP_AGENT_LEASE_TTL_MS,
|
|
637
|
+
MCP_AGENT_RECENT_HEARTBEAT_MS,
|
|
520
638
|
};
|
|
@@ -45,6 +45,10 @@ const {
|
|
|
45
45
|
checkAndCleanupNickname,
|
|
46
46
|
} = require("./nicknameScope");
|
|
47
47
|
const { resolveNodeExecutable } = require("../process/nodeExecutable");
|
|
48
|
+
const {
|
|
49
|
+
createProjectRuntimeControlPlane,
|
|
50
|
+
} = require("./projectRuntimeControlPlane");
|
|
51
|
+
const { loadConfig, normalizeMcpPort } = require("../../config");
|
|
48
52
|
|
|
49
53
|
let providerSessions = null;
|
|
50
54
|
let sessionResolveHandles = new Map();
|
|
@@ -1351,9 +1355,63 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
1351
1355
|
},
|
|
1352
1356
|
});
|
|
1353
1357
|
deliveryScheduler.start();
|
|
1358
|
+
const runtimeControlPlane = createProjectRuntimeControlPlane({ projectRoot });
|
|
1359
|
+
let mcpHttpServer = null;
|
|
1360
|
+
if (isGlobalControllerProjectRoot(projectRoot) && process.env.UFOO_MCP_HTTP_DISABLED !== "1") {
|
|
1361
|
+
const { createGlobalMcpHttpServer } = require("./mcpHttpServer");
|
|
1362
|
+
const config = loadConfig(projectRoot);
|
|
1363
|
+
const configuredPort = process.env.UFOO_MCP_PORT || config.mcpPort;
|
|
1364
|
+
mcpHttpServer = createGlobalMcpHttpServer({
|
|
1365
|
+
projectRoot,
|
|
1366
|
+
port: normalizeMcpPort(configuredPort),
|
|
1367
|
+
log,
|
|
1368
|
+
});
|
|
1369
|
+
mcpHttpServer.start().catch((err) => {
|
|
1370
|
+
logSync(`MCP HTTP startup failed: ${formatFatalReason(err)}`);
|
|
1371
|
+
setImmediate(() => {
|
|
1372
|
+
throw err;
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1354
1376
|
|
|
1355
1377
|
handleIpcRequest = async (req, socket) => {
|
|
1356
1378
|
if (!req || typeof req !== "object") return;
|
|
1379
|
+
if (await runtimeControlPlane.handleRequest(req, socket)) return;
|
|
1380
|
+
if (req.type === IPC_REQUEST_TYPES.MCP_STATUS || req.type === IPC_REQUEST_TYPES.MCP_RESTART) {
|
|
1381
|
+
if (!isGlobalControllerProjectRoot(projectRoot)) {
|
|
1382
|
+
socket.write(`${JSON.stringify({
|
|
1383
|
+
type: IPC_RESPONSE_TYPES.ERROR,
|
|
1384
|
+
error: "MCP control is owned by the global controller daemon",
|
|
1385
|
+
})}\n`);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
if (req.type === IPC_REQUEST_TYPES.MCP_RESTART) {
|
|
1390
|
+
if (!mcpHttpServer) {
|
|
1391
|
+
throw new Error("MCP HTTP listener is disabled");
|
|
1392
|
+
}
|
|
1393
|
+
await mcpHttpServer.stop();
|
|
1394
|
+
await mcpHttpServer.start();
|
|
1395
|
+
}
|
|
1396
|
+
socket.write(`${JSON.stringify({
|
|
1397
|
+
type: IPC_RESPONSE_TYPES.RESPONSE,
|
|
1398
|
+
data: {
|
|
1399
|
+
ok: true,
|
|
1400
|
+
operation: req.type === IPC_REQUEST_TYPES.MCP_RESTART ? "restart" : "status",
|
|
1401
|
+
mcp: mcpHttpServer
|
|
1402
|
+
? mcpHttpServer.getStatus()
|
|
1403
|
+
: { running: false, disabled: true },
|
|
1404
|
+
},
|
|
1405
|
+
})}\n`);
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
socket.write(`${JSON.stringify({
|
|
1408
|
+
type: IPC_RESPONSE_TYPES.ERROR,
|
|
1409
|
+
error: err.message || String(err),
|
|
1410
|
+
code: err.code || "mcp_control_error",
|
|
1411
|
+
})}\n`);
|
|
1412
|
+
}
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1357
1415
|
if (req.type === IPC_REQUEST_TYPES.STATUS) {
|
|
1358
1416
|
cleanupInactiveSubscribers();
|
|
1359
1417
|
const status = buildRuntimeStatus();
|
|
@@ -2595,6 +2653,13 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
2595
2653
|
}
|
|
2596
2654
|
daemonGroupOrchestrator = null;
|
|
2597
2655
|
|
|
2656
|
+
runtimeControlPlane.stop();
|
|
2657
|
+
if (mcpHttpServer) {
|
|
2658
|
+
void mcpHttpServer.stop().catch((err) => {
|
|
2659
|
+
writeLog(`MCP HTTP shutdown failed: ${formatFatalReason(err)}`);
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2598
2663
|
// 清理所有子进程
|
|
2599
2664
|
processManager.cleanup();
|
|
2600
2665
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
readConnectionFiles,
|
|
9
|
+
} = require("./mcpStdioProxy");
|
|
10
|
+
const {
|
|
11
|
+
resolveGlobalControllerProjectRoot,
|
|
12
|
+
} = require("../projects");
|
|
13
|
+
|
|
14
|
+
const MANAGED_BLOCK_START = "# >>> ufoo MCP (managed)";
|
|
15
|
+
const MANAGED_BLOCK_END = "# <<< ufoo MCP (managed)";
|
|
16
|
+
|
|
17
|
+
function tomlString(value = "") {
|
|
18
|
+
return JSON.stringify(String(value || ""));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function codexConfigPath(options = {}) {
|
|
22
|
+
if (options.configPath) return options.configPath;
|
|
23
|
+
const codexHome = String(options.codexHome || process.env.CODEX_HOME || "").trim();
|
|
24
|
+
return path.join(codexHome || path.join(os.homedir(), ".codex"), "config.toml");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildCodexManagedBlock(connection) {
|
|
28
|
+
return [
|
|
29
|
+
MANAGED_BLOCK_START,
|
|
30
|
+
"[mcp_servers.ufoo]",
|
|
31
|
+
`url = ${tomlString(connection.endpoint)}`,
|
|
32
|
+
`http_headers = { Authorization = ${tomlString(`Bearer ${connection.token}`)} }`,
|
|
33
|
+
"tool_timeout_sec = 610",
|
|
34
|
+
"enabled = true",
|
|
35
|
+
MANAGED_BLOCK_END,
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function removeManagedBlock(text = "") {
|
|
40
|
+
const escapedStart = MANAGED_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
|
+
const escapedEnd = MANAGED_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
return String(text || "").replace(
|
|
43
|
+
new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}(?=\\n|$)`, "g"),
|
|
44
|
+
""
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findTomlSections(text = "") {
|
|
49
|
+
const sections = [];
|
|
50
|
+
const pattern = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/gm;
|
|
51
|
+
let match;
|
|
52
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
53
|
+
sections.push({
|
|
54
|
+
header: match[1].trim(),
|
|
55
|
+
start: match.index,
|
|
56
|
+
contentStart: pattern.lastIndex,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return sections.map((section, index) => ({
|
|
60
|
+
...section,
|
|
61
|
+
end: index + 1 < sections.length ? sections[index + 1].start : text.length,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isUfooMainSection(header = "") {
|
|
66
|
+
return /^(?:mcp_servers\.ufoo|mcp_servers\."ufoo")$/.test(String(header || ""));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isUfooStdioEnvSection(header = "") {
|
|
70
|
+
return /^(?:mcp_servers\.ufoo|mcp_servers\."ufoo")\.env$/.test(String(header || ""));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function removeLegacyUfooTransportSections(text = "") {
|
|
74
|
+
const sections = findTomlSections(text);
|
|
75
|
+
const ranges = sections
|
|
76
|
+
.filter((section) => isUfooMainSection(section.header) || isUfooStdioEnvSection(section.header))
|
|
77
|
+
.map((section) => [section.start, section.end])
|
|
78
|
+
.sort((a, b) => b[0] - a[0]);
|
|
79
|
+
let next = text;
|
|
80
|
+
for (const [start, end] of ranges) {
|
|
81
|
+
next = `${next.slice(0, start)}${next.slice(end)}`;
|
|
82
|
+
}
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderCodexConfig(existing, connection) {
|
|
87
|
+
const withoutManaged = removeManagedBlock(existing);
|
|
88
|
+
const withoutLegacy = removeLegacyUfooTransportSections(withoutManaged);
|
|
89
|
+
const trimmed = withoutLegacy.trimEnd();
|
|
90
|
+
return `${trimmed ? `${trimmed}\n\n` : ""}${buildCodexManagedBlock(connection)}\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function configureCodexMcp(options = {}) {
|
|
94
|
+
const projectRoot = options.projectRoot || resolveGlobalControllerProjectRoot();
|
|
95
|
+
const connection = options.connection || readConnectionFiles(projectRoot);
|
|
96
|
+
const target = codexConfigPath(options);
|
|
97
|
+
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
|
98
|
+
const next = renderCodexConfig(existing, connection);
|
|
99
|
+
if (options.dryRun === true) {
|
|
100
|
+
const redacted = renderCodexConfig(existing, {
|
|
101
|
+
...connection,
|
|
102
|
+
token: "<redacted>",
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
dry_run: true,
|
|
107
|
+
target,
|
|
108
|
+
transport: "streamable_http",
|
|
109
|
+
endpoint: connection.endpoint,
|
|
110
|
+
changed: next !== existing,
|
|
111
|
+
content: redacted,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
116
|
+
let backup = "";
|
|
117
|
+
if (fs.existsSync(target) && next !== existing) {
|
|
118
|
+
backup = `${target}.ufoo-backup-${Date.now()}`;
|
|
119
|
+
fs.copyFileSync(target, backup);
|
|
120
|
+
try {
|
|
121
|
+
fs.chmodSync(backup, 0o600);
|
|
122
|
+
} catch {
|
|
123
|
+
// Best effort for filesystems without POSIX modes.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
fs.writeFileSync(target, next, { encoding: "utf8", mode: 0o600 });
|
|
127
|
+
try {
|
|
128
|
+
fs.chmodSync(target, 0o600);
|
|
129
|
+
} catch {
|
|
130
|
+
// Best effort for filesystems without POSIX modes.
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
dry_run: false,
|
|
135
|
+
target,
|
|
136
|
+
backup: backup || null,
|
|
137
|
+
transport: "streamable_http",
|
|
138
|
+
endpoint: connection.endpoint,
|
|
139
|
+
changed: next !== existing,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function runMcpConfigureCli(host, options = {}) {
|
|
144
|
+
const normalized = String(host || "").trim().toLowerCase();
|
|
145
|
+
if (normalized !== "codex") {
|
|
146
|
+
const err = new Error(
|
|
147
|
+
`Direct HTTP auto-configuration is verified only for Codex App/CLI/IDE; keep ${normalized || "this host"} on the stateless "ufoo mcp" stdio proxy`
|
|
148
|
+
);
|
|
149
|
+
err.code = "unsupported_mcp_host_config";
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
const result = configureCodexMcp(options);
|
|
153
|
+
if (options.dryRun === true) {
|
|
154
|
+
process.stdout.write(result.content);
|
|
155
|
+
} else {
|
|
156
|
+
process.stdout.write(`Configured Codex MCP at ${result.target}\n`);
|
|
157
|
+
process.stdout.write(`Transport: Streamable HTTP ${result.endpoint}\n`);
|
|
158
|
+
if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
|
|
159
|
+
process.stdout.write("Restart Codex App/CLI/IDE to load the shared MCP configuration.\n");
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
MANAGED_BLOCK_END,
|
|
166
|
+
MANAGED_BLOCK_START,
|
|
167
|
+
buildCodexManagedBlock,
|
|
168
|
+
codexConfigPath,
|
|
169
|
+
configureCodexMcp,
|
|
170
|
+
findTomlSections,
|
|
171
|
+
removeLegacyUfooTransportSections,
|
|
172
|
+
removeManagedBlock,
|
|
173
|
+
renderCodexConfig,
|
|
174
|
+
runMcpConfigureCli,
|
|
175
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const net = require("net");
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
IPC_REQUEST_TYPES,
|
|
7
|
+
IPC_RESPONSE_TYPES,
|
|
8
|
+
} = require("../contracts/eventContract");
|
|
9
|
+
const {
|
|
10
|
+
isRunning,
|
|
11
|
+
socketPath,
|
|
12
|
+
} = require("./index");
|
|
13
|
+
const {
|
|
14
|
+
resolveGlobalControllerProjectRoot,
|
|
15
|
+
} = require("../projects");
|
|
16
|
+
|
|
17
|
+
function requestMcpControl(operation, options = {}) {
|
|
18
|
+
const projectRoot = options.projectRoot || resolveGlobalControllerProjectRoot();
|
|
19
|
+
const checkRunning = options.isRunning || isRunning;
|
|
20
|
+
const resolveSocketPath = options.socketPath || socketPath;
|
|
21
|
+
const connect = options.connect || ((target) => net.createConnection(target));
|
|
22
|
+
const requestType = operation === "restart"
|
|
23
|
+
? IPC_REQUEST_TYPES.MCP_RESTART
|
|
24
|
+
: IPC_REQUEST_TYPES.MCP_STATUS;
|
|
25
|
+
if (!checkRunning(projectRoot)) {
|
|
26
|
+
const err = new Error("Global controller daemon is not running");
|
|
27
|
+
err.code = "global_daemon_not_running";
|
|
28
|
+
return Promise.reject(err);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const client = connect(resolveSocketPath(projectRoot));
|
|
33
|
+
let buffer = "";
|
|
34
|
+
let settled = false;
|
|
35
|
+
const timeoutMs = Number(options.timeoutMs) || 10000;
|
|
36
|
+
let timer = null;
|
|
37
|
+
|
|
38
|
+
const cleanup = () => {
|
|
39
|
+
if (timer) clearTimeout(timer);
|
|
40
|
+
timer = null;
|
|
41
|
+
client.removeAllListeners();
|
|
42
|
+
try {
|
|
43
|
+
client.end();
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const finishResolve = (value) => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
cleanup();
|
|
52
|
+
resolve(value);
|
|
53
|
+
};
|
|
54
|
+
const finishReject = (err) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
cleanup();
|
|
58
|
+
reject(err);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
timer = setTimeout(() => {
|
|
62
|
+
finishReject(Object.assign(new Error("MCP control request timed out"), {
|
|
63
|
+
code: "mcp_control_timeout",
|
|
64
|
+
}));
|
|
65
|
+
}, timeoutMs);
|
|
66
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
67
|
+
|
|
68
|
+
client.on("connect", () => {
|
|
69
|
+
client.write(`${JSON.stringify({ type: requestType })}\n`);
|
|
70
|
+
});
|
|
71
|
+
client.on("data", (chunk) => {
|
|
72
|
+
buffer += chunk.toString("utf8");
|
|
73
|
+
const lines = buffer.split(/\r?\n/);
|
|
74
|
+
buffer = lines.pop() || "";
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
if (!line.trim()) continue;
|
|
77
|
+
let response;
|
|
78
|
+
try {
|
|
79
|
+
response = JSON.parse(line);
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (response.type === IPC_RESPONSE_TYPES.ERROR) {
|
|
84
|
+
const err = new Error(response.error || "MCP control failed");
|
|
85
|
+
err.code = response.code || "mcp_control_error";
|
|
86
|
+
finishReject(err);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (response.type === IPC_RESPONSE_TYPES.RESPONSE && response.data?.mcp) {
|
|
90
|
+
finishResolve(response.data);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
client.once("error", finishReject);
|
|
96
|
+
client.once("close", () => {
|
|
97
|
+
finishReject(Object.assign(new Error("Global controller closed the MCP control request"), {
|
|
98
|
+
code: "mcp_control_closed",
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runMcpControlCli(operation, options = {}) {
|
|
105
|
+
const result = await requestMcpControl(operation, options);
|
|
106
|
+
if (options.json === true) {
|
|
107
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
const status = result.mcp || {};
|
|
111
|
+
process.stdout.write(`MCP ${status.running ? "running" : "stopped"}\n`);
|
|
112
|
+
if (status.endpoint) process.stdout.write(`Endpoint: ${status.endpoint}\n`);
|
|
113
|
+
if (status.pid) process.stdout.write(`PID: ${status.pid}\n`);
|
|
114
|
+
process.stdout.write(`Sessions: ${status.session_count || 0}\n`);
|
|
115
|
+
process.stdout.write(`Active requests: ${status.active_request_count || 0}\n`);
|
|
116
|
+
process.stdout.write(`Active waits: ${status.active_wait_count || 0}\n`);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
requestMcpControl,
|
|
122
|
+
runMcpControlCli,
|
|
123
|
+
};
|