@xcanwin/manyoyo 7.1.6 → 8.1.0
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 +33 -169
- package/bin/manyoyo.js +604 -231
- package/docker/manyoyo.Dockerfile +10 -4
- package/lib/app-launcher.js +220 -0
- package/lib/app-update.js +324 -0
- package/lib/container-run.js +3 -0
- package/lib/container-runtime.js +121 -0
- package/lib/doctor.js +77 -14
- package/lib/download-verified.js +48 -0
- package/lib/error-hints.js +96 -0
- package/lib/global-config.js +4 -4
- package/lib/headless.js +53 -0
- package/lib/image-build.js +11 -9
- package/lib/image-prune.js +44 -0
- package/lib/image-pull.js +76 -0
- package/lib/image-version-policy.js +26 -0
- package/lib/init-config.js +79 -19
- package/lib/login-token.js +92 -0
- package/lib/mirrors.js +71 -0
- package/lib/offline-import.js +60 -0
- package/lib/plugin/playwright.js +15 -3
- package/lib/podman-passthrough.js +80 -0
- package/lib/proxy-config.js +138 -0
- package/lib/runtime-heal.js +175 -0
- package/lib/runtime-normalizers.js +50 -0
- package/lib/secure-file.js +38 -0
- package/lib/setup-cli.js +215 -0
- package/lib/setup-config.js +32 -0
- package/lib/setup.js +188 -0
- package/lib/uninstall.js +296 -0
- package/lib/update-check.js +91 -0
- package/lib/web/AGENTS.md +4 -6
- package/lib/web/index.html +213 -0
- package/lib/web/server.js +653 -51
- package/lib/worktrees.js +15 -0
- package/manyoyo.example.json +14 -3
- package/package.json +14 -10
- package/lib/web/frontend/shadcn.html +0 -213
package/lib/web/server.js
CHANGED
|
@@ -5,14 +5,25 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const crypto = require('crypto');
|
|
8
|
+
const { consumeLoginToken, isLoopbackHost } = require('../login-token');
|
|
8
9
|
const http = require('http');
|
|
9
10
|
const WebSocket = require('ws');
|
|
10
11
|
const JSON5 = require('json5');
|
|
11
12
|
const { buildContainerRunArgs } = require('../container-run');
|
|
13
|
+
const { applyAptMirror } = require('../mirrors');
|
|
14
|
+
const { normalizeMirrors } = require('../runtime-normalizers');
|
|
12
15
|
const { estimateContainerCapacity } = require('../capacity');
|
|
13
16
|
const { extractAgentMessageFromCodexJsonl } = require('../codex-output');
|
|
14
17
|
const { findValueRangeByPath, applyTextReplacements, upsertValueByPath } = require('../json5-text-edit');
|
|
15
18
|
const { resolveRuntimeConfig } = require('../runtime-resolver');
|
|
19
|
+
const { AGENT_ENV_SCHEMA } = require('../init-config');
|
|
20
|
+
const { ensureImagePresent, pullImageProcess } = require('../image-pull');
|
|
21
|
+
const { waitForImport } = require('../offline-import');
|
|
22
|
+
const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw } = require('../setup-config');
|
|
23
|
+
const { listSetupAgents, getConfiguredAgents, buildAgentRunProfile, classifyConnectionResult, redactSecrets, validateSetupPassword, listMirrorPresets } = require('../setup');
|
|
24
|
+
const { applyDoctorFixes } = require('../doctor');
|
|
25
|
+
const { writeConfigFileSecure } = require('../secure-file');
|
|
26
|
+
const { sanitizeSensitiveData, sanitizeServeLogText } = require('../serve-log');
|
|
16
27
|
const {
|
|
17
28
|
parseEnvEntry,
|
|
18
29
|
expandHomeAliasPath,
|
|
@@ -76,7 +87,7 @@ const WEB_CONFIG_KEEP_SECRET_PLACEHOLDER = '***HIDDEN_SECRET***';
|
|
|
76
87
|
const DEFAULT_AGENT_STREAM_HEARTBEAT_MS = 15 * 1000;
|
|
77
88
|
// token 级增量的历史落盘节流:写盘只为断线/刷新后能对账恢复,不需要逐 token 持久化
|
|
78
89
|
const AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS = 1000;
|
|
79
|
-
const
|
|
90
|
+
const INDEX_HTML_PATH = path.join(__dirname, 'index.html');
|
|
80
91
|
const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
81
92
|
const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
|
|
82
93
|
const SENSITIVE_CONFIG_KEY_PATTERN = /(pass(word)?|passwd|secret|token|api(?:_|-)?key|auth(?:_|-)?token|oauth(?:_|-)?token)$/i;
|
|
@@ -87,7 +98,7 @@ const DEFAULT_WEB_CONFIG_TEMPLATE = `{
|
|
|
87
98
|
"containerName": "my-dev",
|
|
88
99
|
"hostPath": "/path/to/your/project",
|
|
89
100
|
"containerPath": "/path/to/your/project",
|
|
90
|
-
"imageName": "
|
|
101
|
+
"imageName": "ghcr.io/xcanwin/manyoyo",
|
|
91
102
|
"imageVersion": "1.7.4-common",
|
|
92
103
|
"containerMode": "common",
|
|
93
104
|
"shellPrefix": "",
|
|
@@ -1188,7 +1199,7 @@ function runAgentProtocolProcess(ctx, containerName, command, options = {}) {
|
|
|
1188
1199
|
let stdoutAll = '';
|
|
1189
1200
|
let child;
|
|
1190
1201
|
try {
|
|
1191
|
-
child = spawn(ctx.dockerCmd, ['exec', '-i', containerName, '/bin/bash', '-lc', command], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1202
|
+
child = spawn(ctx.dockerCmd, ['exec', '-i', containerName, '/bin/bash', '-lc', command], { stdio: ['pipe', 'pipe', 'pipe'], env: ctx.dockerEnv });
|
|
1192
1203
|
} catch (e) {
|
|
1193
1204
|
resolve({ result: null, stdout: '' });
|
|
1194
1205
|
return;
|
|
@@ -2587,6 +2598,9 @@ function validateWebConfigShape(configObject) {
|
|
|
2587
2598
|
if (hasOwn(config, 'ports')) {
|
|
2588
2599
|
normalizeStringArray(config.ports, 'ports');
|
|
2589
2600
|
}
|
|
2601
|
+
if (hasOwn(config, 'mirrors')) {
|
|
2602
|
+
normalizeMirrors(config.mirrors);
|
|
2603
|
+
}
|
|
2590
2604
|
if (hasOwn(config, 'agentPromptCommand')) {
|
|
2591
2605
|
normalizeAgentPromptCommandTemplate(config.agentPromptCommand, 'agentPromptCommand');
|
|
2592
2606
|
}
|
|
@@ -3179,6 +3193,138 @@ function listWebManyoyoContainers(ctx) {
|
|
|
3179
3193
|
return map;
|
|
3180
3194
|
}
|
|
3181
3195
|
|
|
3196
|
+
function webImageRef(ctx, runtime) {
|
|
3197
|
+
return `${runtime ? runtime.imageName : ctx.imageName}:${runtime ? runtime.imageVersion : ctx.imageVersion}`;
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
const IMAGE_INSPECT_TIMEOUT_MS = 10000;
|
|
3201
|
+
|
|
3202
|
+
// 异步 + 超时:daemon 卡住时不能占住事件循环(向导每隔几秒轮询 /api/setup/status)
|
|
3203
|
+
async function isWebImagePresent(ctx, imageRef) {
|
|
3204
|
+
try {
|
|
3205
|
+
if (ctx.dockerExecAsync) {
|
|
3206
|
+
await ctx.dockerExecAsync(['image', 'inspect', imageRef], { timeout: IMAGE_INSPECT_TIMEOUT_MS });
|
|
3207
|
+
} else {
|
|
3208
|
+
ctx.dockerExecArgs(['image', 'inspect', imageRef], { stdio: 'pipe' });
|
|
3209
|
+
}
|
|
3210
|
+
return true;
|
|
3211
|
+
} catch (e) {
|
|
3212
|
+
return false;
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
function createImagePreparingError(message) {
|
|
3217
|
+
const error = new Error(message);
|
|
3218
|
+
error.statusCode = 409;
|
|
3219
|
+
error.imagePreparing = true;
|
|
3220
|
+
return error;
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
// 本地没有镜像就在后台拉取(异步进程,不阻塞事件循环);同一镜像并发请求共用一次拉取。
|
|
3224
|
+
// 进度写在 state.imagePulls,/api/setup/status 会读它。
|
|
3225
|
+
async function ensureWebImage(ctx, state, imageRef, options = {}) {
|
|
3226
|
+
// 离线安装器正在后台导入镜像:不要同时去仓库拉同一个镜像。
|
|
3227
|
+
// 面向请求的调用(options.failFast)直接返回“镜像准备中”,不把 HTTP 请求挂起几分钟;后台预拉取才等待。
|
|
3228
|
+
if (ctx.importState().active) {
|
|
3229
|
+
if (options.failFast) {
|
|
3230
|
+
throw createImagePreparingError('镜像正在导入,请稍后再试(向导顶部进度条显示进度)');
|
|
3231
|
+
}
|
|
3232
|
+
await waitForImport({ readState: ctx.importState });
|
|
3233
|
+
}
|
|
3234
|
+
const current = state.imagePulls.get(imageRef);
|
|
3235
|
+
if (current && current.status === 'pulling') {
|
|
3236
|
+
if (options.failFast) {
|
|
3237
|
+
throw createImagePreparingError('镜像正在拉取,请稍后再试(向导顶部进度条显示进度)');
|
|
3238
|
+
}
|
|
3239
|
+
await current.promise;
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
if (await isWebImagePresent(ctx, imageRef)) {
|
|
3243
|
+
state.imagePulls.delete(imageRef);
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
const entry = { status: 'pulling', message: '正在拉取镜像', promise: null };
|
|
3247
|
+
entry.promise = ensureImagePresent({
|
|
3248
|
+
imageRef,
|
|
3249
|
+
command: ctx.dockerCmd,
|
|
3250
|
+
isPresent: () => false,
|
|
3251
|
+
pull: () => pullImageProcess({
|
|
3252
|
+
command: ctx.dockerCmd,
|
|
3253
|
+
env: ctx.dockerEnv,
|
|
3254
|
+
imageRef,
|
|
3255
|
+
onOutput: text => {
|
|
3256
|
+
const lastLine = String(text).split(/[\r\n]+/).map(line => line.trim()).filter(Boolean).pop();
|
|
3257
|
+
if (lastLine) entry.message = lastLine.slice(0, 200);
|
|
3258
|
+
}
|
|
3259
|
+
})
|
|
3260
|
+
}).then(() => {
|
|
3261
|
+
state.imagePulls.delete(imageRef);
|
|
3262
|
+
}, error => {
|
|
3263
|
+
entry.status = 'failed';
|
|
3264
|
+
entry.message = error.message;
|
|
3265
|
+
throw error;
|
|
3266
|
+
});
|
|
3267
|
+
state.imagePulls.set(imageRef, entry);
|
|
3268
|
+
await entry.promise;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
// serve 启动后等容器环境就绪,再在后台把默认镜像拉好,首次向导的进度条才会走完
|
|
3272
|
+
function startWebImagePrepare(ctx, state) {
|
|
3273
|
+
const imageRef = webImageRef(ctx);
|
|
3274
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
3275
|
+
const tick = () => {
|
|
3276
|
+
const status = ctx.runtimeState.status;
|
|
3277
|
+
if (status === 'ready') {
|
|
3278
|
+
ensureWebImage(ctx, state, imageRef).catch(e => ctx.logger.warn('image prepare failed', { message: e.message }));
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
3281
|
+
if (status === 'failed' || Date.now() > deadline) return;
|
|
3282
|
+
setTimeout(tick, 1000).unref();
|
|
3283
|
+
};
|
|
3284
|
+
tick();
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
const SETUP_TEST_CONTAINER_PREFIX = 'manyoyo-setup-test-';
|
|
3288
|
+
|
|
3289
|
+
// 连接测试的一次性容器带着用户的 API Key:serve 被强杀时 finally 不会执行,下次启动(运行时就绪后)清掉残留
|
|
3290
|
+
async function cleanupStaleSetupTestContainers(ctx) {
|
|
3291
|
+
if (!ctx.dockerExecAsync) return;
|
|
3292
|
+
try {
|
|
3293
|
+
const listed = await ctx.dockerExecAsync(['ps', '-a', '--filter', `name=${SETUP_TEST_CONTAINER_PREFIX}`, '--format', '{{.Names}}'], { timeout: 10000 });
|
|
3294
|
+
const names = String(listed || '').split('\n').map(line => line.trim())
|
|
3295
|
+
.filter(name => name.startsWith(SETUP_TEST_CONTAINER_PREFIX) && /^[A-Za-z0-9_.-]+$/.test(name));
|
|
3296
|
+
for (const name of names) {
|
|
3297
|
+
await ctx.dockerExecAsync(['rm', '-f', name], { timeout: 20000 }).catch(() => {});
|
|
3298
|
+
}
|
|
3299
|
+
} catch (e) {
|
|
3300
|
+
ctx.logger.warn('cleanup stale setup test containers failed', { message: e && e.message });
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
function startStaleSetupCleanup(ctx) {
|
|
3305
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
3306
|
+
const tick = () => {
|
|
3307
|
+
const status = ctx.runtimeState.status;
|
|
3308
|
+
if (status === 'ready') {
|
|
3309
|
+
cleanupStaleSetupTestContainers(ctx);
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
if (status === 'failed' || Date.now() > deadline) return;
|
|
3313
|
+
setTimeout(tick, 1000).unref();
|
|
3314
|
+
};
|
|
3315
|
+
tick();
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
// 软件源每次创建容器时现读配置:向导/系统设置里改了马上对新容器生效;配置写坏时退回官方默认
|
|
3319
|
+
function readWebMirrors(state) {
|
|
3320
|
+
try {
|
|
3321
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
3322
|
+
return normalizeMirrors(snapshot.parseError ? undefined : (snapshot.parsed || {}).mirrors);
|
|
3323
|
+
} catch (e) {
|
|
3324
|
+
return normalizeMirrors(undefined);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3182
3328
|
async function ensureWebContainer(ctx, state, containerInput, messageSessionRef = null) {
|
|
3183
3329
|
const runtime = typeof containerInput === 'string'
|
|
3184
3330
|
? buildStaticContainerRuntime(ctx, containerInput)
|
|
@@ -3189,7 +3335,9 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
3189
3335
|
}
|
|
3190
3336
|
|
|
3191
3337
|
if (!ctx.containerExists(runtime.containerName)) {
|
|
3338
|
+
const mirrors = readWebMirrors(state);
|
|
3192
3339
|
const args = buildContainerRunArgs({
|
|
3340
|
+
mirrors,
|
|
3193
3341
|
containerName: runtime.containerName,
|
|
3194
3342
|
hostPath: runtime.hostPath,
|
|
3195
3343
|
containerPath: runtime.containerPath,
|
|
@@ -3203,6 +3351,8 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
3203
3351
|
defaultCommand: runtime.defaultCommand
|
|
3204
3352
|
});
|
|
3205
3353
|
|
|
3354
|
+
await ensureWebImage(ctx, state, webImageRef(ctx, runtime), { failFast: true });
|
|
3355
|
+
|
|
3206
3356
|
try {
|
|
3207
3357
|
ctx.dockerExecArgs(args, { stdio: 'pipe' });
|
|
3208
3358
|
} catch (e) {
|
|
@@ -3210,7 +3360,13 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
3210
3360
|
throw e;
|
|
3211
3361
|
}
|
|
3212
3362
|
|
|
3213
|
-
await ctx.waitForContainerReady(runtime.containerName);
|
|
3363
|
+
await ctx.waitForContainerReady(runtime.containerName, { throwOnFailure: true });
|
|
3364
|
+
applyAptMirror({
|
|
3365
|
+
dockerExecArgs: ctx.dockerExecArgs,
|
|
3366
|
+
containerName: runtime.containerName,
|
|
3367
|
+
mirrors,
|
|
3368
|
+
warn: message => console.warn(message)
|
|
3369
|
+
});
|
|
3214
3370
|
appendWebSessionMessage(
|
|
3215
3371
|
state.webHistoryDir,
|
|
3216
3372
|
messageSessionRef || runtime.containerName,
|
|
@@ -3239,7 +3395,7 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
|
|
|
3239
3395
|
const process = spawn(
|
|
3240
3396
|
ctx.dockerCmd,
|
|
3241
3397
|
['exec', containerName, '/bin/bash', '-lc', command],
|
|
3242
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
3398
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], env: ctx.dockerEnv }
|
|
3243
3399
|
);
|
|
3244
3400
|
|
|
3245
3401
|
const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
|
|
@@ -3307,7 +3463,7 @@ function spawnContainerRawFileStream(ctx, containerName, filePath) {
|
|
|
3307
3463
|
return spawn(
|
|
3308
3464
|
ctx.dockerCmd,
|
|
3309
3465
|
['exec', containerName, 'cat', '--', filePath],
|
|
3310
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
3466
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], env: ctx.dockerEnv }
|
|
3311
3467
|
);
|
|
3312
3468
|
}
|
|
3313
3469
|
|
|
@@ -3645,7 +3801,7 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
3645
3801
|
process = spawn(
|
|
3646
3802
|
ctx.dockerCmd,
|
|
3647
3803
|
['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
|
|
3648
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
3804
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], env: ctx.dockerEnv }
|
|
3649
3805
|
);
|
|
3650
3806
|
} catch (e) {
|
|
3651
3807
|
// spawn 同步抛错(如 dockerCmd 不存在)时必须释放锁,否则这个容器永远发不出消息
|
|
@@ -3952,6 +4108,190 @@ function sendHtml(res, statusCode, html, extraHeaders = {}) {
|
|
|
3952
4108
|
res.end(html);
|
|
3953
4109
|
}
|
|
3954
4110
|
|
|
4111
|
+
const SETUP_TEST_TIMEOUT_SECONDS = 60;
|
|
4112
|
+
const SETUP_TEST_PROMPT = 'Reply with the single word OK.';
|
|
4113
|
+
|
|
4114
|
+
function getSetupWorkpath(state) {
|
|
4115
|
+
return path.join(path.dirname(path.resolve(state.webConfigPath)), 'work');
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
async function inspectSetupImage(ctx, state) {
|
|
4119
|
+
const image = `${ctx.imageName}:${ctx.imageVersion}`;
|
|
4120
|
+
const pulling = state && state.imagePulls.get(image);
|
|
4121
|
+
if (pulling) {
|
|
4122
|
+
return { status: pulling.status, name: image, message: pulling.message };
|
|
4123
|
+
}
|
|
4124
|
+
const importing = ctx.importState();
|
|
4125
|
+
if (importing.active) {
|
|
4126
|
+
return { status: 'pulling', name: image, message: importing.message };
|
|
4127
|
+
}
|
|
4128
|
+
if (!ctx.runtimeState || ctx.runtimeState.status !== 'ready') {
|
|
4129
|
+
return { status: 'unknown', name: image };
|
|
4130
|
+
}
|
|
4131
|
+
// 多个标签页同时轮询时共用同一次 inspect(只合并进行中的请求,不做时间缓存,镜像状态变化马上可见)
|
|
4132
|
+
if (state && state.setupImageInflight && state.setupImageInflight.image === image) {
|
|
4133
|
+
return state.setupImageInflight.promise;
|
|
4134
|
+
}
|
|
4135
|
+
const promise = isWebImagePresent(ctx, image).then(present => ({ status: present ? 'ready' : 'missing', name: image }));
|
|
4136
|
+
if (!state) return promise;
|
|
4137
|
+
state.setupImageInflight = { image, promise };
|
|
4138
|
+
try {
|
|
4139
|
+
return await promise;
|
|
4140
|
+
} finally {
|
|
4141
|
+
if (state.setupImageInflight && state.setupImageInflight.promise === promise) state.setupImageInflight = null;
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
// 在一次性容器里用所选 Agent 发一次最小请求;容器无论成败都会删除。
|
|
4146
|
+
async function runSetupConnectionTest(ctx, state, agent, envMap) {
|
|
4147
|
+
const secrets = Object.values(envMap);
|
|
4148
|
+
const image = await inspectSetupImage(ctx, state);
|
|
4149
|
+
if (image.status !== 'ready') {
|
|
4150
|
+
return { category: 'other', message: image.status === 'missing' ? `镜像 ${image.name} 尚未就绪,请等待准备完成后重试` : '容器环境尚未就绪,请稍后重试', detail: '' };
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
const spec = AGENT_ENV_SCHEMA[agent];
|
|
4154
|
+
const template = deriveAgentPromptCommandFromDefaultCommand(resolveYoloCommand(spec.yolo));
|
|
4155
|
+
if (!template) {
|
|
4156
|
+
return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
|
|
4157
|
+
}
|
|
4158
|
+
const agentCommand = renderAgentPromptCommand(template, SETUP_TEST_PROMPT);
|
|
4159
|
+
const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
|
|
4160
|
+
|
|
4161
|
+
const workpath = getSetupWorkpath(state);
|
|
4162
|
+
fs.mkdirSync(workpath, { recursive: true });
|
|
4163
|
+
const containerName = `manyoyo-setup-test-${crypto.randomBytes(4).toString('hex')}`;
|
|
4164
|
+
const containerEnvs = [];
|
|
4165
|
+
Object.entries(envMap).forEach(([key, value]) => containerEnvs.push('--env', `${key}=${value}`));
|
|
4166
|
+
const args = buildContainerRunArgs({
|
|
4167
|
+
containerName,
|
|
4168
|
+
hostPath: workpath,
|
|
4169
|
+
containerPath: '/workspace',
|
|
4170
|
+
imageName: ctx.imageName,
|
|
4171
|
+
imageVersion: ctx.imageVersion,
|
|
4172
|
+
contModeArgs: [],
|
|
4173
|
+
containerExtraArgs: [],
|
|
4174
|
+
containerEnvs,
|
|
4175
|
+
containerVolumes: [],
|
|
4176
|
+
containerPorts: [],
|
|
4177
|
+
defaultCommand: '/bin/bash'
|
|
4178
|
+
});
|
|
4179
|
+
|
|
4180
|
+
try {
|
|
4181
|
+
ctx.dockerExecArgs(args, { stdio: 'pipe' });
|
|
4182
|
+
await ctx.waitForContainerReady(containerName, { throwOnFailure: true });
|
|
4183
|
+
let timer;
|
|
4184
|
+
const result = await Promise.race([
|
|
4185
|
+
execCommandInWebContainer(ctx, containerName, command),
|
|
4186
|
+
new Promise(resolve => {
|
|
4187
|
+
timer = setTimeout(() => resolve({ exitCode: 124, output: '' }), (SETUP_TEST_TIMEOUT_SECONDS + 15) * 1000);
|
|
4188
|
+
})
|
|
4189
|
+
]);
|
|
4190
|
+
clearTimeout(timer);
|
|
4191
|
+
return classifyConnectionResult(result, secrets);
|
|
4192
|
+
} catch (e) {
|
|
4193
|
+
return classifyConnectionResult({ exitCode: 1, output: e && e.message ? e.message : '' }, secrets);
|
|
4194
|
+
} finally {
|
|
4195
|
+
try {
|
|
4196
|
+
ctx.dockerExecArgs(['rm', '-f', containerName], { stdio: 'pipe' });
|
|
4197
|
+
} catch (e) {
|
|
4198
|
+
// 容器可能根本没创建成功
|
|
4199
|
+
}
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
// 网页只展示和“能不能用”相关的检查项,其余(端口、插件、模式、Agent 命令)对网页没有意义
|
|
4204
|
+
const WEB_DOCTOR_CODES = new Set([
|
|
4205
|
+
'RUNTIME_AVAILABLE', 'RUNTIME_UNAVAILABLE', 'DAEMON_AVAILABLE', 'DAEMON_UNAVAILABLE',
|
|
4206
|
+
'IMAGE_AVAILABLE', 'IMAGE_MISSING', 'CONFIG_AVAILABLE', 'CONFIG_MISSING'
|
|
4207
|
+
]);
|
|
4208
|
+
|
|
4209
|
+
function sanitizeDoctorText(value) {
|
|
4210
|
+
return redactSecrets(sanitizeServeLogText(String(value || ''))).slice(0, 500);
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
function buildWebDoctorReport(report) {
|
|
4214
|
+
const checks = report.checks
|
|
4215
|
+
.filter(check => WEB_DOCTOR_CODES.has(check.code))
|
|
4216
|
+
.map(check => {
|
|
4217
|
+
const item = {
|
|
4218
|
+
code: check.code,
|
|
4219
|
+
status: check.status,
|
|
4220
|
+
summary: sanitizeDoctorText(check.summary),
|
|
4221
|
+
action: sanitizeDoctorText(check.action),
|
|
4222
|
+
// 通过项的 detail 是 info 的整段输出,只会带出本机路径和无关信息
|
|
4223
|
+
detail: check.status === 'ok' ? '' : sanitizeDoctorText(check.detail)
|
|
4224
|
+
};
|
|
4225
|
+
if (check.fix) {
|
|
4226
|
+
item.fix = {
|
|
4227
|
+
attempted: Boolean(check.fix.attempted),
|
|
4228
|
+
fixed: Boolean(check.fix.fixed),
|
|
4229
|
+
message: sanitizeDoctorText(check.fix.message)
|
|
4230
|
+
};
|
|
4231
|
+
}
|
|
4232
|
+
return item;
|
|
4233
|
+
});
|
|
4234
|
+
return {
|
|
4235
|
+
ok: !checks.some(check => check.status === 'error' && !(check.fix && check.fix.fixed)),
|
|
4236
|
+
checks
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
async function buildSetupStatus(ctx, state) {
|
|
4241
|
+
// 向导的“选择目录”与默认路径校验都要求目录已存在,所以展示默认路径前先建好
|
|
4242
|
+
try { fs.mkdirSync(getSetupWorkpath(state), { recursive: true }); } catch (e) { /* 建不出来时由后续校验报错 */ }
|
|
4243
|
+
// 向导的“选择目录”与默认路径校验都要求目录已存在,所以展示默认路径前先建好
|
|
4244
|
+
try { fs.mkdirSync(getSetupWorkpath(state), { recursive: true }); } catch (e) { /* 建不出来时由后续校验报错 */ }
|
|
4245
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
4246
|
+
const config = snapshot.parseError ? {} : snapshot.parsed;
|
|
4247
|
+
const configuredAgents = getConfiguredAgents(config);
|
|
4248
|
+
return {
|
|
4249
|
+
needsSetup: configuredAgents.length === 0,
|
|
4250
|
+
configuredAgents,
|
|
4251
|
+
serverUser: ctx.authUser,
|
|
4252
|
+
// 自动生成(用户看不到)的密码不算“已设置”
|
|
4253
|
+
passwordSet: !ctx.authPassAuto || (typeof config.serverPass === 'string' && config.serverPass !== ''),
|
|
4254
|
+
configError: snapshot.parseError || null,
|
|
4255
|
+
runtime: { status: ctx.runtimeState.status, message: ctx.runtimeState.message },
|
|
4256
|
+
image: await inspectSetupImage(ctx, state),
|
|
4257
|
+
defaultHostPath: getSetupWorkpath(state),
|
|
4258
|
+
mirrors: readWebMirrors(state),
|
|
4259
|
+
platform: process.platform,
|
|
4260
|
+
runtimeKind: path.basename(String(ctx.dockerCmd || ''))
|
|
4261
|
+
};
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
function resolveSetupRunInput(ctx, state, payload) {
|
|
4265
|
+
const body = toPlainObject(payload);
|
|
4266
|
+
const agent = String(body.agent || '').trim();
|
|
4267
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
4268
|
+
if (snapshot.parseError) {
|
|
4269
|
+
const error = new Error('当前配置解析失败,无法继续');
|
|
4270
|
+
error.detail = snapshot.parseError;
|
|
4271
|
+
throw error;
|
|
4272
|
+
}
|
|
4273
|
+
const existingRun = toPlainObject(toPlainObject(snapshot.parsed.runs)[agent]);
|
|
4274
|
+
let hostPath = '';
|
|
4275
|
+
if (body.hostPath !== undefined && body.hostPath !== '') {
|
|
4276
|
+
hostPath = typeof body.hostPath === 'string' ? body.hostPath.trim() : '';
|
|
4277
|
+
if (!hostPath || !path.isAbsolute(hostPath)) {
|
|
4278
|
+
throw new Error('hostPath 必须是绝对路径');
|
|
4279
|
+
}
|
|
4280
|
+
if (hostPath === getSetupWorkpath(state)) fs.mkdirSync(hostPath, { recursive: true });
|
|
4281
|
+
ctx.validateHostPath(hostPath);
|
|
4282
|
+
}
|
|
4283
|
+
const profile = buildAgentRunProfile(agent, body.env === undefined ? {} : body.env, {
|
|
4284
|
+
existingRun,
|
|
4285
|
+
defaultHostPath: getSetupWorkpath(state),
|
|
4286
|
+
hostPath
|
|
4287
|
+
});
|
|
4288
|
+
return { agent, snapshot, profile };
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
function redactLoginTokenInUrl(rawUrl) {
|
|
4292
|
+
return String(rawUrl || '').replace(/([?&]token=)[^&#]*/gi, '$1[redacted]');
|
|
4293
|
+
}
|
|
4294
|
+
|
|
3955
4295
|
function sendRedirect(res, statusCode, location, extraHeaders = {}) {
|
|
3956
4296
|
res.writeHead(statusCode, {
|
|
3957
4297
|
Location: location,
|
|
@@ -4147,24 +4487,11 @@ function buildSessionAudit(ctx, state, sessionRef) {
|
|
|
4147
4487
|
};
|
|
4148
4488
|
}
|
|
4149
4489
|
|
|
4150
|
-
function
|
|
4151
|
-
|
|
4152
|
-
}
|
|
4153
|
-
|
|
4154
|
-
function resolveStaticAsset(name) {
|
|
4155
|
-
if (!isSafeStaticAssetName(name)) {
|
|
4156
|
-
return null;
|
|
4157
|
-
}
|
|
4158
|
-
const fullPath = path.join(FRONTEND_DIR, name);
|
|
4159
|
-
return fs.existsSync(fullPath) ? fullPath : null;
|
|
4160
|
-
}
|
|
4161
|
-
|
|
4162
|
-
function loadTemplate(name) {
|
|
4163
|
-
const filePath = resolveStaticAsset(name);
|
|
4164
|
-
if (!filePath) {
|
|
4490
|
+
function loadIndexHtml() {
|
|
4491
|
+
if (!fs.existsSync(INDEX_HTML_PATH)) {
|
|
4165
4492
|
return '<h1>Template Not Found</h1>';
|
|
4166
4493
|
}
|
|
4167
|
-
return fs.readFileSync(
|
|
4494
|
+
return fs.readFileSync(INDEX_HTML_PATH, 'utf-8');
|
|
4168
4495
|
}
|
|
4169
4496
|
|
|
4170
4497
|
function escapeHtmlText(value) {
|
|
@@ -4182,13 +4509,13 @@ function applyServeTitle(html, ctx) {
|
|
|
4182
4509
|
return html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtmlText(ctx.serveTitle)}</title>`);
|
|
4183
4510
|
}
|
|
4184
4511
|
|
|
4185
|
-
function
|
|
4186
|
-
//
|
|
4512
|
+
function renderAppHtml(ctx) {
|
|
4513
|
+
// index.html 是 vite singlefile 打包产物,全部依赖内联成一个 <script>,
|
|
4187
4514
|
// 用 .replace('</head>', ...) 注入标记有极小概率命中某个库源码里字面量出现的
|
|
4188
4515
|
// "</head>",把注入内容插进内联脚本中间导致整页解析错乱;所以改成跟
|
|
4189
4516
|
// applyServeTitle 同款更窄、更安全的 <title> 定位,插一个 meta 标记
|
|
4190
4517
|
// 告知前端跳过动态改写 document.title
|
|
4191
|
-
let html = applyServeTitle(
|
|
4518
|
+
let html = applyServeTitle(loadIndexHtml(), ctx);
|
|
4192
4519
|
if (typeof ctx.serveTitle === 'string') {
|
|
4193
4520
|
html = html.replace(
|
|
4194
4521
|
/<\/title>/,
|
|
@@ -4291,7 +4618,7 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
|
4291
4618
|
terminalBootstrap
|
|
4292
4619
|
];
|
|
4293
4620
|
|
|
4294
|
-
return spawn(ctx.dockerCmd, dockerExecArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
4621
|
+
return spawn(ctx.dockerCmd, dockerExecArgs, { stdio: ['pipe', 'pipe', 'pipe'], env: ctx.dockerEnv });
|
|
4295
4622
|
}
|
|
4296
4623
|
|
|
4297
4624
|
function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
@@ -4415,6 +4742,31 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
|
4415
4742
|
ws.on('error', cleanup);
|
|
4416
4743
|
}
|
|
4417
4744
|
|
|
4745
|
+
const LOGIN_FREE_FAILURES = 5;
|
|
4746
|
+
const LOGIN_MAX_BACKOFF_SECONDS = 300;
|
|
4747
|
+
const LOGIN_FAILURE_TTL_MS = 15 * 60 * 1000;
|
|
4748
|
+
|
|
4749
|
+
function getLoginRetryAfterSeconds(state, key, nowMs) {
|
|
4750
|
+
const entry = state.loginFailures.get(key);
|
|
4751
|
+
if (!entry || entry.blockedUntil <= nowMs) return 0;
|
|
4752
|
+
return Math.max(1, Math.ceil((entry.blockedUntil - nowMs) / 1000));
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
// 前 5 次失败不限;之后每次失败把封禁时间翻倍(1、2、4…秒,上限 300 秒);成功后清零
|
|
4756
|
+
function recordLoginFailure(state, key, nowMs) {
|
|
4757
|
+
for (const [otherKey, entry] of state.loginFailures) {
|
|
4758
|
+
if (nowMs - entry.lastAt > LOGIN_FAILURE_TTL_MS) state.loginFailures.delete(otherKey);
|
|
4759
|
+
}
|
|
4760
|
+
const entry = state.loginFailures.get(key) || { count: 0, blockedUntil: 0, lastAt: nowMs };
|
|
4761
|
+
entry.count += 1;
|
|
4762
|
+
entry.lastAt = nowMs;
|
|
4763
|
+
if (entry.count > LOGIN_FREE_FAILURES) {
|
|
4764
|
+
const seconds = Math.min(2 ** (entry.count - LOGIN_FREE_FAILURES - 1), LOGIN_MAX_BACKOFF_SECONDS);
|
|
4765
|
+
entry.blockedUntil = nowMs + seconds * 1000;
|
|
4766
|
+
}
|
|
4767
|
+
state.loginFailures.set(key, entry);
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4418
4770
|
async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
4419
4771
|
if (req.method === 'GET' && pathname === '/favicon.ico') {
|
|
4420
4772
|
res.writeHead(204, { 'Cache-Control': 'no-store' });
|
|
@@ -4422,16 +4774,20 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
|
4422
4774
|
return true;
|
|
4423
4775
|
}
|
|
4424
4776
|
|
|
4425
|
-
// 登录页:与 /
|
|
4426
|
-
//
|
|
4427
|
-
|
|
4428
|
-
sendHtml(res, 200, applyServeTitle(loadTemplate('shadcn.html'), ctx));
|
|
4429
|
-
return true;
|
|
4430
|
-
}
|
|
4431
|
-
|
|
4432
|
-
// GET /auth/login 曾经是独立的登录页面,现在只留一个跳转,接住老书签
|
|
4777
|
+
// 登录页:与 / 复用同一份构建产物,由前端按 pathname 自行渲染登录表单,
|
|
4778
|
+
// 登录逻辑走下面的 POST /auth/login 接口
|
|
4779
|
+
// 带 ?token= 时是本机启动器的一次性登录:仅回环监听启用,令牌用后即删;令牌无效就落在登录页
|
|
4433
4780
|
if (req.method === 'GET' && pathname === '/auth/login') {
|
|
4434
|
-
|
|
4781
|
+
const token = new URL(req.url || '/', 'http://localhost').searchParams.get('token');
|
|
4782
|
+
if (token !== null && ctx.loginTokenDir && isLoopbackHost(ctx.serverHost) && consumeLoginToken(ctx.loginTokenDir, token)) {
|
|
4783
|
+
const sessionId = createWebAuthSession(state, ctx.authUser);
|
|
4784
|
+
sendRedirect(res, 302, '/', {
|
|
4785
|
+
'Set-Cookie': getWebAuthCookie(sessionId),
|
|
4786
|
+
'Referrer-Policy': 'no-referrer'
|
|
4787
|
+
});
|
|
4788
|
+
return true;
|
|
4789
|
+
}
|
|
4790
|
+
sendHtml(res, 200, applyServeTitle(loadIndexHtml(), ctx), { 'Referrer-Policy': 'no-referrer' });
|
|
4435
4791
|
return true;
|
|
4436
4792
|
}
|
|
4437
4793
|
|
|
@@ -4445,12 +4801,23 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
|
4445
4801
|
return true;
|
|
4446
4802
|
}
|
|
4447
4803
|
|
|
4804
|
+
// 失败限速:按连接来源地址计数(不信任 X-Forwarded-For,伪造它就能绕过限速);
|
|
4805
|
+
// 反代后所有人共用一个来源,会被一起限速,这是有意的保守取舍
|
|
4806
|
+
const rateKey = String(req.socket && req.socket.remoteAddress || '');
|
|
4807
|
+
const retryAfter = getLoginRetryAfterSeconds(state, rateKey, ctx.nowMs());
|
|
4808
|
+
if (retryAfter > 0) {
|
|
4809
|
+
sendJson(res, 429, { error: `登录失败次数过多,请 ${retryAfter} 秒后重试` }, { 'Retry-After': String(retryAfter) });
|
|
4810
|
+
return true;
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4448
4813
|
const userOk = secureStringEqual(username, ctx.authUser);
|
|
4449
4814
|
const passOk = secureStringEqual(password, ctx.authPass);
|
|
4450
4815
|
if (!(userOk && passOk)) {
|
|
4816
|
+
recordLoginFailure(state, rateKey, ctx.nowMs());
|
|
4451
4817
|
sendJson(res, 401, { error: '用户名或密码错误' });
|
|
4452
4818
|
return true;
|
|
4453
4819
|
}
|
|
4820
|
+
state.loginFailures.delete(rateKey);
|
|
4454
4821
|
|
|
4455
4822
|
const sessionId = createWebAuthSession(state, username);
|
|
4456
4823
|
sendJson(
|
|
@@ -4483,7 +4850,7 @@ function sendWebUnauthorized(res, pathname) {
|
|
|
4483
4850
|
}
|
|
4484
4851
|
// 其余都是页面请求:一律打回登录页。登录页本身在认证网关之前就被
|
|
4485
4852
|
// handleWebAuthRoutes 放行,不会在这里形成重定向循环
|
|
4486
|
-
sendRedirect(res, 302, '/
|
|
4853
|
+
sendRedirect(res, 302, '/auth/login', { 'Set-Cookie': getWebAuthClearCookie() });
|
|
4487
4854
|
}
|
|
4488
4855
|
|
|
4489
4856
|
// 运行日志查看接口的支撑函数。日志目录以 serve 实际使用的 logger.path 为准,
|
|
@@ -4661,8 +5028,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4661
5028
|
}
|
|
4662
5029
|
|
|
4663
5030
|
const savePath = path.resolve(state.webConfigPath);
|
|
4664
|
-
|
|
4665
|
-
fs.writeFileSync(savePath, finalRaw, 'utf-8');
|
|
5031
|
+
writeConfigFileSecure(savePath, finalRaw);
|
|
4666
5032
|
|
|
4667
5033
|
sendJson(res, 200, {
|
|
4668
5034
|
saved: true,
|
|
@@ -4671,6 +5037,223 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4671
5037
|
});
|
|
4672
5038
|
}
|
|
4673
5039
|
},
|
|
5040
|
+
{
|
|
5041
|
+
method: 'GET',
|
|
5042
|
+
match: currentPath => currentPath === '/api/setup/status' ? [] : null,
|
|
5043
|
+
handler: async () => {
|
|
5044
|
+
sendJson(res, 200, await buildSetupStatus(ctx, state));
|
|
5045
|
+
}
|
|
5046
|
+
},
|
|
5047
|
+
{
|
|
5048
|
+
method: 'GET',
|
|
5049
|
+
match: currentPath => currentPath === '/api/setup/agents' ? [] : null,
|
|
5050
|
+
handler: async () => {
|
|
5051
|
+
sendJson(res, 200, { agents: listSetupAgents(), mirrorPresets: listMirrorPresets() });
|
|
5052
|
+
}
|
|
5053
|
+
},
|
|
5054
|
+
{
|
|
5055
|
+
method: 'POST',
|
|
5056
|
+
match: currentPath => currentPath === '/api/setup/agent' ? [] : null,
|
|
5057
|
+
handler: async () => {
|
|
5058
|
+
const payload = await readJsonBody(req);
|
|
5059
|
+
let input;
|
|
5060
|
+
try {
|
|
5061
|
+
input = resolveSetupRunInput(ctx, state, payload);
|
|
5062
|
+
} catch (e) {
|
|
5063
|
+
sendJson(res, 400, { error: e.message || '参数无效', detail: e.detail || '' });
|
|
5064
|
+
return;
|
|
5065
|
+
}
|
|
5066
|
+
|
|
5067
|
+
let nextRaw;
|
|
5068
|
+
try {
|
|
5069
|
+
nextRaw = buildAgentConfigRaw(baseRawOf(input.snapshot), input.agent, input.profile);
|
|
5070
|
+
parseAndValidateConfigRaw(nextRaw);
|
|
5071
|
+
} catch (e) {
|
|
5072
|
+
sendJson(res, 400, { error: '配置写入失败', detail: e.message || '' });
|
|
5073
|
+
return;
|
|
5074
|
+
}
|
|
5075
|
+
|
|
5076
|
+
const savePath = path.resolve(state.webConfigPath);
|
|
5077
|
+
writeConfigFileSecure(savePath, nextRaw);
|
|
5078
|
+
fs.mkdirSync(getSetupWorkpath(state), { recursive: true });
|
|
5079
|
+
|
|
5080
|
+
sendJson(res, 200, { saved: true, agent: input.agent, run: sanitizeSensitiveData(input.profile) });
|
|
5081
|
+
}
|
|
5082
|
+
},
|
|
5083
|
+
{
|
|
5084
|
+
method: 'POST',
|
|
5085
|
+
match: currentPath => currentPath === '/api/setup/password' ? [] : null,
|
|
5086
|
+
handler: async () => {
|
|
5087
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
5088
|
+
const reason = validateSetupPassword(payload.password);
|
|
5089
|
+
if (reason) {
|
|
5090
|
+
sendJson(res, 400, { error: reason });
|
|
5091
|
+
return;
|
|
5092
|
+
}
|
|
5093
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
5094
|
+
if (snapshot.parseError) {
|
|
5095
|
+
sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
|
|
5096
|
+
return;
|
|
5097
|
+
}
|
|
5098
|
+
let nextRaw;
|
|
5099
|
+
try {
|
|
5100
|
+
nextRaw = buildPasswordConfigRaw(baseRawOf(snapshot), payload.password);
|
|
5101
|
+
parseAndValidateConfigRaw(nextRaw);
|
|
5102
|
+
} catch (e) {
|
|
5103
|
+
sendJson(res, 400, { error: '配置写入失败' });
|
|
5104
|
+
return;
|
|
5105
|
+
}
|
|
5106
|
+
const savePath = path.resolve(state.webConfigPath);
|
|
5107
|
+
writeConfigFileSecure(savePath, nextRaw);
|
|
5108
|
+
// 就地生效:已登录会话保持有效,新登录用新密码;重启后配置里的 serverPass 优先于环境变量
|
|
5109
|
+
ctx.authPass = payload.password;
|
|
5110
|
+
ctx.authPassAuto = false;
|
|
5111
|
+
sendJson(res, 200, { saved: true });
|
|
5112
|
+
}
|
|
5113
|
+
},
|
|
5114
|
+
{
|
|
5115
|
+
method: 'POST',
|
|
5116
|
+
match: currentPath => currentPath === '/api/setup/mirrors' ? [] : null,
|
|
5117
|
+
handler: async () => {
|
|
5118
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
5119
|
+
let mirrors;
|
|
5120
|
+
try {
|
|
5121
|
+
mirrors = normalizeMirrors(payload.mirrors === undefined ? payload : payload.mirrors);
|
|
5122
|
+
} catch (e) {
|
|
5123
|
+
sendJson(res, 400, { error: e.message || '软件源无效' });
|
|
5124
|
+
return;
|
|
5125
|
+
}
|
|
5126
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
5127
|
+
if (snapshot.parseError) {
|
|
5128
|
+
sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
|
|
5129
|
+
return;
|
|
5130
|
+
}
|
|
5131
|
+
// 全部为官方默认且配置里本来没有 mirrors 时不写任何东西
|
|
5132
|
+
if (!shouldWriteMirrors(snapshot.parsed, mirrors)) {
|
|
5133
|
+
sendJson(res, 200, { saved: false, mirrors });
|
|
5134
|
+
return;
|
|
5135
|
+
}
|
|
5136
|
+
let nextRaw;
|
|
5137
|
+
try {
|
|
5138
|
+
nextRaw = buildMirrorsConfigRaw(baseRawOf(snapshot), mirrors);
|
|
5139
|
+
parseAndValidateConfigRaw(nextRaw);
|
|
5140
|
+
} catch (e) {
|
|
5141
|
+
sendJson(res, 400, { error: '配置写入失败' });
|
|
5142
|
+
return;
|
|
5143
|
+
}
|
|
5144
|
+
writeConfigFileSecure(path.resolve(state.webConfigPath), nextRaw);
|
|
5145
|
+
sendJson(res, 200, { saved: true, mirrors });
|
|
5146
|
+
}
|
|
5147
|
+
},
|
|
5148
|
+
{
|
|
5149
|
+
method: 'POST',
|
|
5150
|
+
match: currentPath => currentPath === '/api/setup/test-connection' ? [] : null,
|
|
5151
|
+
handler: async () => {
|
|
5152
|
+
const payload = await readJsonBody(req);
|
|
5153
|
+
let input;
|
|
5154
|
+
try {
|
|
5155
|
+
input = resolveSetupRunInput(ctx, state, payload);
|
|
5156
|
+
} catch (e) {
|
|
5157
|
+
sendJson(res, 400, { error: e.message || '参数无效', detail: e.detail || '' });
|
|
5158
|
+
return;
|
|
5159
|
+
}
|
|
5160
|
+
if (state.setupTestRunning) {
|
|
5161
|
+
sendJson(res, 409, { error: '已有连接测试正在进行,请稍后重试' });
|
|
5162
|
+
return;
|
|
5163
|
+
}
|
|
5164
|
+
state.setupTestRunning = true;
|
|
5165
|
+
try {
|
|
5166
|
+
const result = await runSetupConnectionTest(ctx, state, input.agent, toPlainObject(input.profile.env));
|
|
5167
|
+
sendJson(res, 200, result);
|
|
5168
|
+
} finally {
|
|
5169
|
+
state.setupTestRunning = false;
|
|
5170
|
+
}
|
|
5171
|
+
}
|
|
5172
|
+
},
|
|
5173
|
+
{
|
|
5174
|
+
method: 'GET',
|
|
5175
|
+
match: currentPath => currentPath === '/api/system/update' ? [] : null,
|
|
5176
|
+
handler: async () => {
|
|
5177
|
+
sendJson(res, 200, ctx.updateInfo());
|
|
5178
|
+
}
|
|
5179
|
+
},
|
|
5180
|
+
{
|
|
5181
|
+
method: 'POST',
|
|
5182
|
+
match: currentPath => currentPath === '/api/system/runtime/retry' ? [] : null,
|
|
5183
|
+
handler: async () => {
|
|
5184
|
+
// 失败/超时后重新自愈;正在进行或已就绪时只返回当前状态,不重复启动
|
|
5185
|
+
if (ctx.runtimeState.status === 'failed' && typeof ctx.retryRuntimeHeal === 'function') {
|
|
5186
|
+
ctx.retryRuntimeHeal();
|
|
5187
|
+
if (ctx.autoPullImage) startWebImagePrepare(ctx, state);
|
|
5188
|
+
startStaleSetupCleanup(ctx);
|
|
5189
|
+
}
|
|
5190
|
+
// 镜像拉取失败的条目会一直挂着:清掉后重新拉
|
|
5191
|
+
if (ctx.runtimeState.status === 'ready' && ctx.autoPullImage) {
|
|
5192
|
+
let cleared = false;
|
|
5193
|
+
for (const [ref, entry] of state.imagePulls) {
|
|
5194
|
+
if (entry.status === 'failed') {
|
|
5195
|
+
state.imagePulls.delete(ref);
|
|
5196
|
+
cleared = true;
|
|
5197
|
+
}
|
|
5198
|
+
}
|
|
5199
|
+
if (cleared) startWebImagePrepare(ctx, state);
|
|
5200
|
+
}
|
|
5201
|
+
sendJson(res, 202, { status: ctx.runtimeState.status, message: ctx.runtimeState.message });
|
|
5202
|
+
}
|
|
5203
|
+
},
|
|
5204
|
+
{
|
|
5205
|
+
method: 'GET',
|
|
5206
|
+
match: currentPath => currentPath === '/api/system/runtime' ? [] : null,
|
|
5207
|
+
handler: async () => {
|
|
5208
|
+
sendJson(res, 200, { status: ctx.runtimeState.status, message: ctx.runtimeState.message });
|
|
5209
|
+
}
|
|
5210
|
+
},
|
|
5211
|
+
{
|
|
5212
|
+
method: 'GET',
|
|
5213
|
+
match: currentPath => currentPath === '/api/system/doctor' ? [] : null,
|
|
5214
|
+
handler: async () => {
|
|
5215
|
+
if (typeof ctx.doctorCheck !== 'function') {
|
|
5216
|
+
sendJson(res, 503, { error: '环境检查不可用' });
|
|
5217
|
+
return;
|
|
5218
|
+
}
|
|
5219
|
+
try {
|
|
5220
|
+
sendJson(res, 200, buildWebDoctorReport(await ctx.doctorCheck()));
|
|
5221
|
+
} catch (e) {
|
|
5222
|
+
sendJson(res, 500, { error: '环境检查失败', detail: sanitizeDoctorText(e && e.message) });
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
5225
|
+
},
|
|
5226
|
+
{
|
|
5227
|
+
method: 'POST',
|
|
5228
|
+
match: currentPath => currentPath === '/api/system/doctor/fix' ? [] : null,
|
|
5229
|
+
handler: async () => {
|
|
5230
|
+
if (typeof ctx.doctorCheck !== 'function') {
|
|
5231
|
+
sendJson(res, 503, { error: '环境检查不可用' });
|
|
5232
|
+
return;
|
|
5233
|
+
}
|
|
5234
|
+
if (state.doctorFixRunning) {
|
|
5235
|
+
sendJson(res, 409, { error: '已有修复正在进行,请稍后重试' });
|
|
5236
|
+
return;
|
|
5237
|
+
}
|
|
5238
|
+
state.doctorFixRunning = true;
|
|
5239
|
+
try {
|
|
5240
|
+
const report = await ctx.doctorCheck();
|
|
5241
|
+
const fixed = await applyDoctorFixes(report, {
|
|
5242
|
+
startRuntime: typeof ctx.doctorStartRuntime === 'function' ? ctx.doctorStartRuntime : undefined,
|
|
5243
|
+
pullImage: async () => {
|
|
5244
|
+
const imageRef = webImageRef(ctx);
|
|
5245
|
+
await ensureWebImage(ctx, state, imageRef);
|
|
5246
|
+
return { fixed: true, message: `已准备镜像 ${imageRef}` };
|
|
5247
|
+
}
|
|
5248
|
+
});
|
|
5249
|
+
sendJson(res, 200, buildWebDoctorReport(fixed));
|
|
5250
|
+
} catch (e) {
|
|
5251
|
+
sendJson(res, 500, { error: '自动修复失败', detail: sanitizeDoctorText(e && e.message) });
|
|
5252
|
+
} finally {
|
|
5253
|
+
state.doctorFixRunning = false;
|
|
5254
|
+
}
|
|
5255
|
+
}
|
|
5256
|
+
},
|
|
4674
5257
|
{
|
|
4675
5258
|
method: 'GET',
|
|
4676
5259
|
match: currentPath => currentPath === '/api/system/capacity' ? [] : null,
|
|
@@ -4742,8 +5325,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4742
5325
|
}
|
|
4743
5326
|
|
|
4744
5327
|
const savePath = path.resolve(state.webConfigPath);
|
|
4745
|
-
|
|
4746
|
-
fs.writeFileSync(savePath, nextRaw, 'utf-8');
|
|
5328
|
+
writeConfigFileSecure(savePath, nextRaw);
|
|
4747
5329
|
|
|
4748
5330
|
sendJson(res, 200, { saved: true, path: quickChatPath, run: quickChatRun });
|
|
4749
5331
|
}
|
|
@@ -5745,6 +6327,15 @@ async function startWebServer(options) {
|
|
|
5745
6327
|
authPassAuto: options.authPassAuto,
|
|
5746
6328
|
serveTitle: typeof options.serveTitle === 'string' ? options.serveTitle : null,
|
|
5747
6329
|
dockerCmd: options.dockerCmd,
|
|
6330
|
+
dockerEnv: options.dockerEnv,
|
|
6331
|
+
loginTokenDir: options.loginTokenDir || '',
|
|
6332
|
+
doctorCheck: options.doctorCheck,
|
|
6333
|
+
doctorStartRuntime: options.doctorStartRuntime,
|
|
6334
|
+
runtimeState: options.runtimeState || { status: 'ready', message: '' },
|
|
6335
|
+
autoPullImage: Boolean(options.autoPullImage),
|
|
6336
|
+
// 离线安装器后台导入镜像的状态读取(默认没有导入)
|
|
6337
|
+
updateInfo: typeof options.updateInfo === 'function' ? options.updateInfo : () => ({ enabled: false, installMode: 'unknown', current: '', latest: '', updateAvailable: false, checkedAt: null, error: '' }),
|
|
6338
|
+
importState: typeof options.importState === 'function' ? options.importState : () => ({ active: false, message: '' }),
|
|
5748
6339
|
hostPath: options.hostPath,
|
|
5749
6340
|
containerPath: options.containerPath,
|
|
5750
6341
|
imageName: options.imageName,
|
|
@@ -5764,6 +6355,9 @@ async function startWebServer(options) {
|
|
|
5764
6355
|
getContainerStatus: options.getContainerStatus,
|
|
5765
6356
|
waitForContainerReady: options.waitForContainerReady,
|
|
5766
6357
|
dockerExecArgs: options.dockerExecArgs,
|
|
6358
|
+
dockerExecAsync: options.dockerExecAsync,
|
|
6359
|
+
nowMs: typeof options.nowMs === 'function' ? options.nowMs : Date.now,
|
|
6360
|
+
retryRuntimeHeal: options.retryRuntimeHeal,
|
|
5767
6361
|
showImagePullHint: options.showImagePullHint,
|
|
5768
6362
|
removeContainer: options.removeContainer,
|
|
5769
6363
|
logger: options.logger && typeof options.logger.info === 'function' ? options.logger : fallbackLogger,
|
|
@@ -5788,7 +6382,10 @@ async function startWebServer(options) {
|
|
|
5788
6382
|
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
5789
6383
|
authSessions: new Map(),
|
|
5790
6384
|
terminalSessions: new Map(),
|
|
5791
|
-
agentRuns: new Map()
|
|
6385
|
+
agentRuns: new Map(),
|
|
6386
|
+
// imageRef -> { status: 'pulling'|'failed', message, promise }
|
|
6387
|
+
imagePulls: new Map(),
|
|
6388
|
+
loginFailures: new Map()
|
|
5792
6389
|
};
|
|
5793
6390
|
|
|
5794
6391
|
ensureWebHistoryDir(state.webHistoryDir);
|
|
@@ -5812,6 +6409,8 @@ async function startWebServer(options) {
|
|
|
5812
6409
|
});
|
|
5813
6410
|
|
|
5814
6411
|
const server = http.createServer(async (req, res) => {
|
|
6412
|
+
// 身份标记:无参启动器复用实例前用它确认端口上跑的确实是 manyoyo serve(未登录的 401 也带);不含任何版本或本机信息
|
|
6413
|
+
res.setHeader('X-Manyoyo-Serve', '1');
|
|
5815
6414
|
try {
|
|
5816
6415
|
const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
|
|
5817
6416
|
const url = new URL(req.url, `http://${req.headers.host || fallbackHost}`);
|
|
@@ -5828,15 +6427,9 @@ async function startWebServer(options) {
|
|
|
5828
6427
|
return;
|
|
5829
6428
|
}
|
|
5830
6429
|
|
|
5831
|
-
//
|
|
6430
|
+
// 前端构建产物见 frontend/(npm run build:web),输出到 lib/web/index.html
|
|
5832
6431
|
if (req.method === 'GET' && pathname === '/') {
|
|
5833
|
-
sendHtml(res, 200,
|
|
5834
|
-
return;
|
|
5835
|
-
}
|
|
5836
|
-
|
|
5837
|
-
// /shadcn 保留为 / 的别名,兼容此前"试验性预览"阶段留下的书签/脚本
|
|
5838
|
-
if (req.method === 'GET' && pathname === '/shadcn') {
|
|
5839
|
-
sendHtml(res, 200, renderShadcnHtml(ctx));
|
|
6432
|
+
sendHtml(res, 200, renderAppHtml(ctx));
|
|
5840
6433
|
return;
|
|
5841
6434
|
}
|
|
5842
6435
|
|
|
@@ -5855,9 +6448,13 @@ async function startWebServer(options) {
|
|
|
5855
6448
|
|
|
5856
6449
|
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
5857
6450
|
} catch (e) {
|
|
6451
|
+
if (e && e.imagePreparing) {
|
|
6452
|
+
sendJson(res, 409, { error: e.message, imagePreparing: true });
|
|
6453
|
+
return;
|
|
6454
|
+
}
|
|
5858
6455
|
ctx.logger.error('http request error', {
|
|
5859
6456
|
method: req && req.method ? req.method : '',
|
|
5860
|
-
url: req && req.url ? req.url : '',
|
|
6457
|
+
url: redactLoginTokenInUrl(req && req.url ? req.url : ''),
|
|
5861
6458
|
message: e && e.message ? e.message : 'Server Error'
|
|
5862
6459
|
});
|
|
5863
6460
|
if ((req.url || '').startsWith('/api/')) {
|
|
@@ -5995,6 +6592,11 @@ async function startWebServer(options) {
|
|
|
5995
6592
|
});
|
|
5996
6593
|
});
|
|
5997
6594
|
|
|
6595
|
+
startStaleSetupCleanup(ctx);
|
|
6596
|
+
if (ctx.autoPullImage) {
|
|
6597
|
+
startWebImagePrepare(ctx, state);
|
|
6598
|
+
}
|
|
6599
|
+
|
|
5998
6600
|
return {
|
|
5999
6601
|
server,
|
|
6000
6602
|
wsServer,
|