@xcanwin/manyoyo 7.1.6 → 8.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -14
- package/bin/manyoyo.js +488 -82
- package/docker/manyoyo.Dockerfile +10 -4
- package/lib/app-launcher.js +220 -0
- package/lib/app-update.js +325 -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/frontend/shadcn.html +86 -86
- package/lib/web/server.js +641 -14
- package/lib/worktrees.js +15 -0
- package/manyoyo.example.json +13 -2
- package/package.json +4 -4
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,
|
|
@@ -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,
|
|
@@ -4291,7 +4631,7 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
|
4291
4631
|
terminalBootstrap
|
|
4292
4632
|
];
|
|
4293
4633
|
|
|
4294
|
-
return spawn(ctx.dockerCmd, dockerExecArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
4634
|
+
return spawn(ctx.dockerCmd, dockerExecArgs, { stdio: ['pipe', 'pipe', 'pipe'], env: ctx.dockerEnv });
|
|
4295
4635
|
}
|
|
4296
4636
|
|
|
4297
4637
|
function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
@@ -4415,6 +4755,31 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
|
4415
4755
|
ws.on('error', cleanup);
|
|
4416
4756
|
}
|
|
4417
4757
|
|
|
4758
|
+
const LOGIN_FREE_FAILURES = 5;
|
|
4759
|
+
const LOGIN_MAX_BACKOFF_SECONDS = 300;
|
|
4760
|
+
const LOGIN_FAILURE_TTL_MS = 15 * 60 * 1000;
|
|
4761
|
+
|
|
4762
|
+
function getLoginRetryAfterSeconds(state, key, nowMs) {
|
|
4763
|
+
const entry = state.loginFailures.get(key);
|
|
4764
|
+
if (!entry || entry.blockedUntil <= nowMs) return 0;
|
|
4765
|
+
return Math.max(1, Math.ceil((entry.blockedUntil - nowMs) / 1000));
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
// 前 5 次失败不限;之后每次失败把封禁时间翻倍(1、2、4…秒,上限 300 秒);成功后清零
|
|
4769
|
+
function recordLoginFailure(state, key, nowMs) {
|
|
4770
|
+
for (const [otherKey, entry] of state.loginFailures) {
|
|
4771
|
+
if (nowMs - entry.lastAt > LOGIN_FAILURE_TTL_MS) state.loginFailures.delete(otherKey);
|
|
4772
|
+
}
|
|
4773
|
+
const entry = state.loginFailures.get(key) || { count: 0, blockedUntil: 0, lastAt: nowMs };
|
|
4774
|
+
entry.count += 1;
|
|
4775
|
+
entry.lastAt = nowMs;
|
|
4776
|
+
if (entry.count > LOGIN_FREE_FAILURES) {
|
|
4777
|
+
const seconds = Math.min(2 ** (entry.count - LOGIN_FREE_FAILURES - 1), LOGIN_MAX_BACKOFF_SECONDS);
|
|
4778
|
+
entry.blockedUntil = nowMs + seconds * 1000;
|
|
4779
|
+
}
|
|
4780
|
+
state.loginFailures.set(key, entry);
|
|
4781
|
+
}
|
|
4782
|
+
|
|
4418
4783
|
async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
4419
4784
|
if (req.method === 'GET' && pathname === '/favicon.ico') {
|
|
4420
4785
|
res.writeHead(204, { 'Cache-Control': 'no-store' });
|
|
@@ -4430,8 +4795,18 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
|
4430
4795
|
}
|
|
4431
4796
|
|
|
4432
4797
|
// GET /auth/login 曾经是独立的登录页面,现在只留一个跳转,接住老书签
|
|
4798
|
+
// 带 ?token= 时是本机启动器的一次性登录:仅回环监听启用,令牌用后即删
|
|
4433
4799
|
if (req.method === 'GET' && pathname === '/auth/login') {
|
|
4434
|
-
|
|
4800
|
+
const token = new URL(req.url || '/', 'http://localhost').searchParams.get('token');
|
|
4801
|
+
if (token !== null && ctx.loginTokenDir && isLoopbackHost(ctx.serverHost) && consumeLoginToken(ctx.loginTokenDir, token)) {
|
|
4802
|
+
const sessionId = createWebAuthSession(state, ctx.authUser);
|
|
4803
|
+
sendRedirect(res, 302, '/', {
|
|
4804
|
+
'Set-Cookie': getWebAuthCookie(sessionId),
|
|
4805
|
+
'Referrer-Policy': 'no-referrer'
|
|
4806
|
+
});
|
|
4807
|
+
return true;
|
|
4808
|
+
}
|
|
4809
|
+
sendRedirect(res, 302, '/shadcn/auth/login', { 'Referrer-Policy': 'no-referrer' });
|
|
4435
4810
|
return true;
|
|
4436
4811
|
}
|
|
4437
4812
|
|
|
@@ -4445,12 +4820,23 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
|
4445
4820
|
return true;
|
|
4446
4821
|
}
|
|
4447
4822
|
|
|
4823
|
+
// 失败限速:按连接来源地址计数(不信任 X-Forwarded-For,伪造它就能绕过限速);
|
|
4824
|
+
// 反代后所有人共用一个来源,会被一起限速,这是有意的保守取舍
|
|
4825
|
+
const rateKey = String(req.socket && req.socket.remoteAddress || '');
|
|
4826
|
+
const retryAfter = getLoginRetryAfterSeconds(state, rateKey, ctx.nowMs());
|
|
4827
|
+
if (retryAfter > 0) {
|
|
4828
|
+
sendJson(res, 429, { error: `登录失败次数过多,请 ${retryAfter} 秒后重试` }, { 'Retry-After': String(retryAfter) });
|
|
4829
|
+
return true;
|
|
4830
|
+
}
|
|
4831
|
+
|
|
4448
4832
|
const userOk = secureStringEqual(username, ctx.authUser);
|
|
4449
4833
|
const passOk = secureStringEqual(password, ctx.authPass);
|
|
4450
4834
|
if (!(userOk && passOk)) {
|
|
4835
|
+
recordLoginFailure(state, rateKey, ctx.nowMs());
|
|
4451
4836
|
sendJson(res, 401, { error: '用户名或密码错误' });
|
|
4452
4837
|
return true;
|
|
4453
4838
|
}
|
|
4839
|
+
state.loginFailures.delete(rateKey);
|
|
4454
4840
|
|
|
4455
4841
|
const sessionId = createWebAuthSession(state, username);
|
|
4456
4842
|
sendJson(
|
|
@@ -4661,8 +5047,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4661
5047
|
}
|
|
4662
5048
|
|
|
4663
5049
|
const savePath = path.resolve(state.webConfigPath);
|
|
4664
|
-
|
|
4665
|
-
fs.writeFileSync(savePath, finalRaw, 'utf-8');
|
|
5050
|
+
writeConfigFileSecure(savePath, finalRaw);
|
|
4666
5051
|
|
|
4667
5052
|
sendJson(res, 200, {
|
|
4668
5053
|
saved: true,
|
|
@@ -4671,6 +5056,223 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4671
5056
|
});
|
|
4672
5057
|
}
|
|
4673
5058
|
},
|
|
5059
|
+
{
|
|
5060
|
+
method: 'GET',
|
|
5061
|
+
match: currentPath => currentPath === '/api/setup/status' ? [] : null,
|
|
5062
|
+
handler: async () => {
|
|
5063
|
+
sendJson(res, 200, await buildSetupStatus(ctx, state));
|
|
5064
|
+
}
|
|
5065
|
+
},
|
|
5066
|
+
{
|
|
5067
|
+
method: 'GET',
|
|
5068
|
+
match: currentPath => currentPath === '/api/setup/agents' ? [] : null,
|
|
5069
|
+
handler: async () => {
|
|
5070
|
+
sendJson(res, 200, { agents: listSetupAgents(), mirrorPresets: listMirrorPresets() });
|
|
5071
|
+
}
|
|
5072
|
+
},
|
|
5073
|
+
{
|
|
5074
|
+
method: 'POST',
|
|
5075
|
+
match: currentPath => currentPath === '/api/setup/agent' ? [] : null,
|
|
5076
|
+
handler: async () => {
|
|
5077
|
+
const payload = await readJsonBody(req);
|
|
5078
|
+
let input;
|
|
5079
|
+
try {
|
|
5080
|
+
input = resolveSetupRunInput(ctx, state, payload);
|
|
5081
|
+
} catch (e) {
|
|
5082
|
+
sendJson(res, 400, { error: e.message || '参数无效', detail: e.detail || '' });
|
|
5083
|
+
return;
|
|
5084
|
+
}
|
|
5085
|
+
|
|
5086
|
+
let nextRaw;
|
|
5087
|
+
try {
|
|
5088
|
+
nextRaw = buildAgentConfigRaw(baseRawOf(input.snapshot), input.agent, input.profile);
|
|
5089
|
+
parseAndValidateConfigRaw(nextRaw);
|
|
5090
|
+
} catch (e) {
|
|
5091
|
+
sendJson(res, 400, { error: '配置写入失败', detail: e.message || '' });
|
|
5092
|
+
return;
|
|
5093
|
+
}
|
|
5094
|
+
|
|
5095
|
+
const savePath = path.resolve(state.webConfigPath);
|
|
5096
|
+
writeConfigFileSecure(savePath, nextRaw);
|
|
5097
|
+
fs.mkdirSync(getSetupWorkpath(state), { recursive: true });
|
|
5098
|
+
|
|
5099
|
+
sendJson(res, 200, { saved: true, agent: input.agent, run: sanitizeSensitiveData(input.profile) });
|
|
5100
|
+
}
|
|
5101
|
+
},
|
|
5102
|
+
{
|
|
5103
|
+
method: 'POST',
|
|
5104
|
+
match: currentPath => currentPath === '/api/setup/password' ? [] : null,
|
|
5105
|
+
handler: async () => {
|
|
5106
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
5107
|
+
const reason = validateSetupPassword(payload.password);
|
|
5108
|
+
if (reason) {
|
|
5109
|
+
sendJson(res, 400, { error: reason });
|
|
5110
|
+
return;
|
|
5111
|
+
}
|
|
5112
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
5113
|
+
if (snapshot.parseError) {
|
|
5114
|
+
sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
|
|
5115
|
+
return;
|
|
5116
|
+
}
|
|
5117
|
+
let nextRaw;
|
|
5118
|
+
try {
|
|
5119
|
+
nextRaw = buildPasswordConfigRaw(baseRawOf(snapshot), payload.password);
|
|
5120
|
+
parseAndValidateConfigRaw(nextRaw);
|
|
5121
|
+
} catch (e) {
|
|
5122
|
+
sendJson(res, 400, { error: '配置写入失败' });
|
|
5123
|
+
return;
|
|
5124
|
+
}
|
|
5125
|
+
const savePath = path.resolve(state.webConfigPath);
|
|
5126
|
+
writeConfigFileSecure(savePath, nextRaw);
|
|
5127
|
+
// 就地生效:已登录会话保持有效,新登录用新密码;重启后配置里的 serverPass 优先于环境变量
|
|
5128
|
+
ctx.authPass = payload.password;
|
|
5129
|
+
ctx.authPassAuto = false;
|
|
5130
|
+
sendJson(res, 200, { saved: true });
|
|
5131
|
+
}
|
|
5132
|
+
},
|
|
5133
|
+
{
|
|
5134
|
+
method: 'POST',
|
|
5135
|
+
match: currentPath => currentPath === '/api/setup/mirrors' ? [] : null,
|
|
5136
|
+
handler: async () => {
|
|
5137
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
5138
|
+
let mirrors;
|
|
5139
|
+
try {
|
|
5140
|
+
mirrors = normalizeMirrors(payload.mirrors === undefined ? payload : payload.mirrors);
|
|
5141
|
+
} catch (e) {
|
|
5142
|
+
sendJson(res, 400, { error: e.message || '软件源无效' });
|
|
5143
|
+
return;
|
|
5144
|
+
}
|
|
5145
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
5146
|
+
if (snapshot.parseError) {
|
|
5147
|
+
sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
|
|
5148
|
+
return;
|
|
5149
|
+
}
|
|
5150
|
+
// 全部为官方默认且配置里本来没有 mirrors 时不写任何东西
|
|
5151
|
+
if (!shouldWriteMirrors(snapshot.parsed, mirrors)) {
|
|
5152
|
+
sendJson(res, 200, { saved: false, mirrors });
|
|
5153
|
+
return;
|
|
5154
|
+
}
|
|
5155
|
+
let nextRaw;
|
|
5156
|
+
try {
|
|
5157
|
+
nextRaw = buildMirrorsConfigRaw(baseRawOf(snapshot), mirrors);
|
|
5158
|
+
parseAndValidateConfigRaw(nextRaw);
|
|
5159
|
+
} catch (e) {
|
|
5160
|
+
sendJson(res, 400, { error: '配置写入失败' });
|
|
5161
|
+
return;
|
|
5162
|
+
}
|
|
5163
|
+
writeConfigFileSecure(path.resolve(state.webConfigPath), nextRaw);
|
|
5164
|
+
sendJson(res, 200, { saved: true, mirrors });
|
|
5165
|
+
}
|
|
5166
|
+
},
|
|
5167
|
+
{
|
|
5168
|
+
method: 'POST',
|
|
5169
|
+
match: currentPath => currentPath === '/api/setup/test-connection' ? [] : null,
|
|
5170
|
+
handler: async () => {
|
|
5171
|
+
const payload = await readJsonBody(req);
|
|
5172
|
+
let input;
|
|
5173
|
+
try {
|
|
5174
|
+
input = resolveSetupRunInput(ctx, state, payload);
|
|
5175
|
+
} catch (e) {
|
|
5176
|
+
sendJson(res, 400, { error: e.message || '参数无效', detail: e.detail || '' });
|
|
5177
|
+
return;
|
|
5178
|
+
}
|
|
5179
|
+
if (state.setupTestRunning) {
|
|
5180
|
+
sendJson(res, 409, { error: '已有连接测试正在进行,请稍后重试' });
|
|
5181
|
+
return;
|
|
5182
|
+
}
|
|
5183
|
+
state.setupTestRunning = true;
|
|
5184
|
+
try {
|
|
5185
|
+
const result = await runSetupConnectionTest(ctx, state, input.agent, toPlainObject(input.profile.env));
|
|
5186
|
+
sendJson(res, 200, result);
|
|
5187
|
+
} finally {
|
|
5188
|
+
state.setupTestRunning = false;
|
|
5189
|
+
}
|
|
5190
|
+
}
|
|
5191
|
+
},
|
|
5192
|
+
{
|
|
5193
|
+
method: 'GET',
|
|
5194
|
+
match: currentPath => currentPath === '/api/system/update' ? [] : null,
|
|
5195
|
+
handler: async () => {
|
|
5196
|
+
sendJson(res, 200, ctx.updateInfo());
|
|
5197
|
+
}
|
|
5198
|
+
},
|
|
5199
|
+
{
|
|
5200
|
+
method: 'POST',
|
|
5201
|
+
match: currentPath => currentPath === '/api/system/runtime/retry' ? [] : null,
|
|
5202
|
+
handler: async () => {
|
|
5203
|
+
// 失败/超时后重新自愈;正在进行或已就绪时只返回当前状态,不重复启动
|
|
5204
|
+
if (ctx.runtimeState.status === 'failed' && typeof ctx.retryRuntimeHeal === 'function') {
|
|
5205
|
+
ctx.retryRuntimeHeal();
|
|
5206
|
+
if (ctx.autoPullImage) startWebImagePrepare(ctx, state);
|
|
5207
|
+
startStaleSetupCleanup(ctx);
|
|
5208
|
+
}
|
|
5209
|
+
// 镜像拉取失败的条目会一直挂着:清掉后重新拉
|
|
5210
|
+
if (ctx.runtimeState.status === 'ready' && ctx.autoPullImage) {
|
|
5211
|
+
let cleared = false;
|
|
5212
|
+
for (const [ref, entry] of state.imagePulls) {
|
|
5213
|
+
if (entry.status === 'failed') {
|
|
5214
|
+
state.imagePulls.delete(ref);
|
|
5215
|
+
cleared = true;
|
|
5216
|
+
}
|
|
5217
|
+
}
|
|
5218
|
+
if (cleared) startWebImagePrepare(ctx, state);
|
|
5219
|
+
}
|
|
5220
|
+
sendJson(res, 202, { status: ctx.runtimeState.status, message: ctx.runtimeState.message });
|
|
5221
|
+
}
|
|
5222
|
+
},
|
|
5223
|
+
{
|
|
5224
|
+
method: 'GET',
|
|
5225
|
+
match: currentPath => currentPath === '/api/system/runtime' ? [] : null,
|
|
5226
|
+
handler: async () => {
|
|
5227
|
+
sendJson(res, 200, { status: ctx.runtimeState.status, message: ctx.runtimeState.message });
|
|
5228
|
+
}
|
|
5229
|
+
},
|
|
5230
|
+
{
|
|
5231
|
+
method: 'GET',
|
|
5232
|
+
match: currentPath => currentPath === '/api/system/doctor' ? [] : null,
|
|
5233
|
+
handler: async () => {
|
|
5234
|
+
if (typeof ctx.doctorCheck !== 'function') {
|
|
5235
|
+
sendJson(res, 503, { error: '环境检查不可用' });
|
|
5236
|
+
return;
|
|
5237
|
+
}
|
|
5238
|
+
try {
|
|
5239
|
+
sendJson(res, 200, buildWebDoctorReport(await ctx.doctorCheck()));
|
|
5240
|
+
} catch (e) {
|
|
5241
|
+
sendJson(res, 500, { error: '环境检查失败', detail: sanitizeDoctorText(e && e.message) });
|
|
5242
|
+
}
|
|
5243
|
+
}
|
|
5244
|
+
},
|
|
5245
|
+
{
|
|
5246
|
+
method: 'POST',
|
|
5247
|
+
match: currentPath => currentPath === '/api/system/doctor/fix' ? [] : null,
|
|
5248
|
+
handler: async () => {
|
|
5249
|
+
if (typeof ctx.doctorCheck !== 'function') {
|
|
5250
|
+
sendJson(res, 503, { error: '环境检查不可用' });
|
|
5251
|
+
return;
|
|
5252
|
+
}
|
|
5253
|
+
if (state.doctorFixRunning) {
|
|
5254
|
+
sendJson(res, 409, { error: '已有修复正在进行,请稍后重试' });
|
|
5255
|
+
return;
|
|
5256
|
+
}
|
|
5257
|
+
state.doctorFixRunning = true;
|
|
5258
|
+
try {
|
|
5259
|
+
const report = await ctx.doctorCheck();
|
|
5260
|
+
const fixed = await applyDoctorFixes(report, {
|
|
5261
|
+
startRuntime: typeof ctx.doctorStartRuntime === 'function' ? ctx.doctorStartRuntime : undefined,
|
|
5262
|
+
pullImage: async () => {
|
|
5263
|
+
const imageRef = webImageRef(ctx);
|
|
5264
|
+
await ensureWebImage(ctx, state, imageRef);
|
|
5265
|
+
return { fixed: true, message: `已准备镜像 ${imageRef}` };
|
|
5266
|
+
}
|
|
5267
|
+
});
|
|
5268
|
+
sendJson(res, 200, buildWebDoctorReport(fixed));
|
|
5269
|
+
} catch (e) {
|
|
5270
|
+
sendJson(res, 500, { error: '自动修复失败', detail: sanitizeDoctorText(e && e.message) });
|
|
5271
|
+
} finally {
|
|
5272
|
+
state.doctorFixRunning = false;
|
|
5273
|
+
}
|
|
5274
|
+
}
|
|
5275
|
+
},
|
|
4674
5276
|
{
|
|
4675
5277
|
method: 'GET',
|
|
4676
5278
|
match: currentPath => currentPath === '/api/system/capacity' ? [] : null,
|
|
@@ -4742,8 +5344,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4742
5344
|
}
|
|
4743
5345
|
|
|
4744
5346
|
const savePath = path.resolve(state.webConfigPath);
|
|
4745
|
-
|
|
4746
|
-
fs.writeFileSync(savePath, nextRaw, 'utf-8');
|
|
5347
|
+
writeConfigFileSecure(savePath, nextRaw);
|
|
4747
5348
|
|
|
4748
5349
|
sendJson(res, 200, { saved: true, path: quickChatPath, run: quickChatRun });
|
|
4749
5350
|
}
|
|
@@ -5745,6 +6346,15 @@ async function startWebServer(options) {
|
|
|
5745
6346
|
authPassAuto: options.authPassAuto,
|
|
5746
6347
|
serveTitle: typeof options.serveTitle === 'string' ? options.serveTitle : null,
|
|
5747
6348
|
dockerCmd: options.dockerCmd,
|
|
6349
|
+
dockerEnv: options.dockerEnv,
|
|
6350
|
+
loginTokenDir: options.loginTokenDir || '',
|
|
6351
|
+
doctorCheck: options.doctorCheck,
|
|
6352
|
+
doctorStartRuntime: options.doctorStartRuntime,
|
|
6353
|
+
runtimeState: options.runtimeState || { status: 'ready', message: '' },
|
|
6354
|
+
autoPullImage: Boolean(options.autoPullImage),
|
|
6355
|
+
// 离线安装器后台导入镜像的状态读取(默认没有导入)
|
|
6356
|
+
updateInfo: typeof options.updateInfo === 'function' ? options.updateInfo : () => ({ enabled: false, installMode: 'unknown', current: '', latest: '', updateAvailable: false, checkedAt: null, error: '' }),
|
|
6357
|
+
importState: typeof options.importState === 'function' ? options.importState : () => ({ active: false, message: '' }),
|
|
5748
6358
|
hostPath: options.hostPath,
|
|
5749
6359
|
containerPath: options.containerPath,
|
|
5750
6360
|
imageName: options.imageName,
|
|
@@ -5764,6 +6374,9 @@ async function startWebServer(options) {
|
|
|
5764
6374
|
getContainerStatus: options.getContainerStatus,
|
|
5765
6375
|
waitForContainerReady: options.waitForContainerReady,
|
|
5766
6376
|
dockerExecArgs: options.dockerExecArgs,
|
|
6377
|
+
dockerExecAsync: options.dockerExecAsync,
|
|
6378
|
+
nowMs: typeof options.nowMs === 'function' ? options.nowMs : Date.now,
|
|
6379
|
+
retryRuntimeHeal: options.retryRuntimeHeal,
|
|
5767
6380
|
showImagePullHint: options.showImagePullHint,
|
|
5768
6381
|
removeContainer: options.removeContainer,
|
|
5769
6382
|
logger: options.logger && typeof options.logger.info === 'function' ? options.logger : fallbackLogger,
|
|
@@ -5788,7 +6401,10 @@ async function startWebServer(options) {
|
|
|
5788
6401
|
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
5789
6402
|
authSessions: new Map(),
|
|
5790
6403
|
terminalSessions: new Map(),
|
|
5791
|
-
agentRuns: new Map()
|
|
6404
|
+
agentRuns: new Map(),
|
|
6405
|
+
// imageRef -> { status: 'pulling'|'failed', message, promise }
|
|
6406
|
+
imagePulls: new Map(),
|
|
6407
|
+
loginFailures: new Map()
|
|
5792
6408
|
};
|
|
5793
6409
|
|
|
5794
6410
|
ensureWebHistoryDir(state.webHistoryDir);
|
|
@@ -5812,6 +6428,8 @@ async function startWebServer(options) {
|
|
|
5812
6428
|
});
|
|
5813
6429
|
|
|
5814
6430
|
const server = http.createServer(async (req, res) => {
|
|
6431
|
+
// 身份标记:无参启动器复用实例前用它确认端口上跑的确实是 manyoyo serve(未登录的 401 也带);不含任何版本或本机信息
|
|
6432
|
+
res.setHeader('X-Manyoyo-Serve', '1');
|
|
5815
6433
|
try {
|
|
5816
6434
|
const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
|
|
5817
6435
|
const url = new URL(req.url, `http://${req.headers.host || fallbackHost}`);
|
|
@@ -5855,9 +6473,13 @@ async function startWebServer(options) {
|
|
|
5855
6473
|
|
|
5856
6474
|
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
5857
6475
|
} catch (e) {
|
|
6476
|
+
if (e && e.imagePreparing) {
|
|
6477
|
+
sendJson(res, 409, { error: e.message, imagePreparing: true });
|
|
6478
|
+
return;
|
|
6479
|
+
}
|
|
5858
6480
|
ctx.logger.error('http request error', {
|
|
5859
6481
|
method: req && req.method ? req.method : '',
|
|
5860
|
-
url: req && req.url ? req.url : '',
|
|
6482
|
+
url: redactLoginTokenInUrl(req && req.url ? req.url : ''),
|
|
5861
6483
|
message: e && e.message ? e.message : 'Server Error'
|
|
5862
6484
|
});
|
|
5863
6485
|
if ((req.url || '').startsWith('/api/')) {
|
|
@@ -5995,6 +6617,11 @@ async function startWebServer(options) {
|
|
|
5995
6617
|
});
|
|
5996
6618
|
});
|
|
5997
6619
|
|
|
6620
|
+
startStaleSetupCleanup(ctx);
|
|
6621
|
+
if (ctx.autoPullImage) {
|
|
6622
|
+
startWebImagePrepare(ctx, state);
|
|
6623
|
+
}
|
|
6624
|
+
|
|
5998
6625
|
return {
|
|
5999
6626
|
server,
|
|
6000
6627
|
wsServer,
|