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 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.8.0-alpha`; 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
+ 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
 
@@ -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) => win?.webContents.send('terminal:data', id, data), (message) => win?.webContents.send('terminal:status', id, message), parsePtySize(size));
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
+ }