@myagentroam/node 0.13.14 → 0.13.16
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/application/execution-lifecycle.js +4 -10
- package/dist/connector.js +5 -6
- package/dist/marctl/package.json +1 -1
- package/dist/marctl/src/config.mjs +1 -7
- package/dist/marctl/src/i18n/en-US.mjs +1 -1
- package/dist/marctl/src/i18n/zh-CN.mjs +1 -1
- package/dist/mcp/capability-token.js +38 -0
- package/dist/remote-desktop/windows-remote-desktop-runtime.js +8 -2
- package/dist/runtime-state.js +24 -0
- package/dist/service/goal-mcp-bridge.js +21 -20
- package/dist/service/node-upgrade-reporter.js +1 -5
- package/dist/service/notification-mcp-bridge.js +15 -20
- package/dist/service/schedule-mcp-bridge.js +33 -24
- package/dist/service/schedule-mcp-stdio.js +26 -9
- package/package.json +4 -4
|
@@ -5,20 +5,16 @@ export class ExecutionLifecycle {
|
|
|
5
5
|
continuous;
|
|
6
6
|
goal;
|
|
7
7
|
usage;
|
|
8
|
-
|
|
9
|
-
scheduleMcp;
|
|
10
|
-
notificationMcp;
|
|
8
|
+
runtime;
|
|
11
9
|
events;
|
|
12
10
|
upgrade;
|
|
13
|
-
constructor(databaseAvailable, scheduled, continuous, goal, usage,
|
|
11
|
+
constructor(databaseAvailable, scheduled, continuous, goal, usage, runtime, events, upgrade) {
|
|
14
12
|
this.databaseAvailable = databaseAvailable;
|
|
15
13
|
this.scheduled = scheduled;
|
|
16
14
|
this.continuous = continuous;
|
|
17
15
|
this.goal = goal;
|
|
18
16
|
this.usage = usage;
|
|
19
|
-
this.
|
|
20
|
-
this.scheduleMcp = scheduleMcp;
|
|
21
|
-
this.notificationMcp = notificationMcp;
|
|
17
|
+
this.runtime = runtime;
|
|
22
18
|
this.events = events;
|
|
23
19
|
this.upgrade = upgrade;
|
|
24
20
|
}
|
|
@@ -28,9 +24,7 @@ export class ExecutionLifecycle {
|
|
|
28
24
|
this.continuous.runTerminated(run.sessionId);
|
|
29
25
|
}
|
|
30
26
|
revokeMcps(executionId) {
|
|
31
|
-
this.
|
|
32
|
-
this.scheduleMcp.revoke(executionId);
|
|
33
|
-
this.notificationMcp.revoke(executionId);
|
|
27
|
+
this.runtime.revokeExecutionMcps(executionId);
|
|
34
28
|
}
|
|
35
29
|
release(executionId) {
|
|
36
30
|
this.usage.release(executionId);
|
package/dist/connector.js
CHANGED
|
@@ -296,7 +296,7 @@ export class NodeConnector {
|
|
|
296
296
|
commandStates = new CommandStateStore();
|
|
297
297
|
goalRuntime = new GoalRuntime(() => undefined);
|
|
298
298
|
sessionScheduleService;
|
|
299
|
-
goalMcpBridge = new GoalMcpBridge(this.goalRuntime, (sessionId) => this.goalCoordinator.changed(sessionId));
|
|
299
|
+
goalMcpBridge = new GoalMcpBridge(this.goalRuntime, (sessionId) => this.goalCoordinator.changed(sessionId), (capability) => this.runtime.resolveMcpExecution(capability)?.sessionId);
|
|
300
300
|
scheduleMcpBridge = new ScheduleMcpBridge(() => {
|
|
301
301
|
if (this.database === undefined)
|
|
302
302
|
throw new Error('NODE_DATABASE_UNAVAILABLE');
|
|
@@ -308,11 +308,12 @@ export class NodeConnector {
|
|
|
308
308
|
session: this.sessionPresentationService.present(session)
|
|
309
309
|
});
|
|
310
310
|
this.scheduledSessions.changed();
|
|
311
|
-
}, (sessionId, proposalId) => this.scheduledSessions.proposalCreated(sessionId, proposalId), (
|
|
311
|
+
}, (sessionId, proposalId) => this.scheduledSessions.proposalCreated(sessionId, proposalId), (capability) => this.runtime.resolveMcpExecution({ ...capability, userId: capability.actorUserId })
|
|
312
|
+
?.sessionId, (input) => {
|
|
312
313
|
if (this.sessionScheduleService === undefined)
|
|
313
314
|
throw new Error('SCHEDULE_SERVICE_UNAVAILABLE');
|
|
314
315
|
return this.sessionScheduleService.createFromMcp(input);
|
|
315
|
-
}
|
|
316
|
+
});
|
|
316
317
|
notificationMcpBridge = new NotificationMcpBridge((capability) => resolveNotificationCapability(this.runtime, capability), (request) => this.mailNotificationControl.request(request), {
|
|
317
318
|
supported: () => this.mailNotificationControl.supportsChannels(),
|
|
318
319
|
send: (request) => this.mailNotificationControl.notification(request)
|
|
@@ -889,7 +890,6 @@ export class NodeConnector {
|
|
|
889
890
|
migrateAttachments: (previousId, nextId) => this.runAttachmentService.migrateSession(previousId, nextId),
|
|
890
891
|
migrateGoal: (previousId, nextId) => {
|
|
891
892
|
this.goalCoordinator.migrateSession(previousId, nextId);
|
|
892
|
-
this.goalMcpBridge.migrateSession(previousId, nextId);
|
|
893
893
|
},
|
|
894
894
|
migrateQueue: (previousId, nextId) => this.continuousRunCoordinator.migrateSession(previousId, nextId),
|
|
895
895
|
migrateRunnerState: (previousId, nextId) => {
|
|
@@ -1272,7 +1272,6 @@ export class NodeConnector {
|
|
|
1272
1272
|
this.mailNotificationControl.disconnected();
|
|
1273
1273
|
this.marctlExecutionCredentials.disconnected();
|
|
1274
1274
|
this.upgradeCredentials.disconnect();
|
|
1275
|
-
this.notificationMcpBridge.revokeAll();
|
|
1276
1275
|
this.terminalChannel.disconnect();
|
|
1277
1276
|
if (code === 4000 && reason === 'NODE_CONNECTION_REPLACED')
|
|
1278
1277
|
nodeLog('node.connection.replaced', {
|
|
@@ -1488,7 +1487,7 @@ export class NodeConnector {
|
|
|
1488
1487
|
{}),
|
|
1489
1488
|
emitNotification: (payload) => this.emitWorkbenchEvent('conversation', payload)
|
|
1490
1489
|
});
|
|
1491
|
-
this.executionLifecycle = new ExecutionLifecycle(() => this.database !== undefined, this.scheduledSessions, this.continuousRunCoordinator, this.goalCoordinator, this.modelUsage, this.
|
|
1490
|
+
this.executionLifecycle = new ExecutionLifecycle(() => this.database !== undefined, this.scheduledSessions, this.continuousRunCoordinator, this.goalCoordinator, this.modelUsage, this.runtime, this.runEventBridge, this.upgradeCoordinator);
|
|
1492
1491
|
// Register public operations only after domain workflows are connected.
|
|
1493
1492
|
this.runnerService = new RunnerService(requireDatabase, () => this.capabilities, this.runners, this.runnerUsageReader);
|
|
1494
1493
|
this.workbenchManifestService = new WorkbenchManifestService({
|
package/dist/marctl/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"type":"module","version":"0.13.
|
|
1
|
+
{"type":"module","version":"0.13.16"}
|
|
@@ -27,13 +27,7 @@ export function validateServerUrl(value, translate = createTranslator()) {
|
|
|
27
27
|
throw new MarctlError(translate('errors.serverUrlInvalid'));
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
url.protocol === 'http:' &&
|
|
32
|
-
(url.hostname === 'localhost' ||
|
|
33
|
-
url.hostname === '127.0.0.1' ||
|
|
34
|
-
url.hostname === '::1' ||
|
|
35
|
-
url.hostname === '[::1]');
|
|
36
|
-
if (url.protocol !== 'https:' && !isLoopbackHttp)
|
|
30
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
37
31
|
throw new MarctlError(translate('errors.serverUrlTransport'));
|
|
38
32
|
if (url.username || url.password || url.search || url.hash || url.pathname !== '/')
|
|
39
33
|
throw new MarctlError(translate('errors.serverUrlOrigin'));
|
|
@@ -234,7 +234,7 @@ No configuration, authorization, or Server connection is required.`
|
|
|
234
234
|
userContextUnavailable: 'This Node session has no valid marctl user authorization.',
|
|
235
235
|
sessionContextRequired: 'No current Session is available; specify a Session ID explicitly.',
|
|
236
236
|
serverUrlInvalid: 'MAR_CTL_SERVER_URL is invalid.',
|
|
237
|
-
serverUrlTransport: 'MAR_CTL_SERVER_URL must use
|
|
237
|
+
serverUrlTransport: 'MAR_CTL_SERVER_URL must use HTTP or HTTPS.',
|
|
238
238
|
serverUrlOrigin: 'MAR_CTL_SERVER_URL must be an origin without credentials or query data.',
|
|
239
239
|
fetchUnavailable: 'Fetch is unavailable in this Node.js runtime.',
|
|
240
240
|
serverInvalidNodes: 'The server returned an invalid node list.',
|
|
@@ -220,7 +220,7 @@ ${configurationHelp}`,
|
|
|
220
220
|
userContextUnavailable: '此 Node 会话没有有效的 marctl 用户授权。',
|
|
221
221
|
sessionContextRequired: '当前上下文没有会话,请显式指定会话 ID。',
|
|
222
222
|
serverUrlInvalid: 'MAR_CTL_SERVER_URL 无效。',
|
|
223
|
-
serverUrlTransport: 'MAR_CTL_SERVER_URL 必须使用
|
|
223
|
+
serverUrlTransport: 'MAR_CTL_SERVER_URL 必须使用 HTTP 或 HTTPS。',
|
|
224
224
|
serverUrlOrigin: 'MAR_CTL_SERVER_URL 必须是不含凭据或查询参数的 Origin。',
|
|
225
225
|
fetchUnavailable: '当前 Node.js 运行时没有 Fetch。',
|
|
226
226
|
serverInvalidNodes: 'Server 返回的节点列表无效。',
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** Signs process-local MCP claims; authorization and revocation belong to the Execution owner. */
|
|
3
|
+
export class McpCapabilityToken {
|
|
4
|
+
purpose;
|
|
5
|
+
schema;
|
|
6
|
+
key;
|
|
7
|
+
constructor(purpose, schema, key = randomBytes(32)) {
|
|
8
|
+
this.purpose = purpose;
|
|
9
|
+
this.schema = schema;
|
|
10
|
+
this.key = key;
|
|
11
|
+
}
|
|
12
|
+
sign(claims) {
|
|
13
|
+
const body = `v1.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
|
|
14
|
+
return `${body}.${this.signature(body).toString('base64url')}`;
|
|
15
|
+
}
|
|
16
|
+
verify(token) {
|
|
17
|
+
if (typeof token !== 'string' || token.length > 8192)
|
|
18
|
+
return undefined;
|
|
19
|
+
const match = /^(v1\.[A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/u.exec(token);
|
|
20
|
+
if (match === null)
|
|
21
|
+
return undefined;
|
|
22
|
+
const expected = this.signature(match[1]);
|
|
23
|
+
const received = Buffer.from(match[2], 'base64url');
|
|
24
|
+
if (expected.length !== received.length || !timingSafeEqual(expected, received))
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(Buffer.from(match[1].slice(3), 'base64url').toString('utf8'));
|
|
28
|
+
const parsed = this.schema.safeParse(value);
|
|
29
|
+
return parsed.success ? parsed.data : undefined;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
signature(body) {
|
|
36
|
+
return createHmac('sha256', this.key).update(`${this.purpose}\0${body}`).digest();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -78,7 +78,7 @@ async function queryScreens(signal) {
|
|
|
78
78
|
});
|
|
79
79
|
return JSON.parse(stdout);
|
|
80
80
|
}
|
|
81
|
-
/** Owns
|
|
81
|
+
/** Owns serialized Windows GUI layout discovery and one shared UltraVNC backend. */
|
|
82
82
|
export class WindowsRemoteDesktopRuntime {
|
|
83
83
|
options;
|
|
84
84
|
#pool;
|
|
@@ -87,6 +87,7 @@ export class WindowsRemoteDesktopRuntime {
|
|
|
87
87
|
#signature = '';
|
|
88
88
|
#revision = 0;
|
|
89
89
|
#disposed = false;
|
|
90
|
+
#refreshTail = Promise.resolve();
|
|
90
91
|
constructor(options) {
|
|
91
92
|
this.options = options;
|
|
92
93
|
this.#pool = new TigerVncBackendPool((signal, onUnexpectedExit) => UltraVncManagedProcess.start({ binaryPath: options.binaryPath, onUnexpectedExit }, signal), () => options.fence('DESKTOP_BACKEND_CLOSED'));
|
|
@@ -96,7 +97,12 @@ export class WindowsRemoteDesktopRuntime {
|
|
|
96
97
|
await runtime.refresh(signal);
|
|
97
98
|
return runtime;
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
+
refresh(signal) {
|
|
101
|
+
const result = this.#refreshTail.then(() => this.#refresh(signal));
|
|
102
|
+
this.#refreshTail = result.catch(() => { });
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
async #refresh(signal) {
|
|
100
106
|
const screens = (await queryScreens(signal)).sort((left, right) => left.name.localeCompare(right.name));
|
|
101
107
|
const signature = JSON.stringify(screens.map((screen) => [
|
|
102
108
|
screen.name,
|
package/dist/runtime-state.js
CHANGED
|
@@ -711,6 +711,30 @@ export class NodeRuntimeState {
|
|
|
711
711
|
const canonical = this.executions.get(id)?.runId ?? id;
|
|
712
712
|
return this.runs.get(canonical);
|
|
713
713
|
}
|
|
714
|
+
/** Resolves signed MCP scope against live execution ownership, never a token registry. */
|
|
715
|
+
resolveMcpExecution(scope) {
|
|
716
|
+
const execution = this.executions.get(scope.executionId);
|
|
717
|
+
const run = execution === undefined ? undefined : this.runs.get(execution.runId);
|
|
718
|
+
const session = this.getAgentSession(scope.sessionId);
|
|
719
|
+
if (execution === undefined ||
|
|
720
|
+
execution.mcpRevoked === true ||
|
|
721
|
+
terminalExecution(execution.status) ||
|
|
722
|
+
this.isExecutionInterruptRequested(execution.id) ||
|
|
723
|
+
run === undefined ||
|
|
724
|
+
(run.status !== 'STARTING' && run.status !== 'RUNNING') ||
|
|
725
|
+
this.currentExecutionId(run.id) !== execution.id ||
|
|
726
|
+
session === undefined ||
|
|
727
|
+
session.id !== run.sessionId ||
|
|
728
|
+
(scope.workspaceId !== undefined && scope.workspaceId !== run.workspaceId) ||
|
|
729
|
+
(scope.userId !== undefined && scope.userId !== run.initiatedByUserId))
|
|
730
|
+
return undefined;
|
|
731
|
+
return run;
|
|
732
|
+
}
|
|
733
|
+
revokeExecutionMcps(executionId) {
|
|
734
|
+
const execution = this.executions.get(executionId);
|
|
735
|
+
if (execution !== undefined)
|
|
736
|
+
this.executions.set(executionId, { ...execution, mcpRevoked: true });
|
|
737
|
+
}
|
|
714
738
|
/** Only the current execution may replace an active Run's ephemeral metrics. */
|
|
715
739
|
updateRunPerformance(id, performance) {
|
|
716
740
|
const run = this.getRun(id);
|
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
import { NODE_MCP } from '../mcp/naming.js';
|
|
2
|
-
import {
|
|
2
|
+
import { McpCapabilityToken } from '../mcp/capability-token.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
3
5
|
import { existsSync } from 'node:fs';
|
|
4
6
|
import { createServer } from 'node:net';
|
|
5
7
|
import { tmpdir } from 'node:os';
|
|
6
8
|
import { join } from 'node:path';
|
|
7
9
|
import { unlink } from 'node:fs/promises';
|
|
8
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
+
const capabilitySchema = z.object({
|
|
12
|
+
executionId: z.string(),
|
|
13
|
+
sessionId: z.string(),
|
|
14
|
+
goalId: z.string(),
|
|
15
|
+
revision: z.number().int().nonnegative()
|
|
16
|
+
});
|
|
17
|
+
/** Owns Goal IPC and revision validation, not Execution authorization or Session aliases. */
|
|
9
18
|
export class GoalMcpBridge {
|
|
10
19
|
goals;
|
|
11
20
|
changed;
|
|
12
|
-
|
|
21
|
+
resolveExecution;
|
|
22
|
+
tokens = new McpCapabilityToken(NODE_MCP.goal.name, capabilitySchema);
|
|
13
23
|
sockets = new Set();
|
|
14
24
|
endpoint = process.platform === 'win32'
|
|
15
25
|
? `\\\\.\\pipe\\mar-goal-${process.pid}-${randomUUID()}`
|
|
16
26
|
: join(tmpdir(), `mar-goal-${process.pid}-${randomUUID()}.sock`);
|
|
17
27
|
listening;
|
|
18
|
-
constructor(goals, changed) {
|
|
28
|
+
constructor(goals, changed, resolveExecution) {
|
|
19
29
|
this.goals = goals;
|
|
20
30
|
this.changed = changed;
|
|
31
|
+
this.resolveExecution = resolveExecution;
|
|
21
32
|
}
|
|
22
33
|
listen() {
|
|
23
34
|
this.listening ??= new Promise((resolve, reject) => {
|
|
@@ -30,8 +41,7 @@ export class GoalMcpBridge {
|
|
|
30
41
|
async prepare(capability) {
|
|
31
42
|
await this.listen();
|
|
32
43
|
const args = goalMcpStdioArgs();
|
|
33
|
-
const token =
|
|
34
|
-
this.capabilities.set(token, capability);
|
|
44
|
+
const token = this.tokens.sign(capability);
|
|
35
45
|
return {
|
|
36
46
|
name: NODE_MCP.goal.name,
|
|
37
47
|
origin: 'BUILTIN',
|
|
@@ -53,7 +63,6 @@ export class GoalMcpBridge {
|
|
|
53
63
|
if (this.listening === undefined)
|
|
54
64
|
return;
|
|
55
65
|
const server = await this.listening;
|
|
56
|
-
this.capabilities.clear();
|
|
57
66
|
const closed = new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))));
|
|
58
67
|
for (const socket of this.sockets)
|
|
59
68
|
socket.destroy();
|
|
@@ -61,18 +70,6 @@ export class GoalMcpBridge {
|
|
|
61
70
|
if (process.platform !== 'win32')
|
|
62
71
|
await unlink(this.endpoint).catch(() => undefined);
|
|
63
72
|
}
|
|
64
|
-
revoke(executionId) {
|
|
65
|
-
for (const [token, capability] of this.capabilities)
|
|
66
|
-
if (capability.executionId === executionId)
|
|
67
|
-
this.capabilities.delete(token);
|
|
68
|
-
}
|
|
69
|
-
migrateSession(previousId, nextId) {
|
|
70
|
-
if (previousId === nextId)
|
|
71
|
-
return;
|
|
72
|
-
for (const [token, capability] of this.capabilities)
|
|
73
|
-
if (capability.sessionId === previousId)
|
|
74
|
-
this.capabilities.set(token, { ...capability, sessionId: nextId });
|
|
75
|
-
}
|
|
76
73
|
handle(socket) {
|
|
77
74
|
this.sockets.add(socket);
|
|
78
75
|
socket.once('close', () => this.sockets.delete(socket));
|
|
@@ -90,9 +87,13 @@ export class GoalMcpBridge {
|
|
|
90
87
|
async respond(socket, body) {
|
|
91
88
|
try {
|
|
92
89
|
const request = JSON.parse(body);
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
90
|
+
const claims = this.tokens.verify(request.token);
|
|
91
|
+
if (claims === undefined)
|
|
95
92
|
throw new Error('GOAL_CAPABILITY_INVALID');
|
|
93
|
+
const sessionId = this.resolveExecution(claims);
|
|
94
|
+
if (sessionId === undefined)
|
|
95
|
+
throw new Error('GOAL_CAPABILITY_STALE');
|
|
96
|
+
const capability = { ...claims, sessionId };
|
|
96
97
|
const current = this.current(capability);
|
|
97
98
|
if (request.tool === NODE_MCP.goal.tools.get) {
|
|
98
99
|
socket.end(`${JSON.stringify({ result: current })}\n`);
|
|
@@ -18,11 +18,7 @@ export class NodeUpgradeReporter {
|
|
|
18
18
|
endpoint.protocol = 'http:';
|
|
19
19
|
if (endpoint.protocol === 'wss:')
|
|
20
20
|
endpoint.protocol = 'https:';
|
|
21
|
-
if (endpoint.username ||
|
|
22
|
-
endpoint.password ||
|
|
23
|
-
(endpoint.protocol !== 'https:' &&
|
|
24
|
-
!(endpoint.protocol === 'http:' &&
|
|
25
|
-
['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname))))
|
|
21
|
+
if (endpoint.username || endpoint.password || !['http:', 'https:'].includes(endpoint.protocol))
|
|
26
22
|
throw new Error('NODE_UPGRADE_REPORT_ORIGIN_INVALID');
|
|
27
23
|
endpoint.pathname = `/api/nodes/${encodeURIComponent(nodeId)}/upgrade/report`;
|
|
28
24
|
endpoint.search = '';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { NODE_MCP } from '../mcp/naming.js';
|
|
2
|
-
import {
|
|
2
|
+
import { McpCapabilityToken } from '../mcp/capability-token.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
3
5
|
import { existsSync } from 'node:fs';
|
|
4
6
|
import { unlink } from 'node:fs/promises';
|
|
5
7
|
import { createServer } from 'node:net';
|
|
@@ -8,11 +10,19 @@ import { join } from 'node:path';
|
|
|
8
10
|
import { fileURLToPath } from 'node:url';
|
|
9
11
|
import { notificationInputSchema, notificationChannelsRequestSchema, mailNotificationRequestSchema } from '@myagentroam/protocol';
|
|
10
12
|
import { sessionGroup } from '../session/session-presentation-service.js';
|
|
13
|
+
const capabilitySchema = z.object({
|
|
14
|
+
executionId: z.string(),
|
|
15
|
+
sessionId: z.string(),
|
|
16
|
+
workspaceId: z.string(),
|
|
17
|
+
userId: z.number().int().positive(),
|
|
18
|
+
source: z.enum(['MANUAL', 'SCHEDULED'])
|
|
19
|
+
});
|
|
20
|
+
/** Owns notification IPC and signed scope; live authorization belongs to Runtime and Server. */
|
|
11
21
|
export class NotificationMcpBridge {
|
|
12
22
|
resolve;
|
|
13
23
|
send;
|
|
14
24
|
channels;
|
|
15
|
-
|
|
25
|
+
tokens = new McpCapabilityToken(NODE_MCP.notification.name, capabilitySchema);
|
|
16
26
|
sockets = new Set();
|
|
17
27
|
endpoint = process.platform === 'win32'
|
|
18
28
|
? `\\\\.\\pipe\\mar-notification-${process.pid}-${randomUUID()}`
|
|
@@ -26,9 +36,8 @@ export class NotificationMcpBridge {
|
|
|
26
36
|
async prepare(capability) {
|
|
27
37
|
await this.listen();
|
|
28
38
|
const args = notificationMcpStdioArgs();
|
|
29
|
-
const token =
|
|
39
|
+
const token = this.tokens.sign(capability);
|
|
30
40
|
const notificationChannels = this.channels?.supported() === true;
|
|
31
|
-
this.capabilities.set(token, capability);
|
|
32
41
|
return {
|
|
33
42
|
name: NODE_MCP.notification.name,
|
|
34
43
|
origin: 'BUILTIN',
|
|
@@ -49,19 +58,10 @@ export class NotificationMcpBridge {
|
|
|
49
58
|
}
|
|
50
59
|
};
|
|
51
60
|
}
|
|
52
|
-
revoke(executionId) {
|
|
53
|
-
for (const [token, capability] of this.capabilities)
|
|
54
|
-
if (capability.executionId === executionId)
|
|
55
|
-
this.capabilities.delete(token);
|
|
56
|
-
}
|
|
57
|
-
revokeAll() {
|
|
58
|
-
this.capabilities.clear();
|
|
59
|
-
}
|
|
60
61
|
async close() {
|
|
61
62
|
if (this.listening === undefined)
|
|
62
63
|
return;
|
|
63
64
|
const server = await this.listening;
|
|
64
|
-
this.revokeAll();
|
|
65
65
|
const closed = new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))));
|
|
66
66
|
for (const socket of this.sockets)
|
|
67
67
|
socket.destroy();
|
|
@@ -100,7 +100,7 @@ export class NotificationMcpBridge {
|
|
|
100
100
|
async respond(socket, body) {
|
|
101
101
|
try {
|
|
102
102
|
const request = JSON.parse(body);
|
|
103
|
-
const capability =
|
|
103
|
+
const capability = this.tokens.verify(request['token']);
|
|
104
104
|
if (capability === undefined)
|
|
105
105
|
throw new Error('MAIL_CAPABILITY_INVALID');
|
|
106
106
|
const current = this.resolve(capability);
|
|
@@ -171,15 +171,10 @@ export class NotificationMcpBridge {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
export function resolveNotificationCapability(runtime, capability) {
|
|
174
|
-
const run = runtime.
|
|
174
|
+
const run = runtime.resolveMcpExecution(capability);
|
|
175
175
|
const session = runtime.getAgentSession(capability.sessionId);
|
|
176
176
|
if (run === undefined ||
|
|
177
177
|
session === undefined ||
|
|
178
|
-
['SUCCEEDED', 'FAILED', 'INTERRUPTED'].includes(run.status) ||
|
|
179
|
-
runtime.currentExecutionId(run.id) !== capability.executionId ||
|
|
180
|
-
run.sessionId !== session.id ||
|
|
181
|
-
run.workspaceId !== capability.workspaceId ||
|
|
182
|
-
run.initiatedByUserId !== capability.userId ||
|
|
183
178
|
(run.source === 'SCHEDULED' ? 'SCHEDULED' : 'MANUAL') !== capability.source)
|
|
184
179
|
return undefined;
|
|
185
180
|
return { ...capability, sessionId: session.id, sessionGroup: sessionGroup(session) };
|
|
@@ -1,35 +1,45 @@
|
|
|
1
1
|
import { NODE_MCP } from '../mcp/naming.js';
|
|
2
|
-
import {
|
|
2
|
+
import { McpCapabilityToken } from '../mcp/capability-token.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
3
5
|
import { existsSync } from 'node:fs';
|
|
4
6
|
import { unlink } from 'node:fs/promises';
|
|
5
7
|
import { createServer } from 'node:net';
|
|
6
8
|
import { tmpdir } from 'node:os';
|
|
7
9
|
import { join } from 'node:path';
|
|
8
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
+
const capabilitySchema = z.object({
|
|
12
|
+
executionId: z.string(),
|
|
13
|
+
sessionId: z.string(),
|
|
14
|
+
workspaceId: z.string().optional(),
|
|
15
|
+
taskId: z.string().optional(),
|
|
16
|
+
revision: z.number().int().nonnegative().optional(),
|
|
17
|
+
actorUserId: z.number().int().positive()
|
|
18
|
+
});
|
|
19
|
+
/** Owns task IPC, revision checks and private credential renewal after successful edits. */
|
|
9
20
|
export class ScheduleMcpBridge {
|
|
10
21
|
store;
|
|
11
22
|
changed;
|
|
12
23
|
proposed;
|
|
24
|
+
resolveExecution;
|
|
13
25
|
create;
|
|
14
|
-
|
|
15
|
-
#capabilities = new Map();
|
|
26
|
+
#tokens = new McpCapabilityToken(NODE_MCP.schedule.name, capabilitySchema);
|
|
16
27
|
#sockets = new Set();
|
|
17
28
|
#endpoint = process.platform === 'win32'
|
|
18
29
|
? `\\\\.\\pipe\\mar-schedule-${process.pid}-${randomUUID()}`
|
|
19
30
|
: join(tmpdir(), `mar-schedule-${process.pid}-${randomUUID()}.sock`);
|
|
20
31
|
#listening;
|
|
21
|
-
constructor(store, changed, proposed,
|
|
32
|
+
constructor(store, changed, proposed, resolveExecution, create) {
|
|
22
33
|
this.store = store;
|
|
23
34
|
this.changed = changed;
|
|
24
35
|
this.proposed = proposed;
|
|
36
|
+
this.resolveExecution = resolveExecution;
|
|
25
37
|
this.create = create;
|
|
26
|
-
this.resolveSessionId = resolveSessionId;
|
|
27
38
|
}
|
|
28
39
|
async prepare(capability) {
|
|
29
40
|
await this.#listen();
|
|
30
41
|
const args = scheduleMcpStdioArgs();
|
|
31
|
-
const token =
|
|
32
|
-
this.#capabilities.set(token, capability);
|
|
42
|
+
const token = this.#tokens.sign(capability);
|
|
33
43
|
return {
|
|
34
44
|
name: NODE_MCP.schedule.name,
|
|
35
45
|
origin: 'BUILTIN',
|
|
@@ -48,16 +58,10 @@ export class ScheduleMcpBridge {
|
|
|
48
58
|
}
|
|
49
59
|
};
|
|
50
60
|
}
|
|
51
|
-
revoke(executionId) {
|
|
52
|
-
for (const [token, capability] of this.#capabilities)
|
|
53
|
-
if (capability.executionId === executionId)
|
|
54
|
-
this.#capabilities.delete(token);
|
|
55
|
-
}
|
|
56
61
|
async close() {
|
|
57
62
|
if (this.#listening === undefined)
|
|
58
63
|
return;
|
|
59
64
|
const server = await this.#listening;
|
|
60
|
-
this.#capabilities.clear();
|
|
61
65
|
const closed = new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))));
|
|
62
66
|
for (const socket of this.#sockets)
|
|
63
67
|
socket.destroy();
|
|
@@ -91,19 +95,24 @@ export class ScheduleMcpBridge {
|
|
|
91
95
|
async #respond(socket, body) {
|
|
92
96
|
try {
|
|
93
97
|
const request = JSON.parse(body);
|
|
94
|
-
const capability =
|
|
98
|
+
const capability = this.#tokens.verify(request.token);
|
|
95
99
|
if (capability === undefined)
|
|
96
100
|
throw new Error('SCHEDULE_CAPABILITY_INVALID');
|
|
97
|
-
const sessionId = this.
|
|
98
|
-
|
|
99
|
-
if ((task === undefined && capability.taskId !== undefined) ||
|
|
100
|
-
(task !== undefined &&
|
|
101
|
-
(task.id !== capability.taskId || task.revision !== capability.revision)))
|
|
101
|
+
const sessionId = this.resolveExecution(capability);
|
|
102
|
+
if (sessionId === undefined)
|
|
102
103
|
throw new Error('SCHEDULE_CAPABILITY_STALE');
|
|
104
|
+
const task = this.store().get(sessionId);
|
|
103
105
|
if (request.tool === NODE_MCP.schedule.tools.get) {
|
|
106
|
+
// A create may commit before stdio receives the token that binds its task identity.
|
|
107
|
+
if (capability.taskId !== undefined && task?.id !== capability.taskId)
|
|
108
|
+
throw new Error('SCHEDULE_CAPABILITY_STALE');
|
|
104
109
|
socket.end(`${JSON.stringify({ result: task ?? null })}\n`);
|
|
105
110
|
return;
|
|
106
111
|
}
|
|
112
|
+
if ((task === undefined && capability.taskId !== undefined) ||
|
|
113
|
+
(task !== undefined &&
|
|
114
|
+
(task.id !== capability.taskId || task.revision !== capability.revision)))
|
|
115
|
+
throw new Error('SCHEDULE_CAPABILITY_STALE');
|
|
107
116
|
if (request.tool === NODE_MCP.schedule.tools.create) {
|
|
108
117
|
if (task !== undefined)
|
|
109
118
|
throw new Error('SCHEDULE_ALREADY_EXISTS');
|
|
@@ -114,19 +123,19 @@ export class ScheduleMcpBridge {
|
|
|
114
123
|
this.create === undefined)
|
|
115
124
|
throw new Error('SCHEDULE_INVALID');
|
|
116
125
|
const created = await this.create({
|
|
117
|
-
sessionId
|
|
126
|
+
sessionId,
|
|
118
127
|
workspaceId: capability.workspaceId,
|
|
119
128
|
actorUserId: capability.actorUserId,
|
|
120
129
|
objective: args.objective,
|
|
121
130
|
cron: args.cron
|
|
122
131
|
});
|
|
123
|
-
this.#
|
|
132
|
+
const token = this.#tokens.sign({
|
|
124
133
|
...capability,
|
|
125
134
|
sessionId: created.sessionId,
|
|
126
135
|
taskId: created.id,
|
|
127
136
|
revision: created.revision
|
|
128
137
|
});
|
|
129
|
-
socket.end(`${JSON.stringify({ result: created })}\n`);
|
|
138
|
+
socket.end(`${JSON.stringify({ result: created, token })}\n`);
|
|
130
139
|
return;
|
|
131
140
|
}
|
|
132
141
|
if (request.tool !== NODE_MCP.schedule.tools.update)
|
|
@@ -140,12 +149,12 @@ export class ScheduleMcpBridge {
|
|
|
140
149
|
socket.end(`${JSON.stringify({ result })}\n`);
|
|
141
150
|
}
|
|
142
151
|
else {
|
|
143
|
-
this.#
|
|
152
|
+
const token = this.#tokens.sign({
|
|
144
153
|
...capability,
|
|
145
154
|
revision: result.revision
|
|
146
155
|
});
|
|
147
156
|
this.changed(task.sessionId);
|
|
148
|
-
socket.end(`${JSON.stringify({ result })}\n`);
|
|
157
|
+
socket.end(`${JSON.stringify({ result, token })}\n`);
|
|
149
158
|
}
|
|
150
159
|
}
|
|
151
160
|
catch (error) {
|
|
@@ -4,9 +4,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
const endpoint = process.env['MAR_SCHEDULE_MCP_ENDPOINT'];
|
|
7
|
-
|
|
7
|
+
let token = process.env['MAR_SCHEDULE_MCP_TOKEN'];
|
|
8
8
|
if (endpoint === undefined || token === undefined)
|
|
9
9
|
throw new Error('SCHEDULE_CAPABILITY_INVALID');
|
|
10
|
+
// This client owns one rotating credential; consume renewals before dispatching later calls.
|
|
11
|
+
let previousRequest = Promise.resolve();
|
|
10
12
|
const server = new McpServer({ name: NODE_MCP.schedule.name, version: NODE_MCP_VERSION });
|
|
11
13
|
const scopeNote = 'Scope is limited to the MAR scheduled task managed by Node for this conversation; ' +
|
|
12
14
|
'it does not cover repository, CI, system cron, or other timers.';
|
|
@@ -15,7 +17,7 @@ const objectiveDescription = 'Task body for one scheduled run in this conversati
|
|
|
15
17
|
server.registerTool(NODE_MCP.schedule.tools.get, {
|
|
16
18
|
description: 'Read the MAR scheduled task bound to this MAR-managed Workspace conversation, or null if none exists. ' +
|
|
17
19
|
scopeNote
|
|
18
|
-
}, async () => result(await request(NODE_MCP.schedule.tools.get, {})));
|
|
20
|
+
}, async (extra) => result(await request(NODE_MCP.schedule.tools.get, {}, extra.signal)));
|
|
19
21
|
server.registerTool(NODE_MCP.schedule.tools.create, {
|
|
20
22
|
description: 'Create the MAR scheduled task for this MAR-managed Workspace conversation. MAR runs it in this conversation for each admitted trigger using the connected Node time zone. ' +
|
|
21
23
|
scopeNote,
|
|
@@ -27,7 +29,7 @@ server.registerTool(NODE_MCP.schedule.tools.create, {
|
|
|
27
29
|
.max(64)
|
|
28
30
|
.describe('Five-field cron expression interpreted in the connected MAR Node time zone. It defines the schedule separately from the per-run task body.')
|
|
29
31
|
}
|
|
30
|
-
}, async (args) => result(await request(NODE_MCP.schedule.tools.create, args)));
|
|
32
|
+
}, async (args, extra) => result(await request(NODE_MCP.schedule.tools.create, args, extra.signal)));
|
|
31
33
|
server.registerTool(NODE_MCP.schedule.tools.update, {
|
|
32
34
|
description: 'Update the MAR scheduled task for this MAR-managed Workspace conversation. MAR runs the saved objective in this conversation for each admitted trigger using the connected Node time zone. ' +
|
|
33
35
|
scopeNote,
|
|
@@ -40,14 +42,24 @@ server.registerTool(NODE_MCP.schedule.tools.update, {
|
|
|
40
42
|
.describe('Replacement five-field cron expression in the connected MAR Node time zone.')
|
|
41
43
|
.optional()
|
|
42
44
|
}
|
|
43
|
-
}, async (args) => result(await request(NODE_MCP.schedule.tools.update, args)));
|
|
45
|
+
}, async (args, extra) => result(await request(NODE_MCP.schedule.tools.update, args, extra.signal)));
|
|
44
46
|
await server.connect(new StdioServerTransport());
|
|
45
|
-
function request(tool, args) {
|
|
47
|
+
function request(tool, args, signal) {
|
|
48
|
+
const operation = previousRequest.then(() => exchange(tool, args, signal));
|
|
49
|
+
previousRequest = operation.catch(() => undefined);
|
|
50
|
+
return operation;
|
|
51
|
+
}
|
|
52
|
+
function exchange(tool, args, signal) {
|
|
53
|
+
if (signal.aborted)
|
|
54
|
+
return Promise.reject(new Error('SCHEDULE_CALL_CANCELLED: The schedule call was cancelled before dispatch.'));
|
|
46
55
|
return new Promise((resolve, reject) => {
|
|
47
56
|
const socket = connect(endpoint);
|
|
57
|
+
const abort = () => socket.destroy(new Error('SCHEDULE_DELIVERY_UNCERTAIN: The schedule call was cancelled after dispatch; its result is uncertain.'));
|
|
58
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
59
|
+
socket.once('close', () => signal.removeEventListener('abort', abort));
|
|
48
60
|
let body = '';
|
|
49
61
|
socket.setEncoding('utf8');
|
|
50
|
-
socket.once('connect', () => socket.
|
|
62
|
+
socket.once('connect', () => socket.write(`${JSON.stringify({ token, tool, arguments: args })}\n`));
|
|
51
63
|
socket.on('data', (chunk) => (body += chunk));
|
|
52
64
|
socket.once('error', reject);
|
|
53
65
|
socket.once('end', () => {
|
|
@@ -55,11 +67,16 @@ function request(tool, args) {
|
|
|
55
67
|
const payload = JSON.parse(body);
|
|
56
68
|
if (payload.error !== undefined)
|
|
57
69
|
reject(new Error(payload.error));
|
|
58
|
-
else
|
|
70
|
+
else {
|
|
71
|
+
// Credential renewal stays inside IPC; model-visible results never contain tokens.
|
|
72
|
+
if (typeof payload.token === 'string')
|
|
73
|
+
token = payload.token;
|
|
59
74
|
resolve(payload.result);
|
|
75
|
+
}
|
|
60
76
|
}
|
|
61
|
-
catch
|
|
62
|
-
|
|
77
|
+
catch {
|
|
78
|
+
// JSON diagnostics may include a partially received private renewal credential.
|
|
79
|
+
reject(new Error('SCHEDULE_IPC_RESPONSE_INVALID: The schedule service returned an invalid response.'));
|
|
63
80
|
}
|
|
64
81
|
});
|
|
65
82
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.16",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/myagentroam/mar.git",
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
"turndown-plugin-gfm": "^1.0.2",
|
|
50
50
|
"ws": "^8.21.3",
|
|
51
51
|
"zod": "4.4.3",
|
|
52
|
-
"@myagentroam/protocol": "0.13.
|
|
53
|
-
"@myagentroam/agent": "0.13.
|
|
52
|
+
"@myagentroam/protocol": "0.13.16",
|
|
53
|
+
"@myagentroam/agent": "0.13.16"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/turndown": "^5.0.6",
|
|
57
57
|
"@types/ws": "^8.18.1",
|
|
58
58
|
"@myagentroam/code-navigation": "0.11.1",
|
|
59
|
-
"@myagentroam/marctl": "0.13.
|
|
59
|
+
"@myagentroam/marctl": "0.13.16"
|
|
60
60
|
},
|
|
61
61
|
"exports": {
|
|
62
62
|
"./config": {
|