easy-local-mcp 0.3.9
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 -0
- package/README.md +417 -0
- package/chatgpt_plugin.png +0 -0
- package/chatgpt_setting.png +0 -0
- package/dist/agent.js +498 -0
- package/dist/command.js +30 -0
- package/dist/config-watch.js +35 -0
- package/dist/config.js +83 -0
- package/dist/control-endpoint.js +13 -0
- package/dist/control-ui.js +1025 -0
- package/dist/desktop.js +133 -0
- package/dist/index.js +339 -0
- package/dist/lifecycle.js +321 -0
- package/dist/mcp/loader.js +86 -0
- package/dist/process.js +122 -0
- package/dist/relay-config.js +145 -0
- package/dist/relay-protocol.js +34 -0
- package/dist/relay.js +16 -0
- package/dist/security.js +238 -0
- package/dist/server.js +253 -0
- package/dist/skills/loader.js +24 -0
- package/dist/tray.js +74 -0
- package/dist/workspace.js +282 -0
- package/easy-local-mcp.png +0 -0
- package/easy-local-mcp.svg +56 -0
- package/localmcp.example.json +21 -0
- package/package.json +90 -0
- package/scripts/prepare-desktop-bundle.mjs +81 -0
- package/scripts/run-cargo.mjs +35 -0
- package/scripts/run-tauri.mjs +33 -0
- package/scripts/worker-setup.mjs +20 -0
- package/skills/computer-use/SKILL.md +20 -0
- package/skills/computer-use/skill.json +5 -0
- package/skills/local-development/SKILL.md +73 -0
- package/src/relay-protocol.ts +29 -0
- package/worker/index.ts +670 -0
- package/worker/tsconfig.json +1 -0
- package/wrangler.jsonc +10 -0
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { open, readFile } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { config, configFilePath } from './config.js';
|
|
6
|
+
import { auditFile, auditSecurity, secureWriteFileAtomic } from './security.js';
|
|
7
|
+
import { control, maskMcpUrl, request, status } from './lifecycle.js';
|
|
8
|
+
import { DEFAULT_PUBLIC_WORKER_URL, validatedWorkerOrigin } from './relay.js';
|
|
9
|
+
import { relaySetupState, savePendingRegistrationToken, saveRelayPreference } from './relay-config.js';
|
|
10
|
+
const UI_HOST = '127.0.0.1';
|
|
11
|
+
const SESSION_COOKIE = 'localmcp_ui_session';
|
|
12
|
+
const SESSION_TTL_MS = 30 * 60_000;
|
|
13
|
+
const MAX_BODY_BYTES = 32 * 1024;
|
|
14
|
+
const MAX_AUDIT_BYTES = 256 * 1024;
|
|
15
|
+
const SAFE_AUDIT_FIELDS = new Set([
|
|
16
|
+
'timestamp', 'event', 'tool', 'workspace', 'result', 'durationMs', 'reason', 'error',
|
|
17
|
+
'deviceId', 'publicRelay', 'pid', 'code', 'minutes', 'expiresAt', 'externalServer',
|
|
18
|
+
'externalTool', 'changed'
|
|
19
|
+
]);
|
|
20
|
+
function securityHeaders(res) {
|
|
21
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
22
|
+
res.setHeader('Content-Security-Policy', [
|
|
23
|
+
"default-src 'self'",
|
|
24
|
+
"script-src 'unsafe-inline'",
|
|
25
|
+
"style-src 'unsafe-inline'",
|
|
26
|
+
"img-src 'self' data:",
|
|
27
|
+
"connect-src 'self'",
|
|
28
|
+
"base-uri 'none'",
|
|
29
|
+
"form-action 'self'",
|
|
30
|
+
"frame-ancestors 'none'"
|
|
31
|
+
].join('; '));
|
|
32
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
33
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
34
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
35
|
+
}
|
|
36
|
+
function json(res, statusCode, value) {
|
|
37
|
+
securityHeaders(res);
|
|
38
|
+
res.statusCode = statusCode;
|
|
39
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
40
|
+
res.end(JSON.stringify(value));
|
|
41
|
+
}
|
|
42
|
+
function text(res, statusCode, value, contentType = 'text/plain; charset=utf-8') {
|
|
43
|
+
securityHeaders(res);
|
|
44
|
+
res.statusCode = statusCode;
|
|
45
|
+
res.setHeader('Content-Type', contentType);
|
|
46
|
+
res.end(value);
|
|
47
|
+
}
|
|
48
|
+
function isLoopback(address) {
|
|
49
|
+
return address === '127.0.0.1'
|
|
50
|
+
|| address === '::1'
|
|
51
|
+
|| address === '::ffff:127.0.0.1';
|
|
52
|
+
}
|
|
53
|
+
function parseCookies(value) {
|
|
54
|
+
const result = {};
|
|
55
|
+
for (const item of (value || '').split(';')) {
|
|
56
|
+
const index = item.indexOf('=');
|
|
57
|
+
if (index <= 0)
|
|
58
|
+
continue;
|
|
59
|
+
const key = item.slice(0, index).trim();
|
|
60
|
+
const cookieValue = item.slice(index + 1).trim();
|
|
61
|
+
if (key)
|
|
62
|
+
result[key] = cookieValue;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
async function readJsonBody(req) {
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
const chunks = [];
|
|
69
|
+
for await (const chunk of req) {
|
|
70
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
71
|
+
bytes += value.length;
|
|
72
|
+
if (bytes > MAX_BODY_BYTES)
|
|
73
|
+
throw new Error('Request body is too large');
|
|
74
|
+
chunks.push(value);
|
|
75
|
+
}
|
|
76
|
+
if (!chunks.length)
|
|
77
|
+
return {};
|
|
78
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
79
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
80
|
+
throw new Error('JSON body must be an object');
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
async function readRawConfig() {
|
|
85
|
+
const path = configFilePath();
|
|
86
|
+
const content = await readFile(path, 'utf8');
|
|
87
|
+
const raw = JSON.parse(content);
|
|
88
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
89
|
+
throw new Error('Easy Local MCP configuration must be a JSON object');
|
|
90
|
+
}
|
|
91
|
+
return { path, raw: raw };
|
|
92
|
+
}
|
|
93
|
+
function configuredFeatures(raw, normalized) {
|
|
94
|
+
const features = raw.features && typeof raw.features === 'object' && !Array.isArray(raw.features)
|
|
95
|
+
? raw.features
|
|
96
|
+
: {};
|
|
97
|
+
const files = features.files;
|
|
98
|
+
const fileRead = typeof files === 'boolean'
|
|
99
|
+
? files
|
|
100
|
+
: files && typeof files === 'object' && !Array.isArray(files)
|
|
101
|
+
? files.read === true
|
|
102
|
+
: normalized.fileRead;
|
|
103
|
+
const fileWrite = typeof files === 'boolean'
|
|
104
|
+
? files
|
|
105
|
+
: files && typeof files === 'object' && !Array.isArray(files)
|
|
106
|
+
? files.write === true
|
|
107
|
+
: normalized.fileWrite;
|
|
108
|
+
const fileDelete = typeof files === 'boolean'
|
|
109
|
+
? files
|
|
110
|
+
: files && typeof files === 'object' && !Array.isArray(files)
|
|
111
|
+
? files.delete === true
|
|
112
|
+
: normalized.fileDelete;
|
|
113
|
+
return {
|
|
114
|
+
fileRead,
|
|
115
|
+
fileWrite,
|
|
116
|
+
fileDelete,
|
|
117
|
+
shell: typeof features.shell === 'boolean' ? features.shell : normalized.shell,
|
|
118
|
+
processes: typeof features.processes === 'boolean' ? features.processes : normalized.processes,
|
|
119
|
+
externalMcp: typeof features.externalMcp === 'boolean'
|
|
120
|
+
? features.externalMcp
|
|
121
|
+
: normalized.externalMcp
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function configView() {
|
|
125
|
+
const { path, raw } = await readRawConfig();
|
|
126
|
+
const normalized = await config({
|
|
127
|
+
content: JSON.stringify(raw),
|
|
128
|
+
path
|
|
129
|
+
});
|
|
130
|
+
const configured = configuredFeatures(raw, normalized);
|
|
131
|
+
return {
|
|
132
|
+
path,
|
|
133
|
+
defaultWorkspace: normalized.defaultWorkspace,
|
|
134
|
+
workspaces: Object.entries(normalized.workspaces).map(([name, root]) => ({
|
|
135
|
+
name,
|
|
136
|
+
root,
|
|
137
|
+
default: name === normalized.defaultWorkspace
|
|
138
|
+
})),
|
|
139
|
+
features: configured,
|
|
140
|
+
effectiveFeatures: {
|
|
141
|
+
fileRead: normalized.fileRead,
|
|
142
|
+
fileWrite: normalized.fileWrite,
|
|
143
|
+
fileDelete: normalized.fileDelete,
|
|
144
|
+
shell: normalized.shell,
|
|
145
|
+
processes: normalized.processes,
|
|
146
|
+
externalMcp: normalized.externalMcp
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function workerOrigin(url) {
|
|
151
|
+
if (!url)
|
|
152
|
+
return null;
|
|
153
|
+
try {
|
|
154
|
+
return new URL(url).origin;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function capabilityAvailability(enabled, privileged, current) {
|
|
161
|
+
if (!enabled)
|
|
162
|
+
return 'disabled';
|
|
163
|
+
if (current.status !== 'running')
|
|
164
|
+
return 'agent-stopped';
|
|
165
|
+
if (!current.ready)
|
|
166
|
+
return 'connecting';
|
|
167
|
+
if (privileged && current.locked)
|
|
168
|
+
return 'locked';
|
|
169
|
+
return 'available';
|
|
170
|
+
}
|
|
171
|
+
async function statusView() {
|
|
172
|
+
const current = await status();
|
|
173
|
+
const relay = await relaySetupState();
|
|
174
|
+
const configuration = await configView();
|
|
175
|
+
const effective = configuration.effectiveFeatures;
|
|
176
|
+
const capabilityMeta = [
|
|
177
|
+
{ key: 'fileRead', privileged: false },
|
|
178
|
+
{ key: 'fileWrite', privileged: true },
|
|
179
|
+
{ key: 'fileDelete', privileged: true },
|
|
180
|
+
{ key: 'shell', privileged: true },
|
|
181
|
+
{ key: 'processes', privileged: true },
|
|
182
|
+
{ key: 'externalMcp', privileged: true }
|
|
183
|
+
];
|
|
184
|
+
const activeWorkerUrl = current.workerUrl ?? workerOrigin(current.url);
|
|
185
|
+
const relayConfigured = current.status === 'running' || relay.configured;
|
|
186
|
+
const configuredWorkerUrl = activeWorkerUrl ?? relay.workerUrl;
|
|
187
|
+
const suggestedWorkerUrl = configuredWorkerUrl ?? relay.suggestedWorkerUrl;
|
|
188
|
+
return {
|
|
189
|
+
agent: {
|
|
190
|
+
status: current.status,
|
|
191
|
+
pid: current.pid,
|
|
192
|
+
ready: current.ready,
|
|
193
|
+
locked: current.locked,
|
|
194
|
+
unlockExpiresAt: current.unlockExpiresAt,
|
|
195
|
+
log: current.log
|
|
196
|
+
},
|
|
197
|
+
connection: {
|
|
198
|
+
state: current.status === 'running' ? (current.ready ? 'connected' : 'connecting') : 'stopped',
|
|
199
|
+
configured: relayConfigured,
|
|
200
|
+
needsSetup: !relayConfigured,
|
|
201
|
+
workerUrl: configuredWorkerUrl,
|
|
202
|
+
suggestedWorkerUrl,
|
|
203
|
+
relaySource: relay.source,
|
|
204
|
+
deviceId: current.deviceId,
|
|
205
|
+
workerManagedByEnv: relay.managedByEnv || current.workerManagedByEnv,
|
|
206
|
+
registrationTokenManagedByEnv: relay.registrationTokenManagedByEnv,
|
|
207
|
+
publicRelay: suggestedWorkerUrl === validatedWorkerOrigin(DEFAULT_PUBLIC_WORKER_URL).href,
|
|
208
|
+
mcpUrlMasked: maskMcpUrl(current.url)
|
|
209
|
+
},
|
|
210
|
+
capabilities: capabilityMeta.map(({ key, privileged }) => ({
|
|
211
|
+
key,
|
|
212
|
+
configured: configuration.features[key],
|
|
213
|
+
effective: effective[key],
|
|
214
|
+
privileged,
|
|
215
|
+
availability: capabilityAvailability(effective[key], privileged, current)
|
|
216
|
+
})),
|
|
217
|
+
configuration
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function parseFeatureUpdate(body) {
|
|
221
|
+
const source = body.features;
|
|
222
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
|
223
|
+
throw new Error('features is required');
|
|
224
|
+
}
|
|
225
|
+
const features = source;
|
|
226
|
+
const keys = [
|
|
227
|
+
'fileRead', 'fileWrite', 'fileDelete', 'shell', 'processes', 'externalMcp'
|
|
228
|
+
];
|
|
229
|
+
for (const key of keys) {
|
|
230
|
+
if (typeof features[key] !== 'boolean') {
|
|
231
|
+
throw new Error(`features.${key} must be boolean`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (features.processes === true && features.shell !== true) {
|
|
235
|
+
throw new Error('processes requires shell to be enabled');
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
fileRead: features.fileRead,
|
|
239
|
+
fileWrite: features.fileWrite,
|
|
240
|
+
fileDelete: features.fileDelete,
|
|
241
|
+
shell: features.shell,
|
|
242
|
+
processes: features.processes,
|
|
243
|
+
externalMcp: features.externalMcp
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async function updateConfiguration(body) {
|
|
247
|
+
const nextFeatures = parseFeatureUpdate(body);
|
|
248
|
+
const { path, raw } = await readRawConfig();
|
|
249
|
+
const normalizedBefore = await config({
|
|
250
|
+
content: JSON.stringify(raw),
|
|
251
|
+
path
|
|
252
|
+
});
|
|
253
|
+
const previous = configuredFeatures(raw, normalizedBefore);
|
|
254
|
+
const dangerousKeys = [
|
|
255
|
+
'fileWrite', 'fileDelete', 'shell', 'processes', 'externalMcp'
|
|
256
|
+
];
|
|
257
|
+
const enabling = dangerousKeys.filter(key => !previous[key] && nextFeatures[key]);
|
|
258
|
+
if (enabling.length && body.confirmDangerous !== true) {
|
|
259
|
+
throw new Error('Explicit confirmation is required before enabling privileged capabilities');
|
|
260
|
+
}
|
|
261
|
+
const previousFeatures = raw.features && typeof raw.features === 'object' && !Array.isArray(raw.features)
|
|
262
|
+
? raw.features
|
|
263
|
+
: {};
|
|
264
|
+
const previousFiles = previousFeatures.files
|
|
265
|
+
&& typeof previousFeatures.files === 'object'
|
|
266
|
+
&& !Array.isArray(previousFeatures.files)
|
|
267
|
+
? previousFeatures.files
|
|
268
|
+
: {};
|
|
269
|
+
const nextRaw = {
|
|
270
|
+
...raw,
|
|
271
|
+
features: {
|
|
272
|
+
...previousFeatures,
|
|
273
|
+
files: {
|
|
274
|
+
...previousFiles,
|
|
275
|
+
read: nextFeatures.fileRead,
|
|
276
|
+
write: nextFeatures.fileWrite,
|
|
277
|
+
delete: nextFeatures.fileDelete
|
|
278
|
+
},
|
|
279
|
+
shell: nextFeatures.shell,
|
|
280
|
+
processes: nextFeatures.processes,
|
|
281
|
+
externalMcp: nextFeatures.externalMcp
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const content = JSON.stringify(nextRaw, null, 2) + '\n';
|
|
285
|
+
await config({ content, path });
|
|
286
|
+
const changed = Object.keys(nextFeatures)
|
|
287
|
+
.filter(key => previous[key] !== nextFeatures[key]);
|
|
288
|
+
await secureWriteFileAtomic(path, content);
|
|
289
|
+
await auditSecurity('config_update', { changed: changed.join(',') });
|
|
290
|
+
const current = await status();
|
|
291
|
+
let reloaded = false;
|
|
292
|
+
if (current.status === 'running') {
|
|
293
|
+
await request('reload');
|
|
294
|
+
reloaded = true;
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
saved: true,
|
|
298
|
+
reloaded,
|
|
299
|
+
changed,
|
|
300
|
+
configuration: await configView()
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
async function updateWorkspaces(body) {
|
|
304
|
+
if (body.confirm !== true) {
|
|
305
|
+
throw new Error('Workspace changes require explicit confirmation');
|
|
306
|
+
}
|
|
307
|
+
const source = body.workspaces;
|
|
308
|
+
if (!Array.isArray(source) || source.length < 1 || source.length > 32) {
|
|
309
|
+
throw new Error('workspaces must contain 1 to 32 entries');
|
|
310
|
+
}
|
|
311
|
+
const workspaces = {};
|
|
312
|
+
for (const entry of source) {
|
|
313
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
314
|
+
throw new Error('Each workspace must be an object');
|
|
315
|
+
}
|
|
316
|
+
const item = entry;
|
|
317
|
+
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
|
318
|
+
const root = typeof item.root === 'string' ? item.root.trim() : '';
|
|
319
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) {
|
|
320
|
+
throw new Error('Workspace names may contain only letters, numbers, dot, underscore, and hyphen');
|
|
321
|
+
}
|
|
322
|
+
if (!root || root.length > 4096)
|
|
323
|
+
throw new Error(`Workspace '${name}' requires a valid path`);
|
|
324
|
+
if (workspaces[name] !== undefined)
|
|
325
|
+
throw new Error(`Duplicate workspace '${name}'`);
|
|
326
|
+
workspaces[name] = root;
|
|
327
|
+
}
|
|
328
|
+
const defaultWorkspace = typeof body.defaultWorkspace === 'string'
|
|
329
|
+
? body.defaultWorkspace.trim()
|
|
330
|
+
: '';
|
|
331
|
+
if (!workspaces[defaultWorkspace]) {
|
|
332
|
+
throw new Error('defaultWorkspace must reference one of the configured workspaces');
|
|
333
|
+
}
|
|
334
|
+
const { path, raw } = await readRawConfig();
|
|
335
|
+
const nextRaw = { ...raw, workspaces, defaultWorkspace };
|
|
336
|
+
delete nextRaw.root;
|
|
337
|
+
const content = JSON.stringify(nextRaw, null, 2) + '\n';
|
|
338
|
+
await config({ content, path });
|
|
339
|
+
await secureWriteFileAtomic(path, content);
|
|
340
|
+
await auditSecurity('config_update', { changed: 'workspaces' });
|
|
341
|
+
const current = await status();
|
|
342
|
+
let reloaded = false;
|
|
343
|
+
if (current.status === 'running') {
|
|
344
|
+
await request('reload');
|
|
345
|
+
reloaded = true;
|
|
346
|
+
}
|
|
347
|
+
return { saved: true, reloaded, configuration: await configView() };
|
|
348
|
+
}
|
|
349
|
+
async function runAgentAction(action) {
|
|
350
|
+
if (action === 'restart') {
|
|
351
|
+
const current = await status();
|
|
352
|
+
if (current.status === 'running')
|
|
353
|
+
await control('stop');
|
|
354
|
+
await control('start');
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
await control(action);
|
|
358
|
+
}
|
|
359
|
+
return statusView();
|
|
360
|
+
}
|
|
361
|
+
function parseWorkerOrigin(body) {
|
|
362
|
+
const value = typeof body.workerUrl === 'string' ? body.workerUrl.trim() : '';
|
|
363
|
+
if (!value)
|
|
364
|
+
throw new Error('workerUrl is required');
|
|
365
|
+
if (value.length > 2048)
|
|
366
|
+
throw new Error('workerUrl is too long');
|
|
367
|
+
return validatedWorkerOrigin(value).href;
|
|
368
|
+
}
|
|
369
|
+
function parseRegistrationToken(body) {
|
|
370
|
+
if (body.registrationToken === undefined)
|
|
371
|
+
return undefined;
|
|
372
|
+
if (typeof body.registrationToken !== 'string') {
|
|
373
|
+
throw new Error('registrationToken must be a string');
|
|
374
|
+
}
|
|
375
|
+
if (body.registrationToken.length > 8192) {
|
|
376
|
+
throw new Error('registrationToken is too long');
|
|
377
|
+
}
|
|
378
|
+
return body.registrationToken;
|
|
379
|
+
}
|
|
380
|
+
async function configureRelay(body) {
|
|
381
|
+
if (body.confirm !== true) {
|
|
382
|
+
throw new Error('Relay configuration requires explicit confirmation');
|
|
383
|
+
}
|
|
384
|
+
const workerUrl = parseWorkerOrigin(body);
|
|
385
|
+
const registrationToken = parseRegistrationToken(body);
|
|
386
|
+
await savePendingRegistrationToken(registrationToken);
|
|
387
|
+
const savedWorkerUrl = await saveRelayPreference(workerUrl);
|
|
388
|
+
await auditSecurity('relay_config_update', {
|
|
389
|
+
changed: 'relay',
|
|
390
|
+
publicRelay: savedWorkerUrl === validatedWorkerOrigin(DEFAULT_PUBLIC_WORKER_URL).href
|
|
391
|
+
});
|
|
392
|
+
return savedWorkerUrl;
|
|
393
|
+
}
|
|
394
|
+
function safeAuditValue(value) {
|
|
395
|
+
if (value === null || typeof value === 'number' || typeof value === 'boolean')
|
|
396
|
+
return value;
|
|
397
|
+
if (typeof value !== 'string')
|
|
398
|
+
return undefined;
|
|
399
|
+
if (value.length > 500)
|
|
400
|
+
return value.slice(0, 500) + '…';
|
|
401
|
+
if (/(?:bearer\s+)?[a-f0-9]{48,}/i.test(value))
|
|
402
|
+
return '<redacted>';
|
|
403
|
+
if (/\/mcp\/[^\s]+/i.test(value))
|
|
404
|
+
return '<redacted>';
|
|
405
|
+
return value;
|
|
406
|
+
}
|
|
407
|
+
async function readAuditEvents(limit = 100) {
|
|
408
|
+
let handle;
|
|
409
|
+
try {
|
|
410
|
+
handle = await open(auditFile, 'r');
|
|
411
|
+
const stat = await handle.stat();
|
|
412
|
+
const size = Math.min(stat.size, MAX_AUDIT_BYTES);
|
|
413
|
+
const buffer = Buffer.alloc(size);
|
|
414
|
+
await handle.read(buffer, 0, size, Math.max(0, stat.size - size));
|
|
415
|
+
let content = buffer.toString('utf8');
|
|
416
|
+
if (stat.size > size) {
|
|
417
|
+
const firstNewline = content.indexOf('\n');
|
|
418
|
+
content = firstNewline >= 0 ? content.slice(firstNewline + 1) : '';
|
|
419
|
+
}
|
|
420
|
+
const records = [];
|
|
421
|
+
for (const line of content.split(/\r?\n/)) {
|
|
422
|
+
if (!line.trim())
|
|
423
|
+
continue;
|
|
424
|
+
try {
|
|
425
|
+
const source = JSON.parse(line);
|
|
426
|
+
if (!source || typeof source !== 'object' || Array.isArray(source))
|
|
427
|
+
continue;
|
|
428
|
+
const safe = {};
|
|
429
|
+
for (const [key, value] of Object.entries(source)) {
|
|
430
|
+
if (!SAFE_AUDIT_FIELDS.has(key))
|
|
431
|
+
continue;
|
|
432
|
+
const sanitized = safeAuditValue(value);
|
|
433
|
+
if (sanitized !== undefined)
|
|
434
|
+
safe[key] = sanitized;
|
|
435
|
+
}
|
|
436
|
+
records.push(safe);
|
|
437
|
+
}
|
|
438
|
+
catch { }
|
|
439
|
+
}
|
|
440
|
+
return records.slice(-Math.max(1, Math.min(limit, 200))).reverse();
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (error?.code === 'ENOENT')
|
|
444
|
+
return [];
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
await handle?.close().catch(() => { });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function openDefaultBrowser(url) {
|
|
452
|
+
let child;
|
|
453
|
+
if (process.platform === 'win32') {
|
|
454
|
+
child = spawn('rundll32.exe', ['url.dll,FileProtocolHandler', url], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
455
|
+
}
|
|
456
|
+
else if (process.platform === 'darwin') {
|
|
457
|
+
child = spawn('open', [url], { detached: true, stdio: 'ignore' });
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
461
|
+
}
|
|
462
|
+
child.on('error', () => { });
|
|
463
|
+
child.unref();
|
|
464
|
+
}
|
|
465
|
+
const PAGE = String.raw `<!doctype html>
|
|
466
|
+
<html lang="en">
|
|
467
|
+
<head>
|
|
468
|
+
<meta charset="utf-8">
|
|
469
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
470
|
+
<title>Easy Local MCP Control Center</title>
|
|
471
|
+
<style>
|
|
472
|
+
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#182230;background:#f4f6f9}
|
|
473
|
+
*{box-sizing:border-box}body{margin:0;background:#f4f6f9}.shell{max-width:1240px;margin:0 auto;padding:26px 20px 52px}
|
|
474
|
+
header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;margin-bottom:20px}h1{font-size:28px;margin:0}h2{font-size:17px;margin:0 0 14px}p{margin:.45rem 0;color:#667085}
|
|
475
|
+
.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}.metric,.card{background:#fff;border:1px solid #e1e6ee;border-radius:14px;box-shadow:0 4px 14px rgba(16,24,40,.04)}
|
|
476
|
+
.metric{padding:14px 16px}.metric-label{font-size:12px;color:#667085}.metric-value{margin-top:5px;font-size:17px;font-weight:750;word-break:break-word}
|
|
477
|
+
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.card{padding:18px}.wide{grid-column:1/-1}
|
|
478
|
+
.row{display:flex;justify-content:space-between;gap:16px;padding:8px 0;border-bottom:1px solid #edf0f4}.row:last-child{border-bottom:0}.label{color:#667085}.value{font-weight:650;text-align:right;word-break:break-all}
|
|
479
|
+
.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}.header-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}#securityBadge{align-items:center;justify-content:center;align-self:center;line-height:1}button{border:1px solid #cfd6e2;background:#fff;border-radius:9px;padding:8px 12px;font-weight:650;cursor:pointer;color:#27364b}button.primary{background:#172b4d;border-color:#172b4d;color:#fff}button.danger{border-color:#f0a3a3;color:#b42318}button:disabled{opacity:.45;cursor:not-allowed}
|
|
480
|
+
.badge{display:inline-flex;padding:4px 8px;border-radius:999px;background:#eef2f6;font-size:12px;font-weight:750}.badge.ok{background:#e9f8ef;color:#067647}.badge.warn{background:#fff4e5;color:#b54708}.badge.bad{background:#feecec;color:#b42318}
|
|
481
|
+
.muted{font-size:12px;color:#7b8697}.path{font:12px ui-monospace,SFMono-Regular,Consolas,monospace;color:#475467;word-break:break-all}
|
|
482
|
+
.warning{margin-top:12px;padding:11px 12px;border-radius:10px;background:#fff4e5;color:#7a4b00;font-size:13px;font-weight:600}
|
|
483
|
+
table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:9px 8px;border-bottom:1px solid #edf0f4;vertical-align:middle}th{font-size:12px;color:#667085}
|
|
484
|
+
input[type="text"],input[type="password"],select{width:100%;border:1px solid #cfd6e2;border-radius:8px;padding:8px 9px;background:#fff;color:#182230}input[type="checkbox"],input[type="radio"]{width:17px;height:17px}
|
|
485
|
+
.cap-name{font-weight:700}.cap-note{display:block;font-size:11px;color:#7b8697;margin-top:2px}.workspace-actions{display:flex;gap:6px;align-items:center}.connection-editor{display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:12px}
|
|
486
|
+
.audit-tools{display:grid;grid-template-columns:180px 1fr auto auto;gap:8px;align-items:center;margin-bottom:10px}.pager{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:10px;flex-wrap:wrap}.pager-controls{display:flex;gap:8px;align-items:center}
|
|
487
|
+
#revealed{margin-top:9px;font:12px ui-monospace,SFMono-Regular,Consolas,monospace}#message{position:fixed;right:20px;bottom:20px;max-width:460px;padding:11px 14px;background:#172b4d;color:#fff;border-radius:10px;display:none;white-space:pre-wrap;z-index:10}
|
|
488
|
+
@media(max-width:900px){.summary{grid-template-columns:repeat(2,1fr)}.grid{grid-template-columns:1fr}.wide{grid-column:auto}}
|
|
489
|
+
@media(max-width:600px){.shell{padding:18px 10px 40px}.summary{grid-template-columns:1fr 1fr}header{display:block}.audit-tools{grid-template-columns:1fr}.connection-editor{grid-template-columns:1fr}.card{padding:14px}}
|
|
490
|
+
</style>
|
|
491
|
+
</head>
|
|
492
|
+
<body>
|
|
493
|
+
<div class="shell">
|
|
494
|
+
<header>
|
|
495
|
+
<div><h1>Easy Local MCP Control Center</h1><p>Local-only administration over the existing authenticated control plane.</p></div>
|
|
496
|
+
<div class="header-actions"><button id="refreshStatus">Refresh</button><span id="securityBadge" class="badge">Connecting…</span></div>
|
|
497
|
+
</header>
|
|
498
|
+
|
|
499
|
+
<section id="firstRunSetup" class="card wide" hidden style="margin-bottom:16px">
|
|
500
|
+
<h2>Choose a Relay before starting Easy Local MCP</h2>
|
|
501
|
+
<p>The Agent will not start until you explicitly save a Relay. The public Relay is prefilled for convenience, but it is not contacted until you confirm.</p>
|
|
502
|
+
<div class="connection-editor"><input id="setupWorkerInput" type="text" aria-label="Relay origin" placeholder="https://worker.example.com"><button id="setupUseDefault">Use public relay</button></div>
|
|
503
|
+
<div style="margin-top:8px"><input id="setupTokenInput" type="password" aria-label="Registration token" placeholder="Registration token (optional for protected custom Relay)"></div>
|
|
504
|
+
<p id="setupTokenHint" class="muted">The registration token is stored only until registration succeeds, then deleted.</p>
|
|
505
|
+
<div class="warning">The public Relay is shared trusted infrastructure and is not end-to-end encrypted. For company source code, internal systems, or sensitive data, use a self-hosted Relay.</div>
|
|
506
|
+
<div class="actions"><button id="setupStart" class="primary">Save & Start Agent</button></div>
|
|
507
|
+
</section>
|
|
508
|
+
|
|
509
|
+
<div class="summary">
|
|
510
|
+
<div class="metric"><div class="metric-label">Agent</div><div id="summaryAgent" class="metric-value">-</div></div>
|
|
511
|
+
<div class="metric"><div class="metric-label">Relay</div><div id="summaryRelay" class="metric-value">-</div></div>
|
|
512
|
+
<div class="metric"><div class="metric-label">Security</div><div id="summarySecurity" class="metric-value">-</div></div>
|
|
513
|
+
<div class="metric"><div class="metric-label">Default workspace</div><div id="summaryWorkspace" class="metric-value">-</div></div>
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
<div class="grid">
|
|
517
|
+
<section class="card">
|
|
518
|
+
<h2>Agent lifecycle</h2>
|
|
519
|
+
<div class="row"><span class="label">Status</span><span id="agentStatus" class="value">-</span></div>
|
|
520
|
+
<div class="row"><span class="label">PID</span><span id="pid" class="value">-</span></div>
|
|
521
|
+
<div class="row"><span class="label">Unlock expires</span><span id="expiry" class="value">-</span></div>
|
|
522
|
+
<div class="row"><span class="label">Log</span><span id="logPath" class="path">-</span></div>
|
|
523
|
+
<div class="actions">
|
|
524
|
+
<button id="agentStart" class="primary">Start</button>
|
|
525
|
+
<button id="agentRestart">Restart</button>
|
|
526
|
+
<button id="agentStop" class="danger">Stop</button>
|
|
527
|
+
<button id="reload">Reload config</button>
|
|
528
|
+
</div>
|
|
529
|
+
<div class="actions">
|
|
530
|
+
<button data-minutes="5">Unlock 5m</button><button data-minutes="30">Unlock 30m</button><button data-minutes="60">Unlock 60m</button><button id="lock" class="danger">Lock now</button>
|
|
531
|
+
</div>
|
|
532
|
+
</section>
|
|
533
|
+
|
|
534
|
+
<section class="card">
|
|
535
|
+
<h2>Relay & connection</h2>
|
|
536
|
+
<div class="row"><span class="label">State</span><span id="relayState" class="value">-</span></div>
|
|
537
|
+
<div class="row"><span class="label">Worker origin</span><span id="worker" class="value">-</span></div>
|
|
538
|
+
<div class="row"><span class="label">Device</span><span id="deviceId" class="value">-</span></div>
|
|
539
|
+
<div class="row"><span class="label">MCP URL</span><span id="maskedUrl" class="value">-</span></div>
|
|
540
|
+
<div class="connection-editor"><input id="workerInput" type="text" aria-label="Worker origin" placeholder="https://worker.example.com"><button id="useDefaultWorker">Use public relay</button></div>
|
|
541
|
+
<div style="margin-top:8px"><input id="workerTokenInput" type="password" aria-label="Registration token" placeholder="Registration token (optional for protected custom Relay)"></div>
|
|
542
|
+
<p id="workerHint" class="muted">Changing Worker re-registers this device. Registration credentials remain inside the Agent.</p>
|
|
543
|
+
<div class="actions"><button id="reregisterWorker">Re-register Worker</button><button id="reveal">Reveal / Copy MCP URL</button><button id="rotate" class="danger">Rotate credentials</button></div>
|
|
544
|
+
<input id="revealed" type="text" readonly hidden aria-label="Revealed MCP URL">
|
|
545
|
+
</section>
|
|
546
|
+
|
|
547
|
+
<section class="card wide">
|
|
548
|
+
<h2>Permissions</h2>
|
|
549
|
+
<p class="muted">Configured controls what Easy Local MCP may expose. Current availability also reflects Agent and LOCK / UNLOCK state.</p>
|
|
550
|
+
<div style="overflow:auto"><table>
|
|
551
|
+
<thead><tr><th>Capability</th><th>Configured</th><th>Effective config</th><th>Current availability</th></tr></thead>
|
|
552
|
+
<tbody id="capabilityRows">
|
|
553
|
+
<tr data-cap="fileRead"><td><span class="cap-name">files.read</span><span class="cap-note">Read workspace files</span></td><td><input type="checkbox" data-key="fileRead"></td><td class="effective">-</td><td class="availability">-</td></tr>
|
|
554
|
+
<tr data-cap="fileWrite"><td><span class="cap-name">files.write</span><span class="cap-note">Privileged</span></td><td><input type="checkbox" data-key="fileWrite"></td><td class="effective">-</td><td class="availability">-</td></tr>
|
|
555
|
+
<tr data-cap="fileDelete"><td><span class="cap-name">files.delete</span><span class="cap-note">Privileged</span></td><td><input type="checkbox" data-key="fileDelete"></td><td class="effective">-</td><td class="availability">-</td></tr>
|
|
556
|
+
<tr data-cap="shell"><td><span class="cap-name">shell</span><span class="cap-note">OS-level command execution</span></td><td><input type="checkbox" data-key="shell"></td><td class="effective">-</td><td class="availability">-</td></tr>
|
|
557
|
+
<tr data-cap="processes"><td><span class="cap-name">processes</span><span class="cap-note">Requires shell</span></td><td><input type="checkbox" data-key="processes"></td><td class="effective">-</td><td class="availability">-</td></tr>
|
|
558
|
+
<tr data-cap="externalMcp"><td><span class="cap-name">externalMcp</span><span class="cap-note">External MCP execution is privileged</span></td><td><input type="checkbox" data-key="externalMcp"></td><td class="effective">-</td><td class="availability">-</td></tr>
|
|
559
|
+
</tbody></table></div>
|
|
560
|
+
<div class="warning">Shell runs with the Easy Local MCP OS user's authority. Workspace restrictions protect Easy Local MCP file tools; they do not sandbox shell commands.</div>
|
|
561
|
+
<div class="actions"><button id="saveConfig" class="primary">Save permission profile</button><span id="configPath" class="path"></span></div>
|
|
562
|
+
</section>
|
|
563
|
+
|
|
564
|
+
<section class="card wide">
|
|
565
|
+
<h2>Workspaces</h2>
|
|
566
|
+
<p class="muted">Workspace changes alter the file-access boundary and require explicit confirmation. Paths must already exist and be directories.</p>
|
|
567
|
+
<div style="overflow:auto"><table><thead><tr><th>Default</th><th>Name</th><th>Path</th><th></th></tr></thead><tbody id="workspaceRows"></tbody></table></div>
|
|
568
|
+
<div class="actions"><button id="addWorkspace">Add workspace</button><button id="saveWorkspaces" class="primary">Save workspaces</button></div>
|
|
569
|
+
</section>
|
|
570
|
+
|
|
571
|
+
<section class="card wide">
|
|
572
|
+
<h2>Audit history</h2>
|
|
573
|
+
<div class="audit-tools">
|
|
574
|
+
<select id="auditCategory"><option value="all">All events</option><option value="denied">Denied / errors</option><option value="security">Security & credentials</option><option value="config">Configuration</option><option value="tools">Tool activity</option></select>
|
|
575
|
+
<input id="auditSearch" type="text" placeholder="Filter event, tool, workspace, result…">
|
|
576
|
+
<select id="auditPageSize" aria-label="Audit rows per page"><option value="10">10 / page</option><option value="25" selected>25 / page</option><option value="50">50 / page</option></select>
|
|
577
|
+
<button id="refreshAudit">Refresh</button>
|
|
578
|
+
</div>
|
|
579
|
+
<div style="overflow:auto"><table><thead><tr><th>Time</th><th>Event</th><th>Tool / workspace</th><th>Result / reason</th></tr></thead><tbody id="auditRows"></tbody></table></div>
|
|
580
|
+
<div class="pager"><span id="auditPageInfo" class="muted">-</span><div class="pager-controls"><button id="auditPrev">Previous</button><button id="auditNext">Next</button></div></div>
|
|
581
|
+
<p class="muted">Only a safe allowlist of audit fields is displayed. Tokens, URLs, command output and arbitrary payloads are omitted.</p>
|
|
582
|
+
</section>
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
<div id="message"></div>
|
|
586
|
+
<script>
|
|
587
|
+
(() => {
|
|
588
|
+
let currentFeatures=null;
|
|
589
|
+
let currentWorkspaces=[];
|
|
590
|
+
let currentDefaultWorkspace='';
|
|
591
|
+
let auditEvents=[];
|
|
592
|
+
let auditPage=1;
|
|
593
|
+
let workerManagedByEnv=false;
|
|
594
|
+
let registrationTokenManagedByEnv=false;
|
|
595
|
+
let relayConfigured=false;
|
|
596
|
+
const DEFAULT_WORKER='https://localmcp-relay.daodao973597.workers.dev';
|
|
597
|
+
const $=id=>document.getElementById(id);
|
|
598
|
+
const message=(value,error=false)=>{
|
|
599
|
+
const node=$('message');
|
|
600
|
+
node.textContent=value;
|
|
601
|
+
node.style.background=error?'#8a1c13':'#172b4d';
|
|
602
|
+
node.style.display='block';
|
|
603
|
+
clearTimeout(message.timer);
|
|
604
|
+
message.timer=setTimeout(()=>node.style.display='none',4500);
|
|
605
|
+
};
|
|
606
|
+
const api=async(path,body={})=>{
|
|
607
|
+
const response=await fetch(path,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
608
|
+
const data=await response.json().catch(()=>({error:'Invalid server response'}));
|
|
609
|
+
if(!response.ok)throw new Error(data.error||('HTTP '+response.status));
|
|
610
|
+
return data;
|
|
611
|
+
};
|
|
612
|
+
const badge=(text,state)=>{
|
|
613
|
+
const span=document.createElement('span');
|
|
614
|
+
span.className='badge '+(state||'');
|
|
615
|
+
span.textContent=text;
|
|
616
|
+
return span;
|
|
617
|
+
};
|
|
618
|
+
const setFeatures=(features,capabilities)=>{
|
|
619
|
+
currentFeatures={...features};
|
|
620
|
+
document.querySelectorAll('#capabilityRows input[data-key]').forEach(input=>{input.checked=!!features[input.dataset.key];});
|
|
621
|
+
for(const capability of capabilities||[]){
|
|
622
|
+
const row=document.querySelector('tr[data-cap="'+capability.key+'"]');
|
|
623
|
+
if(!row)continue;
|
|
624
|
+
row.querySelector('.effective').replaceChildren(badge(capability.effective?'Enabled':'Disabled',capability.effective?'ok':''));
|
|
625
|
+
const state=capability.availability==='available'?'ok':capability.availability==='locked'?'warn':capability.availability==='disabled'?'':'bad';
|
|
626
|
+
row.querySelector('.availability').replaceChildren(badge(capability.availability,state));
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
const renderWorkspaces=()=>{
|
|
630
|
+
const body=$('workspaceRows');
|
|
631
|
+
body.replaceChildren();
|
|
632
|
+
currentWorkspaces.forEach((workspace,index)=>{
|
|
633
|
+
const tr=document.createElement('tr');
|
|
634
|
+
const tdDefault=document.createElement('td');
|
|
635
|
+
const radio=document.createElement('input');
|
|
636
|
+
radio.type='radio'; radio.name='defaultWorkspace'; radio.checked=workspace.name===currentDefaultWorkspace;
|
|
637
|
+
radio.addEventListener('change',()=>{if(radio.checked)currentDefaultWorkspace=workspace.name;});
|
|
638
|
+
tdDefault.append(radio);
|
|
639
|
+
const tdName=document.createElement('td');
|
|
640
|
+
const name=document.createElement('input'); name.type='text'; name.value=workspace.name; name.maxLength=64;
|
|
641
|
+
name.addEventListener('input',()=>{const old=workspace.name;workspace.name=name.value;if(currentDefaultWorkspace===old)currentDefaultWorkspace=workspace.name;});
|
|
642
|
+
tdName.append(name);
|
|
643
|
+
const tdRoot=document.createElement('td');
|
|
644
|
+
const root=document.createElement('input'); root.type='text'; root.value=workspace.root;
|
|
645
|
+
root.addEventListener('input',()=>{workspace.root=root.value;});
|
|
646
|
+
tdRoot.append(root);
|
|
647
|
+
const tdAction=document.createElement('td');
|
|
648
|
+
const remove=document.createElement('button'); remove.textContent='Remove'; remove.className='danger'; remove.disabled=currentWorkspaces.length===1;
|
|
649
|
+
remove.addEventListener('click',()=>{const removed=currentWorkspaces.splice(index,1)[0];if(removed.name===currentDefaultWorkspace)currentDefaultWorkspace=currentWorkspaces[0].name;renderWorkspaces();});
|
|
650
|
+
tdAction.append(remove);
|
|
651
|
+
tr.append(tdDefault,tdName,tdRoot,tdAction); body.append(tr);
|
|
652
|
+
});
|
|
653
|
+
};
|
|
654
|
+
const load=async()=>{
|
|
655
|
+
const data=await api('/api/status');
|
|
656
|
+
const running=data.agent.status==='running';
|
|
657
|
+
workerManagedByEnv=!!data.connection.workerManagedByEnv;
|
|
658
|
+
registrationTokenManagedByEnv=!!data.connection.registrationTokenManagedByEnv;
|
|
659
|
+
relayConfigured=!!data.connection.configured;
|
|
660
|
+
const needsSetup=!!data.connection.needsSetup;
|
|
661
|
+
$('firstRunSetup').hidden=!needsSetup;
|
|
662
|
+
document.querySelector('.summary').hidden=needsSetup;
|
|
663
|
+
document.querySelector('.grid').hidden=needsSetup;
|
|
664
|
+
$('setupWorkerInput').value=data.connection.suggestedWorkerUrl??DEFAULT_WORKER;
|
|
665
|
+
$('setupWorkerInput').disabled=workerManagedByEnv;
|
|
666
|
+
$('setupUseDefault').disabled=workerManagedByEnv;
|
|
667
|
+
$('setupTokenInput').disabled=registrationTokenManagedByEnv;
|
|
668
|
+
$('setupTokenHint').textContent=registrationTokenManagedByEnv
|
|
669
|
+
? 'Registration token is controlled by LOCALMCP_REGISTRATION_TOKEN.'
|
|
670
|
+
: 'The registration token is stored only until registration succeeds, then deleted.';
|
|
671
|
+
$('agentStatus').textContent=data.agent.status+(data.agent.ready?' / ready':running?' / connecting':'');
|
|
672
|
+
$('pid').textContent=data.agent.pid??'-';
|
|
673
|
+
$('expiry').textContent=data.agent.unlockExpiresAt??'-';
|
|
674
|
+
$('logPath').textContent=data.agent.log??'-';
|
|
675
|
+
$('securityBadge').textContent=data.agent.locked?'LOCKED':'UNLOCKED';
|
|
676
|
+
$('securityBadge').className='badge '+(data.agent.locked?'warn':'ok');
|
|
677
|
+
$('summaryAgent').textContent=data.agent.ready?'Running / ready':running?'Running / connecting':'Stopped';
|
|
678
|
+
$('summaryRelay').textContent=needsSetup?'Setup required':data.connection.state;
|
|
679
|
+
$('summarySecurity').textContent=data.agent.locked?'LOCKED':'UNLOCKED';
|
|
680
|
+
$('summaryWorkspace').textContent=data.configuration.defaultWorkspace;
|
|
681
|
+
$('relayState').textContent=data.connection.state;
|
|
682
|
+
$('worker').textContent=data.connection.workerUrl??'-';
|
|
683
|
+
$('deviceId').textContent=data.connection.deviceId??'legacy / unavailable';
|
|
684
|
+
$('maskedUrl').textContent=data.connection.mcpUrlMasked??'-';
|
|
685
|
+
$('configPath').textContent=data.configuration.path;
|
|
686
|
+
$('workerInput').value=data.connection.suggestedWorkerUrl??DEFAULT_WORKER;
|
|
687
|
+
$('workerInput').disabled=workerManagedByEnv;
|
|
688
|
+
$('workerTokenInput').disabled=registrationTokenManagedByEnv;
|
|
689
|
+
$('reregisterWorker').disabled=workerManagedByEnv;
|
|
690
|
+
$('reregisterWorker').textContent=running?'Re-register Worker':'Save Relay';
|
|
691
|
+
$('useDefaultWorker').disabled=workerManagedByEnv;
|
|
692
|
+
$('workerHint').textContent=workerManagedByEnv
|
|
693
|
+
? 'Worker origin is controlled by LOCALMCP_WORKER_URL. Remove the environment override before changing it here.'
|
|
694
|
+
: (data.connection.publicRelay?'Using the public relay. It is trusted infrastructure, not end-to-end encrypted.':running?'Custom Worker origin. Re-registering replaces device credentials for this Agent.':'Relay changes are saved now and used the next time the Agent starts.');
|
|
695
|
+
$('agentStart').disabled=running||!relayConfigured;
|
|
696
|
+
$('agentStop').disabled=!running;
|
|
697
|
+
$('agentRestart').disabled=!running;
|
|
698
|
+
$('lock').disabled=!running;
|
|
699
|
+
document.querySelectorAll('button[data-minutes]').forEach(button=>button.disabled=!running);
|
|
700
|
+
setFeatures(data.configuration.features,data.capabilities);
|
|
701
|
+
currentWorkspaces=data.configuration.workspaces.map(item=>({name:item.name,root:item.root}));
|
|
702
|
+
currentDefaultWorkspace=data.configuration.defaultWorkspace;
|
|
703
|
+
renderWorkspaces();
|
|
704
|
+
};
|
|
705
|
+
const eventCategory=event=>{
|
|
706
|
+
const name=String(event.event||'').toLowerCase();
|
|
707
|
+
const result=String(event.result||'').toLowerCase();
|
|
708
|
+
if(event.reason||event.error||result==='denied'||result==='error')return 'denied';
|
|
709
|
+
if(name.includes('lock')||name.includes('unlock')||name.includes('credential')||name.includes('worker')||name.includes('reveal'))return 'security';
|
|
710
|
+
if(name.includes('config'))return 'config';
|
|
711
|
+
if(event.tool)return 'tools';
|
|
712
|
+
return 'other';
|
|
713
|
+
};
|
|
714
|
+
const renderAudit=()=>{
|
|
715
|
+
const body=$('auditRows'); body.replaceChildren();
|
|
716
|
+
const category=$('auditCategory').value;
|
|
717
|
+
const query=$('auditSearch').value.trim().toLowerCase();
|
|
718
|
+
const pageSize=Number($('auditPageSize').value)||25;
|
|
719
|
+
const filtered=auditEvents.filter(event=>{
|
|
720
|
+
if(category!=='all'&&eventCategory(event)!==category)return false;
|
|
721
|
+
const searchable=Object.values(event).map(String).join(' ').toLowerCase();
|
|
722
|
+
return !query||searchable.includes(query);
|
|
723
|
+
});
|
|
724
|
+
const totalPages=Math.max(1,Math.ceil(filtered.length/pageSize));
|
|
725
|
+
auditPage=Math.min(Math.max(1,auditPage),totalPages);
|
|
726
|
+
const start=(auditPage-1)*pageSize;
|
|
727
|
+
const pageItems=filtered.slice(start,start+pageSize);
|
|
728
|
+
for(const event of pageItems){
|
|
729
|
+
const tr=document.createElement('tr');
|
|
730
|
+
const values=[event.timestamp||'-',event.event||'-',[event.tool,event.workspace].filter(Boolean).join(' / ')||'-',event.reason||event.result||event.error||event.durationMs||'-'];
|
|
731
|
+
for(const value of values){const td=document.createElement('td');td.textContent=String(value);tr.append(td);} body.append(tr);
|
|
732
|
+
}
|
|
733
|
+
if(!pageItems.length){
|
|
734
|
+
const tr=document.createElement('tr');
|
|
735
|
+
const td=document.createElement('td');td.colSpan=4;td.className='muted';td.textContent='No matching audit events.';tr.append(td);body.append(tr);
|
|
736
|
+
}
|
|
737
|
+
const shownFrom=filtered.length?start+1:0;
|
|
738
|
+
const shownTo=Math.min(start+pageItems.length,filtered.length);
|
|
739
|
+
$('auditPageInfo').textContent='Showing '+shownFrom+'–'+shownTo+' of '+filtered.length+' · Page '+auditPage+' / '+totalPages;
|
|
740
|
+
$('auditPrev').disabled=auditPage<=1;
|
|
741
|
+
$('auditNext').disabled=auditPage>=totalPages;
|
|
742
|
+
};
|
|
743
|
+
const loadAudit=async()=>{const data=await api('/api/audit');auditEvents=data.events||[];auditPage=1;renderAudit();};
|
|
744
|
+
const boot=async()=>{await api('/api/session');await load();await loadAudit();};
|
|
745
|
+
const agentAction=async(action)=>{
|
|
746
|
+
if((action==='stop'||action==='restart')&&!confirm((action==='stop'?'Stop':'Restart')+' the Easy Local MCP Agent? Active MCP connections will be interrupted.'))return;
|
|
747
|
+
try{await api('/api/agent/'+action,{confirm:action==='start'||action==='stop'||action==='restart'});await load();message('Agent '+action+' completed.');}
|
|
748
|
+
catch(error){message(error.message,true);}
|
|
749
|
+
};
|
|
750
|
+
$('setupUseDefault').addEventListener('click',()=>{$('setupWorkerInput').value=DEFAULT_WORKER;});
|
|
751
|
+
$('setupStart').addEventListener('click',async()=>{
|
|
752
|
+
if(workerManagedByEnv)return;
|
|
753
|
+
const workerUrl=$('setupWorkerInput').value.trim();
|
|
754
|
+
const registrationToken=registrationTokenManagedByEnv?undefined:$('setupTokenInput').value;
|
|
755
|
+
if(!confirm('Save this Relay and start the Easy Local MCP Agent?\n\n'+workerUrl))return;
|
|
756
|
+
try{
|
|
757
|
+
await api('/api/relay/configure',{workerUrl,registrationToken,start:true,confirm:true});
|
|
758
|
+
$('setupTokenInput').value='';
|
|
759
|
+
await load();
|
|
760
|
+
await loadAudit();
|
|
761
|
+
message('Relay saved and Agent started.');
|
|
762
|
+
}catch(error){
|
|
763
|
+
await load().catch(()=>{});
|
|
764
|
+
message(error.message,true);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
$('agentStart').addEventListener('click',()=>agentAction('start'));
|
|
768
|
+
$('agentStop').addEventListener('click',()=>agentAction('stop'));
|
|
769
|
+
$('agentRestart').addEventListener('click',()=>agentAction('restart'));
|
|
770
|
+
document.querySelectorAll('button[data-minutes]').forEach(button=>button.addEventListener('click',async()=>{
|
|
771
|
+
try{await api('/api/unlock',{minutes:Number(button.dataset.minutes)});await load();message('Easy Local MCP unlocked.');}catch(error){message(error.message,true);}
|
|
772
|
+
}));
|
|
773
|
+
$('lock').addEventListener('click',async()=>{try{await api('/api/lock');await load();message('Easy Local MCP locked.');}catch(error){message(error.message,true);}});
|
|
774
|
+
$('reload').addEventListener('click',async()=>{try{await api('/api/reload');await load();message('Configuration reloaded.');}catch(error){message(error.message,true);}});
|
|
775
|
+
$('rotate').addEventListener('click',async()=>{
|
|
776
|
+
if(!confirm('Rotate Easy Local MCP credentials? Existing connections may be interrupted.'))return;
|
|
777
|
+
try{await api('/api/rotate',{confirm:true});await load();message('Credentials rotated.');}catch(error){message(error.message,true);}
|
|
778
|
+
});
|
|
779
|
+
$('reveal').addEventListener('click',async()=>{
|
|
780
|
+
if(!confirm('The full MCP URL is a credential. Reveal and copy it locally?'))return;
|
|
781
|
+
try{const data=await api('/api/reveal-url',{confirm:true});const input=$('revealed');input.hidden=false;input.value=data.url;try{await navigator.clipboard.writeText(data.url);message('MCP URL revealed and copied.');}catch{message('MCP URL revealed. Clipboard access was unavailable.');}}catch(error){message(error.message,true);}
|
|
782
|
+
});
|
|
783
|
+
$('useDefaultWorker').addEventListener('click',()=>{$('workerInput').value=DEFAULT_WORKER;});
|
|
784
|
+
$('reregisterWorker').addEventListener('click',async()=>{
|
|
785
|
+
if(workerManagedByEnv)return;
|
|
786
|
+
const workerUrl=$('workerInput').value.trim();
|
|
787
|
+
const registrationToken=registrationTokenManagedByEnv?undefined:$('workerTokenInput').value;
|
|
788
|
+
const running=$('agentStatus').textContent.startsWith('running');
|
|
789
|
+
if(running){
|
|
790
|
+
if(!confirm('Re-register this Easy Local MCP device with '+workerUrl+'? Local credentials will switch to the new Worker. The previous Worker registration may remain valid until it is revoked or rotated there.'))return;
|
|
791
|
+
try{await api('/api/worker/reregister',{workerUrl,registrationToken,confirm:true});$('workerTokenInput').value='';await load();message('Worker re-registration completed.');}catch(error){message(error.message,true);}
|
|
792
|
+
}else{
|
|
793
|
+
if(!confirm('Save this Relay for the next Agent start?\n\n'+workerUrl))return;
|
|
794
|
+
try{await api('/api/relay/configure',{workerUrl,registrationToken,start:false,confirm:true});$('workerTokenInput').value='';await load();message('Relay saved.');}catch(error){message(error.message,true);}
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
document.querySelector('input[data-key="processes"]').addEventListener('change',event=>{if(event.target.checked)document.querySelector('input[data-key="shell"]').checked=true;});
|
|
798
|
+
document.querySelector('input[data-key="shell"]').addEventListener('change',event=>{if(!event.target.checked)document.querySelector('input[data-key="processes"]').checked=false;});
|
|
799
|
+
$('saveConfig').addEventListener('click',async()=>{
|
|
800
|
+
try{
|
|
801
|
+
const features={};document.querySelectorAll('#capabilityRows input[data-key]').forEach(input=>{features[input.dataset.key]=input.checked;});
|
|
802
|
+
const dangerous=['fileWrite','fileDelete','shell','processes','externalMcp'];
|
|
803
|
+
const enabling=dangerous.filter(key=>!currentFeatures?.[key]&&features[key]);
|
|
804
|
+
let confirmDangerous=false;
|
|
805
|
+
if(enabling.length){confirmDangerous=confirm('Enable privileged capabilities: '+enabling.join(', ')+'?\n\nThese capabilities grant additional authority while Easy Local MCP is unlocked.');if(!confirmDangerous)return;}
|
|
806
|
+
await api('/api/config/update',{features,confirmDangerous});await load();message('Permission profile saved.');
|
|
807
|
+
}catch(error){message(error.message,true);}
|
|
808
|
+
});
|
|
809
|
+
$('addWorkspace').addEventListener('click',()=>{let i=1;let name='workspace'+i;const names=new Set(currentWorkspaces.map(item=>item.name));while(names.has(name))name='workspace'+(++i);currentWorkspaces.push({name,root:''});renderWorkspaces();});
|
|
810
|
+
$('saveWorkspaces').addEventListener('click',async()=>{
|
|
811
|
+
if(!confirm('Save workspace changes? This changes the Easy Local MCP file-access boundary.'))return;
|
|
812
|
+
try{await api('/api/workspaces/update',{workspaces:currentWorkspaces,defaultWorkspace:currentDefaultWorkspace,confirm:true});await load();message('Workspaces saved.');}catch(error){message(error.message,true);}
|
|
813
|
+
});
|
|
814
|
+
$('refreshStatus').addEventListener('click',()=>load().catch(error=>message(error.message,true)));
|
|
815
|
+
$('refreshAudit').addEventListener('click',()=>loadAudit().catch(error=>message(error.message,true)));
|
|
816
|
+
$('auditCategory').addEventListener('change',()=>{auditPage=1;renderAudit();});
|
|
817
|
+
$('auditSearch').addEventListener('input',()=>{auditPage=1;renderAudit();});
|
|
818
|
+
$('auditPageSize').addEventListener('change',()=>{auditPage=1;renderAudit();});
|
|
819
|
+
$('auditPrev').addEventListener('click',()=>{if(auditPage>1){auditPage--;renderAudit();}});
|
|
820
|
+
$('auditNext').addEventListener('click',()=>{auditPage++;renderAudit();});
|
|
821
|
+
boot().catch(error=>message(error.message,true));
|
|
822
|
+
})();
|
|
823
|
+
</script>
|
|
824
|
+
</body>
|
|
825
|
+
</html>`;
|
|
826
|
+
export async function startControlUi(options = {}) {
|
|
827
|
+
const sessions = new Map();
|
|
828
|
+
let expectedOrigin = '';
|
|
829
|
+
let expectedHost = '';
|
|
830
|
+
let closedResolve;
|
|
831
|
+
let closing;
|
|
832
|
+
const closed = new Promise(resolveClosed => { closedResolve = resolveClosed; });
|
|
833
|
+
const server = createServer(async (req, res) => {
|
|
834
|
+
try {
|
|
835
|
+
if (!isLoopback(req.socket.remoteAddress)) {
|
|
836
|
+
json(res, 403, { error: 'Loopback access only' });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const host = req.headers.host || '';
|
|
840
|
+
if (!expectedHost || host !== expectedHost) {
|
|
841
|
+
json(res, 403, { error: 'Invalid local host' });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const target = new URL(req.url || '/', expectedOrigin);
|
|
845
|
+
if (target.search) {
|
|
846
|
+
json(res, 400, { error: 'Query strings are not accepted' });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (req.method === 'GET' && target.pathname === '/') {
|
|
850
|
+
text(res, 200, PAGE, 'text/html; charset=utf-8');
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (req.method !== 'POST' || !target.pathname.startsWith('/api/')) {
|
|
854
|
+
json(res, 404, { error: 'Not found' });
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (req.headers.origin !== expectedOrigin) {
|
|
858
|
+
json(res, 403, { error: 'Invalid origin' });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const now = Date.now();
|
|
862
|
+
for (const [token, expiresAt] of sessions) {
|
|
863
|
+
if (expiresAt <= now)
|
|
864
|
+
sessions.delete(token);
|
|
865
|
+
}
|
|
866
|
+
if (target.pathname === '/api/session') {
|
|
867
|
+
const token = randomBytes(32).toString('hex');
|
|
868
|
+
sessions.set(token, now + SESSION_TTL_MS);
|
|
869
|
+
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_TTL_MS / 1000}`);
|
|
870
|
+
json(res, 200, { ok: true });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
874
|
+
const session = cookies[SESSION_COOKIE];
|
|
875
|
+
const expiresAt = session ? sessions.get(session) : undefined;
|
|
876
|
+
if (!session || !expiresAt || expiresAt <= now) {
|
|
877
|
+
if (session)
|
|
878
|
+
sessions.delete(session);
|
|
879
|
+
json(res, 401, { error: 'Local UI session required' });
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
sessions.set(session, now + SESSION_TTL_MS);
|
|
883
|
+
if (target.pathname === '/api/status') {
|
|
884
|
+
await readJsonBody(req);
|
|
885
|
+
json(res, 200, await statusView());
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (target.pathname === '/api/config') {
|
|
889
|
+
await readJsonBody(req);
|
|
890
|
+
json(res, 200, { configuration: await configView() });
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (target.pathname === '/api/config/update') {
|
|
894
|
+
json(res, 200, await updateConfiguration(await readJsonBody(req)));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (target.pathname === '/api/workspaces/update') {
|
|
898
|
+
json(res, 200, await updateWorkspaces(await readJsonBody(req)));
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (target.pathname === '/api/relay/configure') {
|
|
902
|
+
const body = await readJsonBody(req);
|
|
903
|
+
await configureRelay(body);
|
|
904
|
+
if (body.start === true) {
|
|
905
|
+
await runAgentAction('start');
|
|
906
|
+
}
|
|
907
|
+
json(res, 200, await statusView());
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (target.pathname === '/api/agent/start'
|
|
911
|
+
|| target.pathname === '/api/agent/stop'
|
|
912
|
+
|| target.pathname === '/api/agent/restart') {
|
|
913
|
+
const body = await readJsonBody(req);
|
|
914
|
+
const action = target.pathname.slice('/api/agent/'.length);
|
|
915
|
+
if ((action === 'stop' || action === 'restart') && body.confirm !== true) {
|
|
916
|
+
throw new Error(`Agent ${action} requires explicit confirmation`);
|
|
917
|
+
}
|
|
918
|
+
json(res, 200, await runAgentAction(action));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (target.pathname === '/api/worker/reregister') {
|
|
922
|
+
const body = await readJsonBody(req);
|
|
923
|
+
if (body.confirm !== true)
|
|
924
|
+
throw new Error('Worker re-registration requires explicit confirmation');
|
|
925
|
+
const workerUrl = parseWorkerOrigin(body);
|
|
926
|
+
await savePendingRegistrationToken(parseRegistrationToken(body));
|
|
927
|
+
await request('reregister', { workerUrl });
|
|
928
|
+
json(res, 200, await statusView());
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (target.pathname === '/api/unlock') {
|
|
932
|
+
const body = await readJsonBody(req);
|
|
933
|
+
const minutes = Number(body.minutes);
|
|
934
|
+
if (![5, 30, 60].includes(minutes)) {
|
|
935
|
+
throw new Error('UI unlock duration must be 5, 30, or 60 minutes');
|
|
936
|
+
}
|
|
937
|
+
await request('unlock', { minutes });
|
|
938
|
+
json(res, 200, await statusView());
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (target.pathname === '/api/lock') {
|
|
942
|
+
await readJsonBody(req);
|
|
943
|
+
await request('lock');
|
|
944
|
+
json(res, 200, await statusView());
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (target.pathname === '/api/reload') {
|
|
948
|
+
await readJsonBody(req);
|
|
949
|
+
await request('reload');
|
|
950
|
+
json(res, 200, await statusView());
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (target.pathname === '/api/rotate') {
|
|
954
|
+
const body = await readJsonBody(req);
|
|
955
|
+
if (body.confirm !== true)
|
|
956
|
+
throw new Error('Credential rotation requires explicit confirmation');
|
|
957
|
+
await request('rotate');
|
|
958
|
+
json(res, 200, await statusView());
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (target.pathname === '/api/reveal-url') {
|
|
962
|
+
const body = await readJsonBody(req);
|
|
963
|
+
if (body.confirm !== true)
|
|
964
|
+
throw new Error('MCP URL reveal requires explicit confirmation');
|
|
965
|
+
const current = await request('status');
|
|
966
|
+
if (current.status !== 'running' || !current.url) {
|
|
967
|
+
throw new Error('Easy Local MCP is not running or has no MCP URL');
|
|
968
|
+
}
|
|
969
|
+
await auditSecurity('mcp_url_reveal');
|
|
970
|
+
json(res, 200, { url: current.url });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (target.pathname === '/api/audit') {
|
|
974
|
+
await readJsonBody(req);
|
|
975
|
+
json(res, 200, { events: await readAuditEvents(200) });
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
json(res, 404, { error: 'Not found' });
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
server.on('clientError', (_error, socket) => {
|
|
985
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
986
|
+
});
|
|
987
|
+
await new Promise((ready, reject) => {
|
|
988
|
+
server.once('error', reject);
|
|
989
|
+
server.listen(options.port ?? 0, UI_HOST, ready);
|
|
990
|
+
});
|
|
991
|
+
const address = server.address();
|
|
992
|
+
if (!address || typeof address === 'string') {
|
|
993
|
+
server.close();
|
|
994
|
+
throw new Error('Unable to determine Easy Local MCP UI address');
|
|
995
|
+
}
|
|
996
|
+
const port = address.port;
|
|
997
|
+
expectedHost = `${UI_HOST}:${port}`;
|
|
998
|
+
expectedOrigin = `http://${expectedHost}`;
|
|
999
|
+
const url = expectedOrigin + '/';
|
|
1000
|
+
const close = () => {
|
|
1001
|
+
closing ??= new Promise((done, reject) => {
|
|
1002
|
+
sessions.clear();
|
|
1003
|
+
server.close(error => {
|
|
1004
|
+
if (error)
|
|
1005
|
+
reject(error);
|
|
1006
|
+
else {
|
|
1007
|
+
closedResolve();
|
|
1008
|
+
done();
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
server.closeAllConnections();
|
|
1012
|
+
});
|
|
1013
|
+
return closing;
|
|
1014
|
+
};
|
|
1015
|
+
if (options.openBrowser !== false) {
|
|
1016
|
+
openDefaultBrowser(url);
|
|
1017
|
+
}
|
|
1018
|
+
return {
|
|
1019
|
+
host: UI_HOST,
|
|
1020
|
+
port,
|
|
1021
|
+
url,
|
|
1022
|
+
close,
|
|
1023
|
+
closed
|
|
1024
|
+
};
|
|
1025
|
+
}
|