zerogterm 0.8.0-alpha → 0.9.0-alpha.1
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/LICENSE +21 -21
- package/README.md +2 -1
- package/dist/main/main/main.js +295 -1
- package/dist/main/main/mcp-audit.js +12 -0
- package/dist/main/main/mcp-control.js +95 -0
- package/dist/main/main/mcp-execution-broker.js +126 -0
- package/dist/main/main/mcp-execution-policy.js +43 -0
- package/dist/main/main/mcp-protocol.js +51 -0
- package/dist/main/main/mcp-server.js +221 -0
- package/dist/main/main/preload.cjs +28 -0
- package/dist/main/main/prompt-classifier.js +14 -0
- package/dist/main/main/workspace-store.js +3 -0
- package/dist/manifest.json +1 -0
- package/dist/renderer/assets/index-BovKjPi0.css +1 -0
- package/dist/renderer/assets/index-DYB0NFWF.js +142 -0
- package/dist/renderer/index.html +2 -2
- package/package.json +11 -6
- package/dist/renderer/assets/index-BPS6JAQV.js +0 -142
- package/dist/renderer/assets/index-p0gJDyuE.css +0 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Stephen Phillips
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stephen Phillips
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ ZeroG Terminal is an alpha project, but it is already useful as a multi-session
|
|
|
30
30
|
- AI command suggestions from any OpenAI-compatible endpoint — Ollama, LM Studio, llama.cpp, vLLM, OpenRouter, or OpenAI itself — configured with a base URL, a model and an optional key in Settings. Ask what you want, get one command with an explanation, and approve it before it runs.
|
|
31
31
|
- Sandboxed Electron renderer, context isolation, disabled Node integration, and a narrow typed preload API.
|
|
32
32
|
- Safe argument-array handling and validation around SSH and `screen` session operations.
|
|
33
|
+
- Optional local MCP control for inspecting and restoring workspaces, and creating explicit project workspaces. It is loopback-only, bearer-authenticated, lease-controlled, and never executes terminal commands; see [docs/mcp.md](docs/mcp.md).
|
|
33
34
|
|
|
34
35
|
The project is particularly useful for terminal-based AI development workflows: start an agent in a persistent session, disconnect or suffer an interrupted connection, and reconnect later to see what it has done and continue working.
|
|
35
36
|
|
|
@@ -37,7 +38,7 @@ See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
|
|
|
37
38
|
|
|
38
39
|
## Release status
|
|
39
40
|
|
|
40
|
-
ZeroG Terminal is currently a public alpha. The current release is `0.
|
|
41
|
+
ZeroG Terminal is currently a public alpha. The current release is `0.9.0-alpha.1`; the version history is tracked in [versions.txt](versions.txt). The running version is shown beside the wordmark in the title bar, read from the app itself rather than written into the interface, so it is accurate in a packaged build too.
|
|
41
42
|
|
|
42
43
|
The GitHub Releases page provides a Windows x64 installer and portable executable for each desktop release. These alpha builds are intended for early adopters and testing rather than production use. The npm package remains available for developers who prefer to launch ZeroG from Node.js.
|
|
43
44
|
|
package/dist/main/main/main.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { app, BrowserWindow, clipboard, ipcMain, Menu, safeStorage, session, shell } from 'electron';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { writeClipboardText } from './clipboard.js';
|
|
5
6
|
import { ScreenService, parseWslDistributions } from './session-service.js';
|
|
@@ -16,6 +17,13 @@ import { decideExternalLink, isApplicationUrl } from './external-links.js';
|
|
|
16
17
|
import { SftpService } from './sftp-service.js';
|
|
17
18
|
import { AI_API_KEY, SPEECH_API_KEY, SecretStore, defaultSecretsPath } from './secret-store.js';
|
|
18
19
|
import { AiService } from './ai-service.js';
|
|
20
|
+
import { McpControl } from './mcp-control.js';
|
|
21
|
+
import { McpServerHost } from './mcp-server.js';
|
|
22
|
+
import { McpExecutionBroker } from './mcp-execution-broker.js';
|
|
23
|
+
import { McpAudit } from './mcp-audit.js';
|
|
24
|
+
import { classifyMcpPrompt } from './prompt-classifier.js';
|
|
25
|
+
import { isSafeRemoteCommand } from './mcp-execution-policy.js';
|
|
26
|
+
import { requireWorkspaceName } from './mcp-protocol.js';
|
|
19
27
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
20
28
|
const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
|
|
21
29
|
// The only store holding what the user typed. Nothing reaches it that has not
|
|
@@ -35,6 +43,90 @@ const sftp = new SftpService({ onEvent: (event) => win?.webContents.send('sftp:e
|
|
|
35
43
|
// API keys for speech servers. safeStorage is only usable after the app is
|
|
36
44
|
// ready, which every IPC call here already is.
|
|
37
45
|
const secrets = new SecretStore({ filePath: defaultSecretsPath(app.getPath('userData')), crypto: safeStorage });
|
|
46
|
+
const mcpControl = new McpControl({ onChange: (status) => win?.webContents.send('mcp:status', status) });
|
|
47
|
+
const mcpExecutions = new McpExecutionBroker();
|
|
48
|
+
const mcpAudit = new McpAudit();
|
|
49
|
+
const mcpActiveExecutions = new Map();
|
|
50
|
+
const mcpExecutionOutput = new Map();
|
|
51
|
+
const mcpExecutionTimers = new Map();
|
|
52
|
+
function clearMcpExecution(requestId, sessionId) {
|
|
53
|
+
const timer = mcpExecutionTimers.get(requestId);
|
|
54
|
+
if (timer)
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
mcpExecutionTimers.delete(requestId);
|
|
57
|
+
mcpExecutionOutput.delete(requestId);
|
|
58
|
+
if (sessionId) {
|
|
59
|
+
if (mcpActiveExecutions.get(sessionId) === requestId)
|
|
60
|
+
mcpActiveExecutions.delete(sessionId);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
for (const [activeSessionId, activeRequestId] of Array.from(mcpActiveExecutions.entries())) {
|
|
64
|
+
if (activeRequestId === requestId)
|
|
65
|
+
mcpActiveExecutions.delete(activeSessionId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let mcpHost;
|
|
70
|
+
async function restoreWorkspace(workspaceId) {
|
|
71
|
+
const file = await workspaceStore.load();
|
|
72
|
+
const workspace = file.workspaces.find((item) => item.id === workspaceId);
|
|
73
|
+
if (!workspace)
|
|
74
|
+
throw new Error(`Unknown workspace: ${workspaceId}`);
|
|
75
|
+
const existing = await service.list();
|
|
76
|
+
const restored = [];
|
|
77
|
+
for (const member of workspace.members) {
|
|
78
|
+
const match = existing.find((session) => session.id === member.sessionId ||
|
|
79
|
+
(member.kind === 'ssh' && Boolean(member.sshTarget) && session.sshTarget === member.sshTarget) ||
|
|
80
|
+
(member.kind === 'local' && Boolean(member.screenName) && session.screenName === member.screenName));
|
|
81
|
+
if (match) {
|
|
82
|
+
restored.push({ member, session: match, action: 'reused' });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const session = member.kind === 'ssh' && member.sshTarget
|
|
86
|
+
? await service.createSsh(member.sshTarget, member.name)
|
|
87
|
+
: await service.createLocal({ name: member.name, ...(member.cwd ? { cwd: member.cwd } : {}) });
|
|
88
|
+
restored.push({ member, session, action: 'created' });
|
|
89
|
+
}
|
|
90
|
+
win?.webContents.send('mcp:workspace-restored', { workspaceId, workspaceName: workspace.name, restored });
|
|
91
|
+
return { workspaceId, workspaceName: workspace.name, restored };
|
|
92
|
+
}
|
|
93
|
+
async function createWorkspace() {
|
|
94
|
+
const file = await workspaceStore.load();
|
|
95
|
+
const names = new Set(file.workspaces.map((workspace) => workspace.name.toLowerCase()));
|
|
96
|
+
let index = file.workspaces.length + 1;
|
|
97
|
+
while (names.has(`workspace-${index}`))
|
|
98
|
+
index += 1;
|
|
99
|
+
const workspace = { id: `ws-${randomUUID()}`, name: `workspace-${index}`, members: [], view: { layout: 'stack', lastSplit: 'split-v', maximizedSessionId: null } };
|
|
100
|
+
await workspaceStore.save({ version: 1, workspaces: [...file.workspaces, workspace], activeWorkspaceId: workspace.id });
|
|
101
|
+
win?.webContents.send('mcp:workspace-restored', { workspaceId: workspace.id, workspaceName: workspace.name, restored: [] });
|
|
102
|
+
return { workspaceId: workspace.id, workspaceName: workspace.name, restored: [] };
|
|
103
|
+
}
|
|
104
|
+
async function createProjectWorkspace(request) {
|
|
105
|
+
const name = requireWorkspaceName(request.name);
|
|
106
|
+
const file = await workspaceStore.load();
|
|
107
|
+
const existing = file.workspaces.find((item) => item.name === name);
|
|
108
|
+
const running = await service.list();
|
|
109
|
+
const workspaceId = existing?.id ?? `ws-${randomUUID()}`;
|
|
110
|
+
const sessions = [];
|
|
111
|
+
for (const project of request.projects) {
|
|
112
|
+
const reused = running.find((session) => session.kind === 'local' && session.name === project.name && session.cwd === project.cwd);
|
|
113
|
+
const session = reused ?? await service.createLocal({ name: project.name, cwd: project.cwd, ...(project.backend ? { backend: project.backend } : {}) });
|
|
114
|
+
sessions.push(session);
|
|
115
|
+
}
|
|
116
|
+
const members = sessions.map((session) => ({
|
|
117
|
+
sessionId: session.id,
|
|
118
|
+
kind: session.kind,
|
|
119
|
+
name: session.name,
|
|
120
|
+
...(session.host ? { host: session.host } : {}),
|
|
121
|
+
...(session.screenName ? { screenName: session.screenName } : {}),
|
|
122
|
+
...(session.backend ? { backend: session.backend } : {}),
|
|
123
|
+
...(session.kind === 'local' && session.cwd ? { cwd: session.cwd } : {})
|
|
124
|
+
}));
|
|
125
|
+
const workspace = { id: workspaceId, name, members, view: { layout: 'grid', lastSplit: 'grid', maximizedSessionId: null } };
|
|
126
|
+
await workspaceStore.save({ version: 1, workspaces: [...file.workspaces.filter((item) => item.id !== workspaceId), workspace], activeWorkspaceId: workspaceId });
|
|
127
|
+
win?.webContents.send('mcp:workspace-restored', { workspaceId, workspaceName: name, restored: sessions.map((session, index) => ({ member: members[index], session, action: 'created' })) });
|
|
128
|
+
return { workspaceId, workspaceName: name, restored: sessions };
|
|
129
|
+
}
|
|
38
130
|
/** A pane's measured size, as it arrives from the renderer. */
|
|
39
131
|
function parsePtySize(value) {
|
|
40
132
|
if (!value || typeof value !== 'object')
|
|
@@ -229,6 +321,126 @@ ipcMain.handle('forwards:load', () => forwardStore.load());
|
|
|
229
321
|
ipcMain.handle('forwards:save', (_event, file) => forwardStore.save(file));
|
|
230
322
|
ipcMain.handle('workspaces:load', () => workspaceStore.load());
|
|
231
323
|
ipcMain.handle('workspaces:save', (_event, file) => workspaceStore.save(file));
|
|
324
|
+
ipcMain.handle('mcp:status', () => mcpControl.status());
|
|
325
|
+
ipcMain.handle('mcp:revoke', () => { mcpControl.revoke(); mcpExecutions.revokeAll(); for (const requestId of Array.from(mcpExecutionTimers.keys()))
|
|
326
|
+
clearMcpExecution(requestId); win?.webContents.send('mcp:execution', mcpExecutions.listAll()); });
|
|
327
|
+
ipcMain.handle('mcp:executions:list', () => mcpExecutions.listAll());
|
|
328
|
+
ipcMain.handle('mcp:audit:list', () => mcpAudit.list());
|
|
329
|
+
async function runMcpExecution(requestId) {
|
|
330
|
+
const request = mcpExecutions.listAll().find((item) => item.requestId === requestId);
|
|
331
|
+
if (!request || !('sessionId' in request))
|
|
332
|
+
throw new Error('Unknown command request.');
|
|
333
|
+
const sessions = await service.list();
|
|
334
|
+
const session = sessions.find((item) => item.id === request.sessionId);
|
|
335
|
+
if (!session)
|
|
336
|
+
throw new Error('Only attached sessions may execute approved commands.');
|
|
337
|
+
const running = mcpExecutions.start(requestId);
|
|
338
|
+
mcpActiveExecutions.set(session.id, requestId);
|
|
339
|
+
mcpExecutionOutput.set(requestId, '');
|
|
340
|
+
mcpExecutionTimers.set(requestId, setTimeout(() => {
|
|
341
|
+
const output = mcpExecutionOutput.get(requestId) ?? '';
|
|
342
|
+
service.write(session.id, '\u0003');
|
|
343
|
+
const result = mcpExecutions.finishRunning(requestId, 'timed-out', output, output.length >= running.policy.maxOutputBytes, 'Execution timed out.');
|
|
344
|
+
clearMcpExecution(requestId, session.id);
|
|
345
|
+
win?.webContents.send('mcp:execution', result);
|
|
346
|
+
win?.webContents.send('terminal:status', session.id, 'MCP command timed out.');
|
|
347
|
+
}, running.policy.maxRuntimeMs));
|
|
348
|
+
service.write(session.id, `${running.command}\n`);
|
|
349
|
+
win?.webContents.send('mcp:execution', running);
|
|
350
|
+
return running;
|
|
351
|
+
}
|
|
352
|
+
ipcMain.handle('mcp:execution:approve', async (_event, requestId) => {
|
|
353
|
+
if (typeof requestId !== 'string' || !requestId)
|
|
354
|
+
throw new Error('A command request id is required.');
|
|
355
|
+
const pending = mcpExecutions.listAll().find((item) => item.requestId === requestId);
|
|
356
|
+
const result = mcpExecutions.decideFromUser(requestId, 'approve');
|
|
357
|
+
if (pending && 'clientId' in pending)
|
|
358
|
+
mcpAudit.record({ at: Date.now(), requestId, clientId: pending.clientId, capability: 'session:execute', sessionId: pending.sessionId, state: 'approved', command: pending.displayCommand });
|
|
359
|
+
mcpControl.require(result.clientId, 'session:execute');
|
|
360
|
+
return runMcpExecution(requestId);
|
|
361
|
+
});
|
|
362
|
+
ipcMain.handle('mcp:execution:reject', (_event, requestId) => {
|
|
363
|
+
if (typeof requestId !== 'string' || !requestId)
|
|
364
|
+
throw new Error('A command request id is required.');
|
|
365
|
+
const pending = mcpExecutions.listAll().find((item) => item.requestId === requestId);
|
|
366
|
+
const result = mcpExecutions.decideFromUser(requestId, 'reject');
|
|
367
|
+
if (pending && 'clientId' in pending)
|
|
368
|
+
mcpAudit.record({ at: Date.now(), requestId, clientId: pending.clientId, capability: 'session:execute', sessionId: pending.sessionId, state: 'rejected', command: pending.displayCommand });
|
|
369
|
+
clearMcpExecution(requestId);
|
|
370
|
+
win?.webContents.send('mcp:execution', result);
|
|
371
|
+
return result;
|
|
372
|
+
});
|
|
373
|
+
ipcMain.handle('mcp:execution:cancel', (_event, requestId) => {
|
|
374
|
+
if (typeof requestId !== 'string' || !requestId)
|
|
375
|
+
throw new Error('A command request id is required.');
|
|
376
|
+
const pending = mcpExecutions.listAll().find((item) => item.requestId === requestId);
|
|
377
|
+
const result = mcpExecutions.cancelFromUser(requestId);
|
|
378
|
+
if (pending && 'clientId' in pending)
|
|
379
|
+
mcpAudit.record({ at: Date.now(), requestId, clientId: pending.clientId, capability: 'session:execute', sessionId: pending.sessionId, state: 'cancelled', command: pending.displayCommand });
|
|
380
|
+
clearMcpExecution(requestId);
|
|
381
|
+
win?.webContents.send('mcp:execution', result);
|
|
382
|
+
return result;
|
|
383
|
+
});
|
|
384
|
+
ipcMain.handle('mcp:start', async () => {
|
|
385
|
+
mcpHost ??= new McpServerHost({
|
|
386
|
+
control: mcpControl,
|
|
387
|
+
providers: {
|
|
388
|
+
listWorkspaces: () => workspaceStore.load(),
|
|
389
|
+
listSessions: () => service.list(),
|
|
390
|
+
createWorkspace,
|
|
391
|
+
restoreWorkspace,
|
|
392
|
+
createSshSession: async (request) => {
|
|
393
|
+
const session = await service.createSsh(request.target, request.name);
|
|
394
|
+
const attached = service.attach(session.id, (data) => {
|
|
395
|
+
const requestId = mcpActiveExecutions.get(session.id);
|
|
396
|
+
if (requestId) {
|
|
397
|
+
const output = `${mcpExecutionOutput.get(requestId) ?? ''}${data}`.slice(0, 16 * 1024);
|
|
398
|
+
mcpExecutionOutput.set(requestId, output);
|
|
399
|
+
if ((output.match(/ExitCode=\d+/g) ?? []).length >= 2) {
|
|
400
|
+
const result = mcpExecutions.finishRunning(requestId, 'completed', output, false, 'Command completed.');
|
|
401
|
+
clearMcpExecution(requestId, session.id);
|
|
402
|
+
win?.webContents.send('mcp:execution', result);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
win?.webContents.send('terminal:data', session.id, data);
|
|
406
|
+
}, (message) => win?.webContents.send('terminal:status', session.id, message));
|
|
407
|
+
win?.webContents.send('mcp:ssh-session-created', { session: attached });
|
|
408
|
+
return attached;
|
|
409
|
+
},
|
|
410
|
+
createProjectWorkspace,
|
|
411
|
+
requestCommand: async (request) => {
|
|
412
|
+
const sessions = await service.list();
|
|
413
|
+
const session = sessions.find((item) => item.id === request.sessionId);
|
|
414
|
+
if (!session)
|
|
415
|
+
throw new Error('Unknown session.');
|
|
416
|
+
const result = mcpExecutions.create({ clientId: request.clientId, sessionId: request.sessionId, sessionKind: session.kind, command: request.command, policy: { allowRemoteSessions: session.kind === 'ssh', ...(request.timeoutMs === undefined ? {} : { maxRuntimeMs: request.timeoutMs }), ...(request.outputBytes === undefined ? {} : { maxOutputBytes: request.outputBytes }) } });
|
|
417
|
+
const safeRemote = session.kind === 'ssh' && isSafeRemoteCommand(result.command);
|
|
418
|
+
if (safeRemote) {
|
|
419
|
+
const approved = mcpExecutions.approveAutomatically(result.requestId);
|
|
420
|
+
mcpAudit.record({ at: Date.now(), requestId: approved.requestId, clientId: approved.clientId, capability: 'session:execute', sessionId: approved.sessionId, state: 'approved', command: approved.displayCommand });
|
|
421
|
+
await runMcpExecution(approved.requestId);
|
|
422
|
+
return mcpExecutions.get(approved.requestId, request.clientId);
|
|
423
|
+
}
|
|
424
|
+
mcpAudit.record({ at: Date.now(), requestId: result.requestId, clientId: result.clientId, capability: 'session:execute', sessionId: result.sessionId, state: 'requested', command: result.displayCommand });
|
|
425
|
+
win?.webContents.send('mcp:execution', result);
|
|
426
|
+
return result;
|
|
427
|
+
},
|
|
428
|
+
getCommandResult: async (request) => mcpExecutions.get(request.requestId, request.clientId),
|
|
429
|
+
cancelCommand: async (request) => {
|
|
430
|
+
const current = mcpExecutions.get(request.requestId, request.clientId);
|
|
431
|
+
const result = mcpExecutions.cancel(request.requestId, request.clientId);
|
|
432
|
+
const session = 'sessionId' in current ? (await service.list()).find((item) => item.id === current.sessionId) : undefined;
|
|
433
|
+
if (session?.kind === 'local')
|
|
434
|
+
service.write(session.id, '\u0003');
|
|
435
|
+
clearMcpExecution(request.requestId);
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
version: app.getVersion()
|
|
440
|
+
});
|
|
441
|
+
return mcpHost.start();
|
|
442
|
+
});
|
|
443
|
+
ipcMain.handle('mcp:stop', async () => { await mcpHost?.stop(); });
|
|
232
444
|
ipcMain.handle('sessions:backends', () => discoverShellBackends());
|
|
233
445
|
ipcMain.handle('sessions:wslDistributions', async () => {
|
|
234
446
|
try {
|
|
@@ -280,7 +492,30 @@ ipcMain.handle('screens:attachRemote', (_event, input, screenName) => {
|
|
|
280
492
|
ipcMain.handle('sessions:attach', (_event, id, size) => {
|
|
281
493
|
if (typeof id !== 'string' || !id)
|
|
282
494
|
throw new Error('attachSession requires a session id');
|
|
283
|
-
return service.attach(id, (data) =>
|
|
495
|
+
return service.attach(id, (data) => {
|
|
496
|
+
const requestId = mcpActiveExecutions.get(id);
|
|
497
|
+
if (requestId) {
|
|
498
|
+
const existing = mcpExecutionOutput.get(requestId) ?? '';
|
|
499
|
+
const next = `${existing}${data}`;
|
|
500
|
+
mcpExecutionOutput.set(requestId, next.slice(0, 16 * 1024));
|
|
501
|
+
if (next.length >= 16 * 1024) {
|
|
502
|
+
service.write(id, '\u0003');
|
|
503
|
+
const result = mcpExecutions.finishRunning(requestId, 'failed', next.slice(0, 16 * 1024), true, 'Execution stopped after reaching the output limit.');
|
|
504
|
+
clearMcpExecution(requestId, id);
|
|
505
|
+
win?.webContents.send('mcp:execution', result);
|
|
506
|
+
win?.webContents.send('terminal:status', id, 'MCP execution stopped: output limit reached.');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const prompt = classifyMcpPrompt(data);
|
|
510
|
+
if (prompt.kind !== 'unknown') {
|
|
511
|
+
const result = mcpExecutions.cancelFromUser(requestId, `Execution paused for a ${prompt.kind} prompt; MCP cannot answer prompts.`);
|
|
512
|
+
clearMcpExecution(requestId, id);
|
|
513
|
+
win?.webContents.send('mcp:execution', result);
|
|
514
|
+
win?.webContents.send('terminal:status', id, `MCP execution paused: ${prompt.kind} prompt requires user input.`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
win?.webContents.send('terminal:data', id, data);
|
|
518
|
+
}, (message) => win?.webContents.send('terminal:status', id, message), parsePtySize(size));
|
|
284
519
|
});
|
|
285
520
|
ipcMain.handle('sessions:close', (_event, id) => {
|
|
286
521
|
if (typeof id !== 'string' || !id)
|
|
@@ -469,6 +704,65 @@ app.whenReady().then(() => {
|
|
|
469
704
|
Menu.setApplicationMenu(null);
|
|
470
705
|
watchEventLoop();
|
|
471
706
|
createWindow();
|
|
707
|
+
if (process.env.ZEROG_MCP_ENABLED === '1') {
|
|
708
|
+
mcpHost ??= new McpServerHost({
|
|
709
|
+
control: mcpControl,
|
|
710
|
+
providers: {
|
|
711
|
+
listWorkspaces: () => workspaceStore.load(),
|
|
712
|
+
listSessions: () => service.list(),
|
|
713
|
+
createWorkspace,
|
|
714
|
+
restoreWorkspace,
|
|
715
|
+
createSshSession: async (request) => {
|
|
716
|
+
const session = await service.createSsh(request.target, request.name);
|
|
717
|
+
const attached = service.attach(session.id, (data) => {
|
|
718
|
+
const requestId = mcpActiveExecutions.get(session.id);
|
|
719
|
+
if (requestId) {
|
|
720
|
+
const output = `${mcpExecutionOutput.get(requestId) ?? ''}${data}`.slice(0, 16 * 1024);
|
|
721
|
+
mcpExecutionOutput.set(requestId, output);
|
|
722
|
+
if ((output.match(/ExitCode=\d+/g) ?? []).length >= 2) {
|
|
723
|
+
const result = mcpExecutions.finishRunning(requestId, 'completed', output, false, 'Command completed.');
|
|
724
|
+
clearMcpExecution(requestId, session.id);
|
|
725
|
+
win?.webContents.send('mcp:execution', result);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
win?.webContents.send('terminal:data', session.id, data);
|
|
729
|
+
}, (message) => win?.webContents.send('terminal:status', session.id, message));
|
|
730
|
+
win?.webContents.send('mcp:ssh-session-created', { session: attached });
|
|
731
|
+
return attached;
|
|
732
|
+
},
|
|
733
|
+
createProjectWorkspace,
|
|
734
|
+
requestCommand: async (request) => {
|
|
735
|
+
const sessions = await service.list();
|
|
736
|
+
const session = sessions.find((item) => item.id === request.sessionId);
|
|
737
|
+
if (!session)
|
|
738
|
+
throw new Error('Unknown session.');
|
|
739
|
+
const result = mcpExecutions.create({ clientId: request.clientId, sessionId: request.sessionId, sessionKind: session.kind, command: request.command, policy: { allowRemoteSessions: session.kind === 'ssh', ...(request.timeoutMs === undefined ? {} : { maxRuntimeMs: request.timeoutMs }), ...(request.outputBytes === undefined ? {} : { maxOutputBytes: request.outputBytes }) } });
|
|
740
|
+
const safeRemote = session.kind === 'ssh' && isSafeRemoteCommand(result.command);
|
|
741
|
+
if (safeRemote) {
|
|
742
|
+
const approved = mcpExecutions.approveAutomatically(result.requestId);
|
|
743
|
+
mcpAudit.record({ at: Date.now(), requestId: approved.requestId, clientId: approved.clientId, capability: 'session:execute', sessionId: approved.sessionId, state: 'approved', command: approved.displayCommand });
|
|
744
|
+
await runMcpExecution(approved.requestId);
|
|
745
|
+
return mcpExecutions.get(approved.requestId, request.clientId);
|
|
746
|
+
}
|
|
747
|
+
mcpAudit.record({ at: Date.now(), requestId: result.requestId, clientId: result.clientId, capability: 'session:execute', sessionId: result.sessionId, state: 'requested', command: result.displayCommand });
|
|
748
|
+
win?.webContents.send('mcp:execution', result);
|
|
749
|
+
return result;
|
|
750
|
+
},
|
|
751
|
+
getCommandResult: async (request) => mcpExecutions.get(request.requestId, request.clientId),
|
|
752
|
+
cancelCommand: async (request) => {
|
|
753
|
+
const current = mcpExecutions.get(request.requestId, request.clientId);
|
|
754
|
+
const result = mcpExecutions.cancel(request.requestId, request.clientId);
|
|
755
|
+
const session = 'sessionId' in current ? (await service.list()).find((item) => item.id === current.sessionId) : undefined;
|
|
756
|
+
if (session?.kind === 'local')
|
|
757
|
+
service.write(session.id, '\u0003');
|
|
758
|
+
clearMcpExecution(request.requestId);
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
version: app.getVersion()
|
|
763
|
+
});
|
|
764
|
+
void mcpHost.start();
|
|
765
|
+
}
|
|
472
766
|
app.on('activate', () => {
|
|
473
767
|
if (!BrowserWindow.getAllWindows().length)
|
|
474
768
|
createWindow();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const MAX_EVENTS = 256;
|
|
3
|
+
const MAX_COMMAND = 256;
|
|
4
|
+
export class McpAudit {
|
|
5
|
+
events = [];
|
|
6
|
+
record(input) {
|
|
7
|
+
this.events.push({ ...input, client: createHash('sha256').update(input.clientId).digest('hex').slice(0, 16), command: input.command.slice(0, MAX_COMMAND) });
|
|
8
|
+
if (this.events.length > MAX_EVENTS)
|
|
9
|
+
this.events.splice(0, this.events.length - MAX_EVENTS);
|
|
10
|
+
}
|
|
11
|
+
list() { return this.events.map((event) => ({ ...event })); }
|
|
12
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { requireMcpCapability } from './mcp-protocol.js';
|
|
2
|
+
/**
|
|
3
|
+
* The single-client control lease. This class deliberately knows nothing about
|
|
4
|
+
* MCP or Electron so revocation and expiry can be tested without a running app.
|
|
5
|
+
*/
|
|
6
|
+
export class McpControl {
|
|
7
|
+
leaseMs;
|
|
8
|
+
now;
|
|
9
|
+
onChange;
|
|
10
|
+
lease;
|
|
11
|
+
state = { state: 'disabled', capabilities: [] };
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.leaseMs = options.leaseMs ?? 5 * 60_000;
|
|
14
|
+
this.now = options.now ?? Date.now;
|
|
15
|
+
this.onChange = options.onChange;
|
|
16
|
+
}
|
|
17
|
+
status() {
|
|
18
|
+
this.expireIfNeeded();
|
|
19
|
+
return { ...this.state, capabilities: [...this.state.capabilities] };
|
|
20
|
+
}
|
|
21
|
+
enable(endpoint) {
|
|
22
|
+
this.state = { state: 'listening', endpoint, capabilities: [] };
|
|
23
|
+
this.emit();
|
|
24
|
+
return this.status();
|
|
25
|
+
}
|
|
26
|
+
disable() {
|
|
27
|
+
this.lease = undefined;
|
|
28
|
+
this.state = { state: 'disabled', capabilities: [] };
|
|
29
|
+
this.emit();
|
|
30
|
+
}
|
|
31
|
+
connect(clientId, clientName, capabilities) {
|
|
32
|
+
if (this.state.state === 'disabled')
|
|
33
|
+
throw new Error('MCP control is disabled in ZeroG settings.');
|
|
34
|
+
if (this.lease && this.lease.clientId !== clientId && this.isLeaseActive()) {
|
|
35
|
+
throw new Error('Another AI client already holds the ZeroG control lease.');
|
|
36
|
+
}
|
|
37
|
+
const unique = Array.from(new Set(capabilities));
|
|
38
|
+
this.lease = { clientId, ...(clientName ? { clientName } : {}), capabilities: unique, expiresAt: this.now() + this.leaseMs };
|
|
39
|
+
this.state = { ...this.state, state: 'connected', clientName, leaseExpiresAt: this.lease.expiresAt, capabilities: unique };
|
|
40
|
+
this.emit();
|
|
41
|
+
return { ...this.lease, capabilities: [...this.lease.capabilities] };
|
|
42
|
+
}
|
|
43
|
+
renew(clientId) {
|
|
44
|
+
this.requireClient(clientId);
|
|
45
|
+
const lease = this.lease;
|
|
46
|
+
lease.expiresAt = this.now() + this.leaseMs;
|
|
47
|
+
this.state = { ...this.state, leaseExpiresAt: lease.expiresAt };
|
|
48
|
+
this.emit();
|
|
49
|
+
return { ...lease, capabilities: [...lease.capabilities] };
|
|
50
|
+
}
|
|
51
|
+
upgrade(clientId, capabilities) {
|
|
52
|
+
this.requireClient(clientId);
|
|
53
|
+
const lease = this.lease;
|
|
54
|
+
const unique = Array.from(new Set([...lease.capabilities, ...capabilities]));
|
|
55
|
+
lease.capabilities = unique;
|
|
56
|
+
lease.expiresAt = this.now() + this.leaseMs;
|
|
57
|
+
this.state = { ...this.state, leaseExpiresAt: lease.expiresAt, capabilities: unique };
|
|
58
|
+
this.emit();
|
|
59
|
+
return { ...lease, capabilities: [...lease.capabilities] };
|
|
60
|
+
}
|
|
61
|
+
revoke(reason = 'AI control revoked by the user.') {
|
|
62
|
+
this.lease = undefined;
|
|
63
|
+
this.state = { ...this.state, state: 'revoked', leaseExpiresAt: undefined, capabilities: [] };
|
|
64
|
+
this.emit();
|
|
65
|
+
void reason;
|
|
66
|
+
}
|
|
67
|
+
require(clientId, capability) {
|
|
68
|
+
this.expireIfNeeded();
|
|
69
|
+
const lease = this.lease;
|
|
70
|
+
if (!lease || lease.clientId !== clientId)
|
|
71
|
+
throw new Error('This AI client does not hold the active control lease.');
|
|
72
|
+
requireMcpCapability(lease, capability, this.now());
|
|
73
|
+
}
|
|
74
|
+
requireClient(clientId) {
|
|
75
|
+
this.expireIfNeeded();
|
|
76
|
+
if (!this.lease || this.lease.clientId !== clientId)
|
|
77
|
+
throw new Error('This AI client does not hold the active control lease.');
|
|
78
|
+
}
|
|
79
|
+
isLeaseActive() {
|
|
80
|
+
return Boolean(this.lease && this.lease.expiresAt > this.now());
|
|
81
|
+
}
|
|
82
|
+
expireIfNeeded() {
|
|
83
|
+
if (this.lease && this.lease.expiresAt <= this.now()) {
|
|
84
|
+
this.lease = undefined;
|
|
85
|
+
this.state = { ...this.state, state: 'revoked', leaseExpiresAt: undefined, capabilities: [] };
|
|
86
|
+
this.emit();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
emit() {
|
|
90
|
+
this.onChange?.(this.statusWithoutExpiry());
|
|
91
|
+
}
|
|
92
|
+
statusWithoutExpiry() {
|
|
93
|
+
return { ...this.state, capabilities: [...this.state.capabilities] };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { validateMcpCommand } from './mcp-execution-policy.js';
|
|
3
|
+
/** Approval state only. Execution remains owned by the main-process PTY layer. */
|
|
4
|
+
export class McpExecutionBroker {
|
|
5
|
+
maxPending;
|
|
6
|
+
pending = new Map();
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.maxPending = options.maxPending ?? 8;
|
|
9
|
+
}
|
|
10
|
+
create(input) {
|
|
11
|
+
const decision = validateMcpCommand(input);
|
|
12
|
+
if (!decision.allowed || !decision.command || !decision.displayCommand)
|
|
13
|
+
throw new Error(decision.reason ?? 'Command rejected.');
|
|
14
|
+
if (Array.from(this.pending.values()).some((item) => item.request.clientId === input.clientId && item.request.sessionId === input.sessionId && item.request.state === 'pending')) {
|
|
15
|
+
throw new Error('A command approval is already pending for this session.');
|
|
16
|
+
}
|
|
17
|
+
if (Array.from(this.pending.values()).filter((item) => item.request.state === 'pending').length >= this.maxPending)
|
|
18
|
+
throw new Error('Too many pending command approvals.');
|
|
19
|
+
const now = input.now ?? Date.now();
|
|
20
|
+
const request = {
|
|
21
|
+
requestId: randomUUID(), clientId: input.clientId, sessionId: input.sessionId,
|
|
22
|
+
command: decision.command, displayCommand: decision.displayCommand, state: 'pending',
|
|
23
|
+
createdAt: now, expiresAt: now + Math.min(input.approvalTtlMs ?? 60_000, 5 * 60_000), policy: decision.policy
|
|
24
|
+
};
|
|
25
|
+
this.pending.set(request.requestId, { request });
|
|
26
|
+
return { ...request, policy: { ...request.policy } };
|
|
27
|
+
}
|
|
28
|
+
get(requestId, clientId, now = Date.now()) {
|
|
29
|
+
const item = this.owned(requestId, clientId);
|
|
30
|
+
this.expire(item, now);
|
|
31
|
+
return item.result ? { ...item.result } : { ...item.request, policy: { ...item.request.policy } };
|
|
32
|
+
}
|
|
33
|
+
decide(requestId, clientId, decision, now = Date.now()) {
|
|
34
|
+
const item = this.owned(requestId, clientId);
|
|
35
|
+
this.expire(item, now);
|
|
36
|
+
if (item.request.state !== 'pending')
|
|
37
|
+
throw new Error('Command approval is no longer pending.');
|
|
38
|
+
item.request.state = decision === 'approve' ? 'approved' : 'rejected';
|
|
39
|
+
if (decision === 'reject')
|
|
40
|
+
this.finish(item, 'rejected', 'Rejected by the user.', now);
|
|
41
|
+
return { ...item.request, policy: { ...item.request.policy } };
|
|
42
|
+
}
|
|
43
|
+
decideFromUser(requestId, decision, now = Date.now()) {
|
|
44
|
+
const item = this.pending.get(requestId);
|
|
45
|
+
if (!item)
|
|
46
|
+
throw new Error('Unknown command request.');
|
|
47
|
+
this.expire(item, now);
|
|
48
|
+
if (item.request.state !== 'pending')
|
|
49
|
+
throw new Error('Command approval is no longer pending.');
|
|
50
|
+
item.request.state = decision === 'approve' ? 'approved' : 'rejected';
|
|
51
|
+
if (decision === 'reject')
|
|
52
|
+
this.finish(item, 'rejected', 'Rejected by the user.', now);
|
|
53
|
+
return { ...item.request, policy: { ...item.request.policy } };
|
|
54
|
+
}
|
|
55
|
+
approveAutomatically(requestId, now = Date.now()) {
|
|
56
|
+
const item = this.pending.get(requestId);
|
|
57
|
+
if (!item)
|
|
58
|
+
throw new Error('Unknown command request.');
|
|
59
|
+
this.expire(item, now);
|
|
60
|
+
if (item.request.state !== 'pending')
|
|
61
|
+
throw new Error('Command approval is no longer pending.');
|
|
62
|
+
item.request.state = 'approved';
|
|
63
|
+
return { ...item.request, policy: { ...item.request.policy } };
|
|
64
|
+
}
|
|
65
|
+
start(requestId, now = Date.now()) {
|
|
66
|
+
const item = this.pending.get(requestId);
|
|
67
|
+
if (!item)
|
|
68
|
+
throw new Error('Unknown command request.');
|
|
69
|
+
this.expire(item, now);
|
|
70
|
+
if (item.request.state !== 'approved')
|
|
71
|
+
throw new Error('Command must be approved before execution.');
|
|
72
|
+
item.request.state = 'running';
|
|
73
|
+
return { ...item.request, policy: { ...item.request.policy } };
|
|
74
|
+
}
|
|
75
|
+
finishRunning(requestId, state, output, truncated, message, now = Date.now()) {
|
|
76
|
+
const item = this.pending.get(requestId);
|
|
77
|
+
if (!item)
|
|
78
|
+
throw new Error('Unknown command request.');
|
|
79
|
+
if (item.request.state !== 'running')
|
|
80
|
+
throw new Error('Command is not running.');
|
|
81
|
+
this.finish(item, state, message, now, output, truncated);
|
|
82
|
+
return item.result;
|
|
83
|
+
}
|
|
84
|
+
cancel(requestId, clientId, message = 'Cancelled by the user.', now = Date.now()) {
|
|
85
|
+
const item = this.owned(requestId, clientId);
|
|
86
|
+
this.finish(item, 'cancelled', message, now);
|
|
87
|
+
return item.result;
|
|
88
|
+
}
|
|
89
|
+
cancelFromUser(requestId, message = 'Cancelled by the user.', now = Date.now()) {
|
|
90
|
+
const item = this.pending.get(requestId);
|
|
91
|
+
if (!item)
|
|
92
|
+
throw new Error('Unknown command request.');
|
|
93
|
+
this.finish(item, 'cancelled', message, now);
|
|
94
|
+
return item.result;
|
|
95
|
+
}
|
|
96
|
+
revoke(clientId, now = Date.now()) {
|
|
97
|
+
for (const item of Array.from(this.pending.values()))
|
|
98
|
+
if (item.request.clientId === clientId && !item.result)
|
|
99
|
+
this.finish(item, 'cancelled', 'AI control was revoked.', now);
|
|
100
|
+
}
|
|
101
|
+
revokeAll(now = Date.now()) {
|
|
102
|
+
for (const item of Array.from(this.pending.values()))
|
|
103
|
+
if (!item.result)
|
|
104
|
+
this.finish(item, 'cancelled', 'AI control was revoked.', now);
|
|
105
|
+
}
|
|
106
|
+
list(clientId, now = Date.now()) {
|
|
107
|
+
return Array.from(this.pending.values()).filter((item) => item.request.clientId === clientId).map((item) => this.get(item.request.requestId, clientId, now));
|
|
108
|
+
}
|
|
109
|
+
listAll(now = Date.now()) {
|
|
110
|
+
return Array.from(this.pending.values()).map((item) => { this.expire(item, now); return item.result ? { ...item.result } : { ...item.request, policy: { ...item.request.policy } }; });
|
|
111
|
+
}
|
|
112
|
+
owned(requestId, clientId) {
|
|
113
|
+
const item = this.pending.get(requestId);
|
|
114
|
+
if (!item || item.request.clientId !== clientId)
|
|
115
|
+
throw new Error('Unknown command request.');
|
|
116
|
+
return item;
|
|
117
|
+
}
|
|
118
|
+
expire(item, now) {
|
|
119
|
+
if (!item.result && item.request.state === 'pending' && item.request.expiresAt <= now)
|
|
120
|
+
this.finish(item, 'timed-out', 'Approval expired.', now);
|
|
121
|
+
}
|
|
122
|
+
finish(item, state, message, now, output, truncated = false) {
|
|
123
|
+
item.request.state = state;
|
|
124
|
+
item.result = { requestId: item.request.requestId, state, ...(output ? { output } : {}), ...(truncated ? { truncated: true } : {}), message, completedAt: now };
|
|
125
|
+
}
|
|
126
|
+
}
|