zerogterm 0.8.0-alpha.1 → 0.9.1-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/README.md +55 -1
- package/dist/main/main/main.js +247 -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/renderer/assets/index-CknqVqkF.js +142 -0
- package/dist/renderer/assets/index-axEdZFb8.css +1 -0
- package/dist/renderer/index.html +2 -2
- package/package.json +11 -6
- package/dist/renderer/assets/index-CiMNrFEg.js +0 -142
- package/dist/renderer/assets/index-p0gJDyuE.css +0 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function isMcpCapability(value) {
|
|
2
|
+
return value === 'workspace:read' || value === 'workspace:restore' || value === 'workspace:write' ||
|
|
3
|
+
value === 'session:read' || value === 'session:create' || value === 'session:close' || value === 'session:execute';
|
|
4
|
+
}
|
|
5
|
+
export function requireMcpCapability(lease, capability, now = Date.now()) {
|
|
6
|
+
if (!lease)
|
|
7
|
+
throw new Error('No AI control lease is active.');
|
|
8
|
+
if (lease.expiresAt <= now)
|
|
9
|
+
throw new Error('The AI control lease has expired.');
|
|
10
|
+
if (!lease.capabilities.includes(capability))
|
|
11
|
+
throw new Error(`The AI connection lacks ${capability} capability.`);
|
|
12
|
+
}
|
|
13
|
+
export function requireBoundedString(value, field, max = 256) {
|
|
14
|
+
if (typeof value !== 'string' || !value.trim())
|
|
15
|
+
throw new Error(`${field} is required.`);
|
|
16
|
+
const clean = value.trim();
|
|
17
|
+
if (clean.length > max)
|
|
18
|
+
throw new Error(`${field} is too long.`);
|
|
19
|
+
if (/[\u0000-\u001f\u007f]/.test(clean))
|
|
20
|
+
throw new Error(`${field} contains control characters.`);
|
|
21
|
+
return clean;
|
|
22
|
+
}
|
|
23
|
+
export function requireWorkspaceId(value) {
|
|
24
|
+
return requireBoundedString(value, 'workspaceId', 64);
|
|
25
|
+
}
|
|
26
|
+
export function requireSessionId(value) {
|
|
27
|
+
return requireBoundedString(value, 'sessionId', 128);
|
|
28
|
+
}
|
|
29
|
+
export function requireWorkspaceName(value) {
|
|
30
|
+
const name = requireBoundedString(value, 'name', 49);
|
|
31
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9_.]|-| ){0,48}$/.test(name)) {
|
|
32
|
+
throw new Error('Workspace name contains unsupported characters.');
|
|
33
|
+
}
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
export function requireStoredWorkspaceFile(value) {
|
|
37
|
+
if (!value || typeof value !== 'object')
|
|
38
|
+
throw new Error('A workspace file is required.');
|
|
39
|
+
const file = value;
|
|
40
|
+
if (file.version !== 1 || !Array.isArray(file.workspaces))
|
|
41
|
+
throw new Error('Invalid workspace file.');
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
export function boundedDeadline(value, defaultMs = 10_000, maxMs = 60_000) {
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
return defaultMs;
|
|
47
|
+
if (!Number.isInteger(value) || value < 100 || value > maxMs) {
|
|
48
|
+
throw new Error(`deadlineMs must be between 100 and ${maxMs}.`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { McpControl } from './mcp-control.js';
|
|
7
|
+
import { requireWorkspaceId } from './mcp-protocol.js';
|
|
8
|
+
const MAX_BODY_BYTES = 128 * 1024;
|
|
9
|
+
export const DEFAULT_MCP_PORT = 60056;
|
|
10
|
+
const DEFAULT_CAPABILITIES = ['workspace:read', 'session:read'];
|
|
11
|
+
const ALL_CAPABILITIES = [
|
|
12
|
+
'workspace:read', 'workspace:restore', 'workspace:write',
|
|
13
|
+
'session:read', 'session:create', 'session:close', 'session:execute'
|
|
14
|
+
];
|
|
15
|
+
/** Local authenticated MCP endpoint owned by the running ZeroG instance. */
|
|
16
|
+
export class McpServerHost {
|
|
17
|
+
providers;
|
|
18
|
+
control;
|
|
19
|
+
version;
|
|
20
|
+
configuredPort;
|
|
21
|
+
token;
|
|
22
|
+
http;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.providers = options.providers;
|
|
25
|
+
this.control = options.control ?? new McpControl();
|
|
26
|
+
this.version = options.version ?? '0.0.0';
|
|
27
|
+
this.configuredPort = options.port ?? DEFAULT_MCP_PORT;
|
|
28
|
+
this.token = options.token ?? process.env.MCP_BEARER_TOKEN ?? randomBytes(32).toString('base64url');
|
|
29
|
+
}
|
|
30
|
+
status() {
|
|
31
|
+
return this.control.status();
|
|
32
|
+
}
|
|
33
|
+
async start() {
|
|
34
|
+
if (this.http)
|
|
35
|
+
return { endpoint: this.endpoint(), token: this.token };
|
|
36
|
+
this.http = createServer((req, res) => { void this.handle(req, res); });
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
this.http.once('error', reject);
|
|
39
|
+
this.http.listen(this.configuredPort, '127.0.0.1', () => {
|
|
40
|
+
this.http.off('error', reject);
|
|
41
|
+
resolve();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
const address = this.http.address();
|
|
45
|
+
const port = typeof address === 'object' && address ? address.port : this.configuredPort;
|
|
46
|
+
this.control.enable(`http://127.0.0.1:${port}/mcp`);
|
|
47
|
+
return { endpoint: this.endpoint(), token: this.token };
|
|
48
|
+
}
|
|
49
|
+
async stop() {
|
|
50
|
+
this.control.disable();
|
|
51
|
+
if (this.http) {
|
|
52
|
+
await new Promise((resolve) => this.http.close(() => resolve()));
|
|
53
|
+
this.http = undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
endpoint() {
|
|
57
|
+
const address = this.http?.address();
|
|
58
|
+
const port = typeof address === 'object' && address ? address.port : this.configuredPort;
|
|
59
|
+
return `http://127.0.0.1:${port}/mcp`;
|
|
60
|
+
}
|
|
61
|
+
async handle(req, res) {
|
|
62
|
+
if (req.url !== '/mcp')
|
|
63
|
+
return respond(res, 404, { error: 'Not found' });
|
|
64
|
+
if (!this.authorized(req))
|
|
65
|
+
return respond(res, 401, { error: 'Authorization required' }, { 'WWW-Authenticate': 'Bearer' });
|
|
66
|
+
if (req.method !== 'POST' && req.method !== 'GET' && req.method !== 'DELETE') {
|
|
67
|
+
return respond(res, 405, { error: 'Method not allowed' }, { Allow: 'GET, POST, DELETE' });
|
|
68
|
+
}
|
|
69
|
+
if (req.method === 'POST') {
|
|
70
|
+
const body = await readJson(req);
|
|
71
|
+
if (body.error)
|
|
72
|
+
return respond(res, body.status, { error: body.error });
|
|
73
|
+
// Stateless transport: each HTTP request gets an independent protocol
|
|
74
|
+
// wrapper, so one client cannot wedge the desktop-wide MCP endpoint.
|
|
75
|
+
const mcp = this.buildMcpServer();
|
|
76
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
77
|
+
await mcp.connect(transport);
|
|
78
|
+
await transport.handleRequest(req, res, body.value);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
respond(res, 400, { error: 'This stateless endpoint accepts POST requests only.' });
|
|
82
|
+
}
|
|
83
|
+
authorized(req) {
|
|
84
|
+
const value = req.headers.authorization;
|
|
85
|
+
if (!value?.startsWith('Bearer '))
|
|
86
|
+
return false;
|
|
87
|
+
const received = Buffer.from(value.slice(7));
|
|
88
|
+
const expected = Buffer.from(this.token);
|
|
89
|
+
return received.length === expected.length && timingSafeEqual(received, expected);
|
|
90
|
+
}
|
|
91
|
+
buildMcpServer() {
|
|
92
|
+
const server = new McpServer({ name: 'zerogterm', version: this.version });
|
|
93
|
+
server.registerTool('zerog_get_connection_status', {
|
|
94
|
+
description: 'Return ZeroG MCP connection and AI lease state.',
|
|
95
|
+
inputSchema: z.object({})
|
|
96
|
+
}, async () => text(this.control.status()));
|
|
97
|
+
server.registerTool('zerog_list_workspaces', {
|
|
98
|
+
description: 'List saved ZeroG workspaces and pane metadata.',
|
|
99
|
+
inputSchema: z.object({ clientId: z.string().min(1) })
|
|
100
|
+
}, async ({ clientId }) => {
|
|
101
|
+
this.control.require(clientId, 'workspace:read');
|
|
102
|
+
return text(await this.providers.listWorkspaces());
|
|
103
|
+
});
|
|
104
|
+
server.registerTool('zerog_list_sessions', {
|
|
105
|
+
description: 'List ZeroG terminal sessions without terminal output or credentials.',
|
|
106
|
+
inputSchema: z.object({ clientId: z.string().min(1) })
|
|
107
|
+
}, async ({ clientId }) => {
|
|
108
|
+
this.control.require(clientId, 'session:read');
|
|
109
|
+
return text(await this.providers.listSessions());
|
|
110
|
+
});
|
|
111
|
+
server.registerTool('zerog_restore_workspace', {
|
|
112
|
+
description: 'Restore a saved workspace and recreate only missing sessions. SSH authentication remains user-controlled.',
|
|
113
|
+
inputSchema: z.object({ clientId: z.string().min(1), workspaceId: z.string().min(1).max(64) })
|
|
114
|
+
}, async ({ clientId, workspaceId }) => {
|
|
115
|
+
this.control.require(clientId, 'workspace:restore');
|
|
116
|
+
if (!this.providers.restoreWorkspace)
|
|
117
|
+
throw new Error('Workspace restoration is not available.');
|
|
118
|
+
return text(await this.providers.restoreWorkspace(requireWorkspaceId(workspaceId)));
|
|
119
|
+
});
|
|
120
|
+
server.registerTool('zerog_create_workspace', {
|
|
121
|
+
description: 'Create and select a new blank workspace using the next workspace-N name.',
|
|
122
|
+
inputSchema: z.object({ clientId: z.string().min(1) })
|
|
123
|
+
}, async ({ clientId }) => {
|
|
124
|
+
this.control.require(clientId, 'workspace:write');
|
|
125
|
+
if (!this.providers.createWorkspace)
|
|
126
|
+
throw new Error('Workspace creation is not available.');
|
|
127
|
+
return text(await this.providers.createWorkspace(''));
|
|
128
|
+
});
|
|
129
|
+
server.registerTool('zerog_create_project_workspace', {
|
|
130
|
+
description: 'Create or replace a workspace containing local project panes. Does not execute commands.',
|
|
131
|
+
inputSchema: z.object({
|
|
132
|
+
clientId: z.string().min(1),
|
|
133
|
+
name: z.string().min(1).max(49),
|
|
134
|
+
projects: z.array(z.object({ name: z.string().min(1).max(64), cwd: z.string().min(1).max(512), backend: z.string().max(32).optional() })).min(1).max(4)
|
|
135
|
+
})
|
|
136
|
+
}, async ({ clientId, name, projects }) => {
|
|
137
|
+
this.control.require(clientId, 'workspace:write');
|
|
138
|
+
this.control.require(clientId, 'session:create');
|
|
139
|
+
if (!this.providers.createProjectWorkspace)
|
|
140
|
+
throw new Error('Project workspace creation is not available.');
|
|
141
|
+
return text(await this.providers.createProjectWorkspace({ name, projects }));
|
|
142
|
+
});
|
|
143
|
+
server.registerTool('zerog_create_ssh_session', {
|
|
144
|
+
description: 'Open a new SSH session in the active ZeroG workspace. Authentication and host-key prompts remain user-controlled.',
|
|
145
|
+
inputSchema: z.object({
|
|
146
|
+
clientId: z.string().min(1),
|
|
147
|
+
target: z.string().min(1).max(512),
|
|
148
|
+
name: z.string().min(1).max(128).optional()
|
|
149
|
+
})
|
|
150
|
+
}, async ({ clientId, target, name }) => {
|
|
151
|
+
this.control.require(clientId, 'session:create');
|
|
152
|
+
if (!this.providers.createSshSession)
|
|
153
|
+
throw new Error('SSH session creation is not available.');
|
|
154
|
+
return text(await this.providers.createSshSession({ target, ...(name === undefined ? {} : { name }) }));
|
|
155
|
+
});
|
|
156
|
+
server.registerTool('zerog_request_command', {
|
|
157
|
+
description: 'Request a local command for explicit user approval. This never executes the command by itself.',
|
|
158
|
+
inputSchema: z.object({ clientId: z.string().min(1), sessionId: z.string().min(1).max(128), command: z.string().min(1).max(512), timeoutMs: z.number().int().min(100).max(30000).optional(), outputBytes: z.number().int().min(256).max(16384).optional() })
|
|
159
|
+
}, async ({ clientId, sessionId, command, timeoutMs, outputBytes }) => {
|
|
160
|
+
this.control.require(clientId, 'session:execute');
|
|
161
|
+
if (!this.providers.requestCommand)
|
|
162
|
+
throw new Error('Command requests are not available.');
|
|
163
|
+
return text(await this.providers.requestCommand({ clientId, sessionId, command, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...(outputBytes === undefined ? {} : { outputBytes }) }));
|
|
164
|
+
});
|
|
165
|
+
server.registerTool('zerog_get_command_result', {
|
|
166
|
+
description: 'Read the state of a command request owned by this client.',
|
|
167
|
+
inputSchema: z.object({ clientId: z.string().min(1), requestId: z.string().min(1).max(128) })
|
|
168
|
+
}, async ({ clientId, requestId }) => {
|
|
169
|
+
this.control.require(clientId, 'session:execute');
|
|
170
|
+
if (!this.providers.getCommandResult)
|
|
171
|
+
throw new Error('Command requests are not available.');
|
|
172
|
+
return text(await this.providers.getCommandResult({ clientId, requestId }));
|
|
173
|
+
});
|
|
174
|
+
server.registerTool('zerog_cancel_command', {
|
|
175
|
+
description: 'Cancel a pending command request owned by this client.',
|
|
176
|
+
inputSchema: z.object({ clientId: z.string().min(1), requestId: z.string().min(1).max(128) })
|
|
177
|
+
}, async ({ clientId, requestId }) => {
|
|
178
|
+
this.control.require(clientId, 'session:execute');
|
|
179
|
+
if (!this.providers.cancelCommand)
|
|
180
|
+
throw new Error('Command requests are not available.');
|
|
181
|
+
return text(await this.providers.cancelCommand({ clientId, requestId }));
|
|
182
|
+
});
|
|
183
|
+
server.registerTool('zerog_acquire_control', {
|
|
184
|
+
description: 'Acquire the single AI control lease for this local ZeroG instance.',
|
|
185
|
+
inputSchema: z.object({ clientId: z.string().min(1).max(128), clientName: z.string().max(128).optional(), capabilities: z.array(z.enum(ALL_CAPABILITIES)).optional() })
|
|
186
|
+
}, async ({ clientId, clientName, capabilities }) => text(this.control.connect(clientId, clientName, (capabilities ?? DEFAULT_CAPABILITIES))));
|
|
187
|
+
server.registerTool('zerog_renew_control', {
|
|
188
|
+
description: 'Renew the current AI control lease.',
|
|
189
|
+
inputSchema: z.object({ clientId: z.string().min(1) })
|
|
190
|
+
}, async ({ clientId }) => text(this.control.renew(clientId)));
|
|
191
|
+
server.registerTool('zerog_upgrade_control', {
|
|
192
|
+
description: 'Add capabilities to the current AI control lease without taking control from another client.',
|
|
193
|
+
inputSchema: z.object({ clientId: z.string().min(1), capabilities: z.array(z.enum(ALL_CAPABILITIES)).min(1) })
|
|
194
|
+
}, async ({ clientId, capabilities }) => text(this.control.upgrade(clientId, capabilities)));
|
|
195
|
+
return server;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function text(value) {
|
|
199
|
+
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
|
200
|
+
}
|
|
201
|
+
function respond(res, status, body, headers = {}) {
|
|
202
|
+
res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
|
203
|
+
res.end(JSON.stringify(body));
|
|
204
|
+
}
|
|
205
|
+
async function readJson(req) {
|
|
206
|
+
let size = 0;
|
|
207
|
+
const chunks = [];
|
|
208
|
+
for await (const chunk of req) {
|
|
209
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
210
|
+
size += data.length;
|
|
211
|
+
if (size > MAX_BODY_BYTES)
|
|
212
|
+
return { error: 'Request body is too large.', status: 413 };
|
|
213
|
+
chunks.push(data);
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
return { value: JSON.parse(Buffer.concat(chunks).toString('utf8')), status: 200 };
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return { error: 'Request body must be valid JSON.', status: 400 };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -21,6 +21,34 @@ const api = {
|
|
|
21
21
|
},
|
|
22
22
|
loadWorkspaces: () => ipcRenderer.invoke('workspaces:load'),
|
|
23
23
|
saveWorkspaces: (file) => ipcRenderer.invoke('workspaces:save', file),
|
|
24
|
+
mcpStatus: () => ipcRenderer.invoke('mcp:status'),
|
|
25
|
+
revokeMcpControl: () => ipcRenderer.invoke('mcp:revoke'),
|
|
26
|
+
startMcp: () => ipcRenderer.invoke('mcp:start'),
|
|
27
|
+
stopMcp: () => ipcRenderer.invoke('mcp:stop'),
|
|
28
|
+
onMcpStatus: (callback) => {
|
|
29
|
+
const listener = (_event, payload) => callback(payload);
|
|
30
|
+
ipcRenderer.on('mcp:status', listener);
|
|
31
|
+
return () => ipcRenderer.removeListener('mcp:status', listener);
|
|
32
|
+
},
|
|
33
|
+
onMcpWorkspaceRestored: (callback) => {
|
|
34
|
+
const listener = (_event, payload) => callback(payload);
|
|
35
|
+
ipcRenderer.on('mcp:workspace-restored', listener);
|
|
36
|
+
return () => ipcRenderer.removeListener('mcp:workspace-restored', listener);
|
|
37
|
+
},
|
|
38
|
+
onMcpSshSessionCreated: (callback) => {
|
|
39
|
+
const listener = (_event, payload) => callback(payload);
|
|
40
|
+
ipcRenderer.on('mcp:ssh-session-created', listener);
|
|
41
|
+
return () => ipcRenderer.removeListener('mcp:ssh-session-created', listener);
|
|
42
|
+
},
|
|
43
|
+
listMcpExecutions: () => ipcRenderer.invoke('mcp:executions:list'),
|
|
44
|
+
approveMcpExecution: (requestId) => ipcRenderer.invoke('mcp:execution:approve', requestId),
|
|
45
|
+
rejectMcpExecution: (requestId) => ipcRenderer.invoke('mcp:execution:reject', requestId),
|
|
46
|
+
cancelMcpExecution: (requestId) => ipcRenderer.invoke('mcp:execution:cancel', requestId),
|
|
47
|
+
onMcpExecution: (callback) => {
|
|
48
|
+
const listener = (_event, payload) => callback(payload);
|
|
49
|
+
ipcRenderer.on('mcp:execution', listener);
|
|
50
|
+
return () => ipcRenderer.removeListener('mcp:execution', listener);
|
|
51
|
+
},
|
|
24
52
|
listBackends: () => ipcRenderer.invoke('sessions:backends'),
|
|
25
53
|
listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
|
|
26
54
|
createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const PASSWORD = /password|passwd|login\s*:/i;
|
|
2
|
+
const PASSPHRASE = /passphrase|private\s+key/i;
|
|
3
|
+
const CODE = /verification|one[- ]time|security\s+code|otp|authenticator/i;
|
|
4
|
+
const HOST_KEY = /are you sure you want to continue connecting|authenticity of host|fingerprint|host key/i;
|
|
5
|
+
const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
6
|
+
/** Classify untrusted terminal text without ever accepting an answer. */
|
|
7
|
+
export function classifyMcpPrompt(raw) {
|
|
8
|
+
const text = raw.replace(ANSI, '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').slice(0, 512);
|
|
9
|
+
const kind = PASSPHRASE.test(text) ? 'passphrase'
|
|
10
|
+
: CODE.test(text) ? 'verification-code'
|
|
11
|
+
: HOST_KEY.test(text) ? 'host-key'
|
|
12
|
+
: PASSWORD.test(text) ? 'password' : 'unknown';
|
|
13
|
+
return { kind, text, answerableByMcp: false };
|
|
14
|
+
}
|
|
@@ -96,6 +96,7 @@ function normalizeMember(value) {
|
|
|
96
96
|
const screenName = optionalText(item.screenName, 64);
|
|
97
97
|
const sshTarget = optionalText(item.sshTarget);
|
|
98
98
|
const backend = optionalText(item.backend, 32);
|
|
99
|
+
const cwd = optionalText(item.cwd, 512);
|
|
99
100
|
if (host)
|
|
100
101
|
member.host = host;
|
|
101
102
|
if (screenName)
|
|
@@ -104,6 +105,8 @@ function normalizeMember(value) {
|
|
|
104
105
|
member.sshTarget = sshTarget;
|
|
105
106
|
if (backend)
|
|
106
107
|
member.backend = backend;
|
|
108
|
+
if (cwd)
|
|
109
|
+
member.cwd = cwd;
|
|
107
110
|
return member;
|
|
108
111
|
}
|
|
109
112
|
function normalizeView(value, memberIds) {
|