@xcanwin/manyoyo 4.1.4 → 4.1.10
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/docker/manyoyo.Dockerfile +36 -39
- package/lib/web/frontend/app.css +420 -190
- package/lib/web/frontend/app.html +71 -4
- package/lib/web/frontend/app.js +840 -136
- package/lib/web/frontend/login.css +77 -63
- package/lib/web/server.js +609 -40
- package/package.json +2 -2
package/lib/web/server.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const crypto = require('crypto');
|
|
8
8
|
const http = require('http');
|
|
9
9
|
const WebSocket = require('ws');
|
|
10
|
+
const JSON5 = require('json5');
|
|
10
11
|
const { buildContainerRunArgs } = require('../container-run');
|
|
11
12
|
|
|
12
13
|
const WEB_HISTORY_MAX_MESSAGES = 500;
|
|
@@ -20,6 +21,39 @@ const WEB_TERMINAL_MIN_ROWS = 12;
|
|
|
20
21
|
const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
|
|
21
22
|
const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
|
|
22
23
|
const FRONTEND_DIR = path.join(__dirname, 'frontend');
|
|
24
|
+
const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
25
|
+
const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
|
|
26
|
+
|
|
27
|
+
const YOLO_COMMAND_MAP = {
|
|
28
|
+
claude: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
|
|
29
|
+
cc: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
|
|
30
|
+
c: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
|
|
31
|
+
gemini: 'gemini --yolo',
|
|
32
|
+
gm: 'gemini --yolo',
|
|
33
|
+
g: 'gemini --yolo',
|
|
34
|
+
codex: 'codex --dangerously-bypass-approvals-and-sandbox',
|
|
35
|
+
cx: 'codex --dangerously-bypass-approvals-and-sandbox',
|
|
36
|
+
opencode: 'OPENCODE_PERMISSION=\'{"*":"allow"}\' opencode',
|
|
37
|
+
oc: 'OPENCODE_PERMISSION=\'{"*":"allow"}\' opencode'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const DEFAULT_WEB_CONFIG_TEMPLATE = `{
|
|
41
|
+
// MANYOYO 全局配置文件(JSON5)
|
|
42
|
+
"containerName": "my-dev",
|
|
43
|
+
"hostPath": "/path/to/your/project",
|
|
44
|
+
"containerPath": "/path/to/your/project",
|
|
45
|
+
"imageName": "localhost/xcanwin/manyoyo",
|
|
46
|
+
"imageVersion": "1.7.4-common",
|
|
47
|
+
"containerMode": "common",
|
|
48
|
+
"shellPrefix": "",
|
|
49
|
+
"shell": "",
|
|
50
|
+
"shellSuffix": "",
|
|
51
|
+
"yolo": "",
|
|
52
|
+
"env": {},
|
|
53
|
+
"envFile": [],
|
|
54
|
+
"volumes": []
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
23
57
|
|
|
24
58
|
let XTERM_JS_FILE = null;
|
|
25
59
|
let XTERM_CSS_FILE = null;
|
|
@@ -218,6 +252,464 @@ function getWebAuthClearCookie() {
|
|
|
218
252
|
return `${WEB_AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
219
253
|
}
|
|
220
254
|
|
|
255
|
+
function getDefaultWebConfigPath() {
|
|
256
|
+
return path.join(os.homedir(), '.manyoyo', 'manyoyo.json');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function hasOwn(obj, key) {
|
|
260
|
+
return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function toPlainObject(value) {
|
|
264
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function pickFirstString() {
|
|
271
|
+
for (let i = 0; i < arguments.length; i += 1) {
|
|
272
|
+
const value = arguments[i];
|
|
273
|
+
if (typeof value === 'string') {
|
|
274
|
+
const trimmed = value.trim();
|
|
275
|
+
if (trimmed) {
|
|
276
|
+
return trimmed;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveNowTemplate(value, formatDate) {
|
|
284
|
+
if (typeof value !== 'string') {
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
const nowText = typeof formatDate === 'function' ? formatDate() : '';
|
|
288
|
+
return value.replace(/\{now\}|\$\{now\}/g, nowText);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function validateContainerNameStrict(containerName) {
|
|
292
|
+
if (!SAFE_CONTAINER_NAME_PATTERN.test(containerName)) {
|
|
293
|
+
throw new Error(`containerName 非法: ${containerName}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function validateImageVersionStrict(imageVersion) {
|
|
298
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(imageVersion)) {
|
|
299
|
+
throw new Error(`imageVersion 非法: ${imageVersion}`);
|
|
300
|
+
}
|
|
301
|
+
if (!IMAGE_VERSION_TAG_PATTERN.test(imageVersion)) {
|
|
302
|
+
throw new Error(`imageVersion 格式必须为 <x.y.z-后缀>,例如 1.7.4-common。当前值: ${imageVersion}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function validateWebHostPath(hostPath) {
|
|
307
|
+
if (typeof hostPath !== 'string' || !hostPath.trim()) {
|
|
308
|
+
throw new Error('hostPath 不能为空');
|
|
309
|
+
}
|
|
310
|
+
if (!fs.existsSync(hostPath)) {
|
|
311
|
+
throw new Error(`宿主机路径不存在: ${hostPath}`);
|
|
312
|
+
}
|
|
313
|
+
const realHostPath = fs.realpathSync(hostPath);
|
|
314
|
+
const homeDir = process.env.HOME || os.homedir() || '/home';
|
|
315
|
+
if (realHostPath === '/' || realHostPath === '/home' || realHostPath === homeDir) {
|
|
316
|
+
throw new Error('不允许挂载根目录或home目录。');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseEnvEntry(entryText) {
|
|
321
|
+
const text = String(entryText || '');
|
|
322
|
+
const idx = text.indexOf('=');
|
|
323
|
+
if (idx <= 0) {
|
|
324
|
+
throw new Error(`env 格式应为 KEY=VALUE: ${text}`);
|
|
325
|
+
}
|
|
326
|
+
const key = text.slice(0, idx);
|
|
327
|
+
const value = text.slice(idx + 1);
|
|
328
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
329
|
+
throw new Error(`env key 非法: ${key}`);
|
|
330
|
+
}
|
|
331
|
+
if (/[\r\n\0]/.test(value) || /[;&|`$<>]/.test(value)) {
|
|
332
|
+
throw new Error(`env value 含非法字符: ${key}`);
|
|
333
|
+
}
|
|
334
|
+
return { key, value };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function normalizeEnvMap(envConfig, sourceLabel) {
|
|
338
|
+
if (envConfig === undefined || envConfig === null) {
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
341
|
+
if (typeof envConfig !== 'object' || Array.isArray(envConfig)) {
|
|
342
|
+
throw new Error(`${sourceLabel} 必须是对象(map)`);
|
|
343
|
+
}
|
|
344
|
+
const result = {};
|
|
345
|
+
for (const [key, rawValue] of Object.entries(envConfig)) {
|
|
346
|
+
if (rawValue !== null && !['string', 'number', 'boolean'].includes(typeof rawValue)) {
|
|
347
|
+
throw new Error(`${sourceLabel}.${key} 必须是 string/number/boolean/null`);
|
|
348
|
+
}
|
|
349
|
+
const parsed = parseEnvEntry(`${key}=${rawValue === null ? '' : String(rawValue)}`);
|
|
350
|
+
result[parsed.key] = parsed.value;
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function normalizeStringArray(value, sourceLabel) {
|
|
356
|
+
if (value === undefined || value === null) {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
if (!Array.isArray(value)) {
|
|
360
|
+
throw new Error(`${sourceLabel} 必须是数组`);
|
|
361
|
+
}
|
|
362
|
+
return value
|
|
363
|
+
.map(item => String(item || '').trim())
|
|
364
|
+
.filter(Boolean);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseEnvFileToArgs(filePath) {
|
|
368
|
+
if (!path.isAbsolute(filePath)) {
|
|
369
|
+
throw new Error(`envFile 仅支持绝对路径: ${filePath}`);
|
|
370
|
+
}
|
|
371
|
+
if (!fs.existsSync(filePath)) {
|
|
372
|
+
throw new Error(`未找到环境文件: ${filePath}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
376
|
+
const args = [];
|
|
377
|
+
const lines = content.split('\n');
|
|
378
|
+
|
|
379
|
+
for (let line of lines) {
|
|
380
|
+
const match = line.match(/^(?:export\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/);
|
|
381
|
+
if (!match) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const key = match[1];
|
|
386
|
+
let value = match[2].trim();
|
|
387
|
+
if (/[\r\n\0]/.test(value)) continue;
|
|
388
|
+
if (/[\$\(\)\`\|\&\*\{\};<>]/.test(value)) continue;
|
|
389
|
+
if (/^\(/.test(value)) continue;
|
|
390
|
+
|
|
391
|
+
if (/^"(.*)"$/.test(value)) {
|
|
392
|
+
value = value.slice(1, -1);
|
|
393
|
+
} else if (/^'(.*)'$/.test(value)) {
|
|
394
|
+
value = value.slice(1, -1);
|
|
395
|
+
}
|
|
396
|
+
args.push('--env', `${key}=${value}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return args;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function resolveContainerModeArgs(mode) {
|
|
403
|
+
const modeAliasMap = {
|
|
404
|
+
common: 'common',
|
|
405
|
+
'docker-in-docker': 'dind',
|
|
406
|
+
dind: 'dind',
|
|
407
|
+
d: 'dind',
|
|
408
|
+
'mount-docker-socket': 'sock',
|
|
409
|
+
sock: 'sock',
|
|
410
|
+
s: 'sock'
|
|
411
|
+
};
|
|
412
|
+
const normalized = modeAliasMap[String(mode || '').trim().toLowerCase()];
|
|
413
|
+
if (!normalized) {
|
|
414
|
+
throw new Error(`未知 containerMode: ${mode}`);
|
|
415
|
+
}
|
|
416
|
+
if (normalized === 'common') {
|
|
417
|
+
return { mode: 'common', args: [] };
|
|
418
|
+
}
|
|
419
|
+
if (normalized === 'dind') {
|
|
420
|
+
return { mode: 'dind', args: ['--privileged'] };
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
mode: 'sock',
|
|
424
|
+
args: [
|
|
425
|
+
'--privileged',
|
|
426
|
+
'--volume', '/var/run/docker.sock:/var/run/docker.sock',
|
|
427
|
+
'--env', 'DOCKER_HOST=unix:///var/run/docker.sock',
|
|
428
|
+
'--env', 'CONTAINER_HOST=unix:///var/run/docker.sock'
|
|
429
|
+
]
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function resolveYoloCommand(yolo) {
|
|
434
|
+
const key = String(yolo || '').trim().toLowerCase();
|
|
435
|
+
if (!key) {
|
|
436
|
+
return '';
|
|
437
|
+
}
|
|
438
|
+
const mapped = YOLO_COMMAND_MAP[key];
|
|
439
|
+
if (!mapped) {
|
|
440
|
+
throw new Error(`未知 yolo 值: ${yolo}`);
|
|
441
|
+
}
|
|
442
|
+
return mapped;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function buildDefaultCommand(shellPrefix, shell, shellSuffix) {
|
|
446
|
+
const parts = [];
|
|
447
|
+
if (shellPrefix && String(shellPrefix).trim()) {
|
|
448
|
+
parts.push(String(shellPrefix).trim());
|
|
449
|
+
}
|
|
450
|
+
if (shell && String(shell).trim()) {
|
|
451
|
+
parts.push(String(shell).trim());
|
|
452
|
+
}
|
|
453
|
+
if (shellSuffix && String(shellSuffix).trim()) {
|
|
454
|
+
parts.push(String(shellSuffix).trim());
|
|
455
|
+
}
|
|
456
|
+
return parts.join(' ').trim();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function validateWebConfigShape(configObject) {
|
|
460
|
+
const config = toPlainObject(configObject);
|
|
461
|
+
|
|
462
|
+
if (hasOwn(config, 'containerName') && String(config.containerName || '').trim()) {
|
|
463
|
+
validateContainerNameStrict(resolveNowTemplate(String(config.containerName), () => '0101-0000'));
|
|
464
|
+
}
|
|
465
|
+
if (hasOwn(config, 'imageVersion') && String(config.imageVersion || '').trim()) {
|
|
466
|
+
validateImageVersionStrict(String(config.imageVersion).trim());
|
|
467
|
+
}
|
|
468
|
+
if (hasOwn(config, 'env')) {
|
|
469
|
+
normalizeEnvMap(config.env, 'env');
|
|
470
|
+
}
|
|
471
|
+
if (hasOwn(config, 'envFile')) {
|
|
472
|
+
normalizeStringArray(config.envFile, 'envFile');
|
|
473
|
+
}
|
|
474
|
+
if (hasOwn(config, 'volumes')) {
|
|
475
|
+
normalizeStringArray(config.volumes, 'volumes');
|
|
476
|
+
}
|
|
477
|
+
if (hasOwn(config, 'runs')) {
|
|
478
|
+
const runs = config.runs;
|
|
479
|
+
if (runs !== undefined && (typeof runs !== 'object' || runs === null || Array.isArray(runs))) {
|
|
480
|
+
throw new Error('runs 必须是对象(map)');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function readWebConfigSnapshot(configPath) {
|
|
486
|
+
const resolvedPath = path.resolve(configPath || getDefaultWebConfigPath());
|
|
487
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
488
|
+
return {
|
|
489
|
+
path: resolvedPath,
|
|
490
|
+
raw: DEFAULT_WEB_CONFIG_TEMPLATE,
|
|
491
|
+
parsed: {},
|
|
492
|
+
parseError: null,
|
|
493
|
+
exists: false
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const raw = fs.readFileSync(resolvedPath, 'utf-8');
|
|
498
|
+
if (!String(raw || '').trim()) {
|
|
499
|
+
return {
|
|
500
|
+
path: resolvedPath,
|
|
501
|
+
raw,
|
|
502
|
+
parsed: {},
|
|
503
|
+
parseError: null,
|
|
504
|
+
exists: true
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
const parsed = JSON5.parse(raw);
|
|
510
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
511
|
+
throw new Error('配置根节点必须是对象(map)');
|
|
512
|
+
}
|
|
513
|
+
const config = toPlainObject(parsed);
|
|
514
|
+
validateWebConfigShape(config);
|
|
515
|
+
return {
|
|
516
|
+
path: resolvedPath,
|
|
517
|
+
raw,
|
|
518
|
+
parsed: config,
|
|
519
|
+
parseError: null,
|
|
520
|
+
exists: true
|
|
521
|
+
};
|
|
522
|
+
} catch (e) {
|
|
523
|
+
return {
|
|
524
|
+
path: resolvedPath,
|
|
525
|
+
raw,
|
|
526
|
+
parsed: {},
|
|
527
|
+
parseError: e && e.message ? e.message : '配置解析失败',
|
|
528
|
+
exists: true
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function parseAndValidateConfigRaw(raw) {
|
|
534
|
+
const parsed = JSON5.parse(String(raw || ''));
|
|
535
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
536
|
+
throw new Error('配置根节点必须是对象(map)');
|
|
537
|
+
}
|
|
538
|
+
const config = toPlainObject(parsed);
|
|
539
|
+
validateWebConfigShape(config);
|
|
540
|
+
return config;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function buildConfigDefaults(ctx, config) {
|
|
544
|
+
const parsed = toPlainObject(config);
|
|
545
|
+
const defaults = {
|
|
546
|
+
containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
|
|
547
|
+
hostPath: pickFirstString(parsed.hostPath, ctx.hostPath),
|
|
548
|
+
containerPath: pickFirstString(parsed.containerPath, ctx.containerPath),
|
|
549
|
+
imageName: pickFirstString(parsed.imageName, ctx.imageName),
|
|
550
|
+
imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
|
|
551
|
+
containerMode: hasOwn(parsed, 'containerMode') ? String(parsed.containerMode || '') : '',
|
|
552
|
+
shellPrefix: hasOwn(parsed, 'shellPrefix') ? String(parsed.shellPrefix || '') : '',
|
|
553
|
+
shell: hasOwn(parsed, 'shell') ? String(parsed.shell || '') : '',
|
|
554
|
+
shellSuffix: hasOwn(parsed, 'shellSuffix') ? String(parsed.shellSuffix || '') : '',
|
|
555
|
+
yolo: hasOwn(parsed, 'yolo') ? String(parsed.yolo || '') : '',
|
|
556
|
+
env: {},
|
|
557
|
+
envFile: [],
|
|
558
|
+
volumes: []
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
defaults.env = normalizeEnvMap(parsed.env, 'env');
|
|
563
|
+
} catch (e) {
|
|
564
|
+
defaults.env = {};
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
defaults.envFile = normalizeStringArray(parsed.envFile, 'envFile');
|
|
568
|
+
} catch (e) {
|
|
569
|
+
defaults.envFile = [];
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
defaults.volumes = normalizeStringArray(parsed.volumes, 'volumes');
|
|
573
|
+
} catch (e) {
|
|
574
|
+
defaults.volumes = [];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return defaults;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function buildStaticContainerRuntime(ctx, containerName) {
|
|
581
|
+
return {
|
|
582
|
+
containerName,
|
|
583
|
+
hostPath: ctx.hostPath,
|
|
584
|
+
containerPath: ctx.containerPath,
|
|
585
|
+
imageName: ctx.imageName,
|
|
586
|
+
imageVersion: ctx.imageVersion,
|
|
587
|
+
contModeArgs: Array.isArray(ctx.contModeArgs) ? ctx.contModeArgs.slice() : [],
|
|
588
|
+
containerEnvs: Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [],
|
|
589
|
+
containerVolumes: Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [],
|
|
590
|
+
defaultCommand: buildDefaultCommand(ctx.execCommandPrefix, ctx.execCommand, ctx.execCommandSuffix) || '/bin/bash'
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function buildCreateRuntime(ctx, state, payload) {
|
|
595
|
+
const body = toPlainObject(payload);
|
|
596
|
+
const requestOptions = toPlainObject(body.createOptions);
|
|
597
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
598
|
+
const config = snapshot.parseError ? {} : snapshot.parsed;
|
|
599
|
+
|
|
600
|
+
const hasRequestEnv = hasOwn(requestOptions, 'env');
|
|
601
|
+
const hasRequestEnvFile = hasOwn(requestOptions, 'envFile');
|
|
602
|
+
const hasRequestVolumes = hasOwn(requestOptions, 'volumes');
|
|
603
|
+
const hasConfigEnv = hasOwn(config, 'env');
|
|
604
|
+
const hasConfigEnvFile = hasOwn(config, 'envFile');
|
|
605
|
+
const hasConfigVolumes = hasOwn(config, 'volumes');
|
|
606
|
+
|
|
607
|
+
const requestName = pickFirstString(requestOptions.containerName, body.name);
|
|
608
|
+
let containerName = pickFirstString(requestName, config.containerName);
|
|
609
|
+
if (!containerName) {
|
|
610
|
+
containerName = `my-${ctx.formatDate()}`;
|
|
611
|
+
}
|
|
612
|
+
containerName = resolveNowTemplate(containerName, ctx.formatDate);
|
|
613
|
+
validateContainerNameStrict(containerName);
|
|
614
|
+
|
|
615
|
+
const hostPath = pickFirstString(requestOptions.hostPath, config.hostPath, ctx.hostPath);
|
|
616
|
+
validateWebHostPath(hostPath);
|
|
617
|
+
|
|
618
|
+
const containerPath = pickFirstString(requestOptions.containerPath, config.containerPath, ctx.containerPath, hostPath) || hostPath;
|
|
619
|
+
const imageName = pickFirstString(requestOptions.imageName, config.imageName, ctx.imageName);
|
|
620
|
+
const imageVersion = pickFirstString(requestOptions.imageVersion, config.imageVersion, ctx.imageVersion);
|
|
621
|
+
|
|
622
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/:-]*$/.test(imageName)) {
|
|
623
|
+
throw new Error(`imageName 非法: ${imageName}`);
|
|
624
|
+
}
|
|
625
|
+
validateImageVersionStrict(imageVersion);
|
|
626
|
+
|
|
627
|
+
let contModeArgs = Array.isArray(ctx.contModeArgs) ? ctx.contModeArgs.slice() : [];
|
|
628
|
+
let containerMode = '';
|
|
629
|
+
const modeValue = pickFirstString(requestOptions.containerMode, config.containerMode);
|
|
630
|
+
if (modeValue) {
|
|
631
|
+
const mode = resolveContainerModeArgs(modeValue);
|
|
632
|
+
containerMode = mode.mode;
|
|
633
|
+
contModeArgs = mode.args;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const shellPrefix = hasOwn(requestOptions, 'shellPrefix')
|
|
637
|
+
? String(requestOptions.shellPrefix || '')
|
|
638
|
+
: (hasOwn(config, 'shellPrefix') ? String(config.shellPrefix || '') : String(ctx.execCommandPrefix || ''));
|
|
639
|
+
let shell = hasOwn(requestOptions, 'shell')
|
|
640
|
+
? String(requestOptions.shell || '')
|
|
641
|
+
: (hasOwn(config, 'shell') ? String(config.shell || '') : String(ctx.execCommand || ''));
|
|
642
|
+
const shellSuffix = hasOwn(requestOptions, 'shellSuffix')
|
|
643
|
+
? String(requestOptions.shellSuffix || '')
|
|
644
|
+
: (hasOwn(config, 'shellSuffix') ? String(config.shellSuffix || '') : String(ctx.execCommandSuffix || ''));
|
|
645
|
+
const yolo = hasOwn(requestOptions, 'yolo')
|
|
646
|
+
? String(requestOptions.yolo || '')
|
|
647
|
+
: (hasOwn(config, 'yolo') ? String(config.yolo || '') : '');
|
|
648
|
+
const yoloCommand = resolveYoloCommand(yolo);
|
|
649
|
+
if (yoloCommand) {
|
|
650
|
+
shell = yoloCommand;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let containerEnvs = Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [];
|
|
654
|
+
if (hasRequestEnv || hasRequestEnvFile || hasConfigEnv || hasConfigEnvFile) {
|
|
655
|
+
const configEnv = normalizeEnvMap(config.env, 'config.env');
|
|
656
|
+
const requestEnv = hasRequestEnv ? normalizeEnvMap(requestOptions.env, 'createOptions.env') : {};
|
|
657
|
+
const mergedEnv = { ...configEnv, ...requestEnv };
|
|
658
|
+
const envArgs = [];
|
|
659
|
+
Object.entries(mergedEnv).forEach(([key, value]) => {
|
|
660
|
+
const parsed = parseEnvEntry(`${key}=${value}`);
|
|
661
|
+
envArgs.push('--env', `${parsed.key}=${parsed.value}`);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const envFileList = hasRequestEnvFile
|
|
665
|
+
? normalizeStringArray(requestOptions.envFile, 'createOptions.envFile')
|
|
666
|
+
: normalizeStringArray(config.envFile, 'config.envFile');
|
|
667
|
+
const envFileArgs = [];
|
|
668
|
+
envFileList.forEach(filePath => {
|
|
669
|
+
envFileArgs.push(...parseEnvFileToArgs(filePath));
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
containerEnvs = [...envArgs, ...envFileArgs];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
let containerVolumes = Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [];
|
|
676
|
+
if (hasRequestVolumes || hasConfigVolumes) {
|
|
677
|
+
const volumeList = hasRequestVolumes
|
|
678
|
+
? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes')
|
|
679
|
+
: normalizeStringArray(config.volumes, 'config.volumes');
|
|
680
|
+
containerVolumes = [];
|
|
681
|
+
volumeList.forEach(volume => {
|
|
682
|
+
containerVolumes.push('--volume', volume);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
containerName,
|
|
688
|
+
hostPath,
|
|
689
|
+
containerPath,
|
|
690
|
+
imageName,
|
|
691
|
+
imageVersion,
|
|
692
|
+
contModeArgs,
|
|
693
|
+
containerEnvs,
|
|
694
|
+
containerVolumes,
|
|
695
|
+
defaultCommand: buildDefaultCommand(shellPrefix, shell, shellSuffix) || '/bin/bash',
|
|
696
|
+
applied: {
|
|
697
|
+
containerName,
|
|
698
|
+
hostPath,
|
|
699
|
+
containerPath,
|
|
700
|
+
imageName,
|
|
701
|
+
imageVersion,
|
|
702
|
+
containerMode,
|
|
703
|
+
shellPrefix: shellPrefix || '',
|
|
704
|
+
shell: shell || '',
|
|
705
|
+
shellSuffix: shellSuffix || '',
|
|
706
|
+
yolo: yolo || '',
|
|
707
|
+
envCount: Math.floor(containerEnvs.length / 2),
|
|
708
|
+
volumeCount: Math.floor(containerVolumes.length / 2)
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
221
713
|
function listWebManyoyoContainers(ctx) {
|
|
222
714
|
const output = ctx.dockerExecArgs(
|
|
223
715
|
['ps', '-a', '--filter', 'label=manyoyo.default_cmd', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
|
|
@@ -244,19 +736,26 @@ function listWebManyoyoContainers(ctx) {
|
|
|
244
736
|
return map;
|
|
245
737
|
}
|
|
246
738
|
|
|
247
|
-
async function ensureWebContainer(ctx, state,
|
|
248
|
-
|
|
249
|
-
|
|
739
|
+
async function ensureWebContainer(ctx, state, containerInput) {
|
|
740
|
+
const runtime = typeof containerInput === 'string'
|
|
741
|
+
? buildStaticContainerRuntime(ctx, containerInput)
|
|
742
|
+
: containerInput;
|
|
743
|
+
|
|
744
|
+
if (!runtime || !runtime.containerName) {
|
|
745
|
+
throw new Error('containerName 不能为空');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (!ctx.containerExists(runtime.containerName)) {
|
|
250
749
|
const args = buildContainerRunArgs({
|
|
251
|
-
containerName,
|
|
252
|
-
hostPath:
|
|
253
|
-
containerPath:
|
|
254
|
-
imageName:
|
|
255
|
-
imageVersion:
|
|
256
|
-
contModeArgs:
|
|
257
|
-
containerEnvs:
|
|
258
|
-
containerVolumes:
|
|
259
|
-
defaultCommand:
|
|
750
|
+
containerName: runtime.containerName,
|
|
751
|
+
hostPath: runtime.hostPath,
|
|
752
|
+
containerPath: runtime.containerPath,
|
|
753
|
+
imageName: runtime.imageName,
|
|
754
|
+
imageVersion: runtime.imageVersion,
|
|
755
|
+
contModeArgs: runtime.contModeArgs,
|
|
756
|
+
containerEnvs: runtime.containerEnvs,
|
|
757
|
+
containerVolumes: runtime.containerVolumes,
|
|
758
|
+
defaultCommand: runtime.defaultCommand
|
|
260
759
|
});
|
|
261
760
|
|
|
262
761
|
try {
|
|
@@ -266,33 +765,58 @@ async function ensureWebContainer(ctx, state, containerName) {
|
|
|
266
765
|
throw e;
|
|
267
766
|
}
|
|
268
767
|
|
|
269
|
-
await ctx.waitForContainerReady(containerName);
|
|
270
|
-
appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已创建并启动。`);
|
|
768
|
+
await ctx.waitForContainerReady(runtime.containerName);
|
|
769
|
+
appendWebSessionMessage(state.webHistoryDir, runtime.containerName, 'system', `容器 ${runtime.containerName} 已创建并启动。`);
|
|
271
770
|
return;
|
|
272
771
|
}
|
|
273
772
|
|
|
274
|
-
const status = ctx.getContainerStatus(containerName);
|
|
773
|
+
const status = ctx.getContainerStatus(runtime.containerName);
|
|
275
774
|
if (status !== 'running') {
|
|
276
|
-
ctx.dockerExecArgs(['start', containerName], { stdio: 'pipe' });
|
|
277
|
-
appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已启动。`);
|
|
775
|
+
ctx.dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
|
|
776
|
+
appendWebSessionMessage(state.webHistoryDir, runtime.containerName, 'system', `容器 ${runtime.containerName} 已启动。`);
|
|
278
777
|
}
|
|
279
778
|
}
|
|
280
779
|
|
|
281
|
-
function execCommandInWebContainer(ctx, containerName, command) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
780
|
+
async function execCommandInWebContainer(ctx, containerName, command) {
|
|
781
|
+
return await new Promise((resolve, reject) => {
|
|
782
|
+
const process = spawn(
|
|
783
|
+
ctx.dockerCmd,
|
|
784
|
+
['exec', containerName, '/bin/bash', '-lc', command],
|
|
785
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
786
|
+
);
|
|
286
787
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
788
|
+
const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
|
|
789
|
+
let rawOutput = '';
|
|
790
|
+
let outputTruncated = false;
|
|
791
|
+
|
|
792
|
+
function appendChunk(chunk) {
|
|
793
|
+
if (!chunk) return;
|
|
794
|
+
const text = chunk.toString('utf-8');
|
|
795
|
+
if (!text) return;
|
|
796
|
+
if (rawOutput.length >= MAX_RAW_OUTPUT_CHARS) {
|
|
797
|
+
outputTruncated = true;
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const remain = MAX_RAW_OUTPUT_CHARS - rawOutput.length;
|
|
801
|
+
if (text.length > remain) {
|
|
802
|
+
rawOutput += text.slice(0, remain);
|
|
803
|
+
outputTruncated = true;
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
rawOutput += text;
|
|
807
|
+
}
|
|
290
808
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const output = clipText(stripAnsi(rawOutput).trim() || '(无输出)');
|
|
809
|
+
process.stdout.on('data', appendChunk);
|
|
810
|
+
process.stderr.on('data', appendChunk);
|
|
294
811
|
|
|
295
|
-
|
|
812
|
+
process.on('error', reject);
|
|
813
|
+
process.on('close', code => {
|
|
814
|
+
const exitCode = typeof code === 'number' ? code : 1;
|
|
815
|
+
const clippedRaw = outputTruncated ? `${rawOutput}\n...[raw-truncated]` : rawOutput;
|
|
816
|
+
const output = clipText(stripAnsi(clippedRaw).trim() || '(无输出)');
|
|
817
|
+
resolve({ exitCode, output });
|
|
818
|
+
});
|
|
819
|
+
});
|
|
296
820
|
}
|
|
297
821
|
|
|
298
822
|
function readRequestBody(req) {
|
|
@@ -694,6 +1218,51 @@ function sendWebUnauthorized(res, pathname) {
|
|
|
694
1218
|
|
|
695
1219
|
async function handleWebApi(req, res, pathname, ctx, state) {
|
|
696
1220
|
const routes = [
|
|
1221
|
+
{
|
|
1222
|
+
method: 'GET',
|
|
1223
|
+
match: currentPath => currentPath === '/api/config' ? [] : null,
|
|
1224
|
+
handler: async () => {
|
|
1225
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
1226
|
+
const defaults = buildConfigDefaults(ctx, snapshot.parseError ? {} : snapshot.parsed);
|
|
1227
|
+
sendJson(res, 200, {
|
|
1228
|
+
path: snapshot.path,
|
|
1229
|
+
raw: snapshot.raw,
|
|
1230
|
+
parsed: snapshot.parseError ? null : snapshot.parsed,
|
|
1231
|
+
parseError: snapshot.parseError,
|
|
1232
|
+
defaults
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
method: 'PUT',
|
|
1238
|
+
match: currentPath => currentPath === '/api/config' ? [] : null,
|
|
1239
|
+
handler: async () => {
|
|
1240
|
+
const payload = await readJsonBody(req);
|
|
1241
|
+
const raw = typeof payload.raw === 'string' ? payload.raw : '';
|
|
1242
|
+
if (!raw.trim()) {
|
|
1243
|
+
sendJson(res, 400, { error: '配置内容不能为空' });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
let parsed = null;
|
|
1248
|
+
try {
|
|
1249
|
+
parsed = parseAndValidateConfigRaw(raw);
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
sendJson(res, 400, { error: '配置格式错误', detail: e.message || '解析失败' });
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const savePath = path.resolve(state.webConfigPath);
|
|
1256
|
+
fs.mkdirSync(path.dirname(savePath), { recursive: true });
|
|
1257
|
+
fs.writeFileSync(savePath, raw, 'utf-8');
|
|
1258
|
+
|
|
1259
|
+
sendJson(res, 200, {
|
|
1260
|
+
saved: true,
|
|
1261
|
+
path: savePath,
|
|
1262
|
+
defaults: buildConfigDefaults(ctx, parsed)
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
},
|
|
697
1266
|
{
|
|
698
1267
|
method: 'GET',
|
|
699
1268
|
match: currentPath => currentPath === '/api/sessions' ? [] : null,
|
|
@@ -720,17 +1289,16 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
720
1289
|
match: currentPath => currentPath === '/api/sessions' ? [] : null,
|
|
721
1290
|
handler: async () => {
|
|
722
1291
|
const payload = await readJsonBody(req);
|
|
723
|
-
let
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
1292
|
+
let runtime = null;
|
|
1293
|
+
try {
|
|
1294
|
+
runtime = buildCreateRuntime(ctx, state, payload);
|
|
1295
|
+
} catch (e) {
|
|
1296
|
+
sendJson(res, 400, { error: e.message || '创建参数错误' });
|
|
729
1297
|
return;
|
|
730
1298
|
}
|
|
731
1299
|
|
|
732
|
-
await ensureWebContainer(ctx, state,
|
|
733
|
-
sendJson(res, 200, { name: containerName });
|
|
1300
|
+
await ensureWebContainer(ctx, state, runtime);
|
|
1301
|
+
sendJson(res, 200, { name: runtime.containerName, applied: runtime.applied });
|
|
734
1302
|
}
|
|
735
1303
|
},
|
|
736
1304
|
{
|
|
@@ -763,12 +1331,12 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
763
1331
|
|
|
764
1332
|
await ensureWebContainer(ctx, state, containerName);
|
|
765
1333
|
appendWebSessionMessage(state.webHistoryDir, containerName, 'user', command);
|
|
766
|
-
const result = execCommandInWebContainer(ctx, containerName, command);
|
|
1334
|
+
const result = await execCommandInWebContainer(ctx, containerName, command);
|
|
767
1335
|
appendWebSessionMessage(
|
|
768
1336
|
state.webHistoryDir,
|
|
769
1337
|
containerName,
|
|
770
1338
|
'assistant',
|
|
771
|
-
|
|
1339
|
+
result.output,
|
|
772
1340
|
{ exitCode: result.exitCode }
|
|
773
1341
|
);
|
|
774
1342
|
sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
|
|
@@ -862,6 +1430,7 @@ async function startWebServer(options) {
|
|
|
862
1430
|
|
|
863
1431
|
const state = {
|
|
864
1432
|
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
1433
|
+
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
865
1434
|
authSessions: new Map(),
|
|
866
1435
|
terminalSessions: new Map()
|
|
867
1436
|
};
|