@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/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 FRONTEND_DIR = path.join(__dirname, 'frontend');
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": "localhost/xcanwin/manyoyo",
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 isSafeStaticAssetName(name) {
4151
- return /^[A-Za-z0-9._-]+$/.test(name);
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(filePath, 'utf-8');
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 renderShadcnHtml(ctx) {
4186
- // shadcn.html 是 vite singlefile 打包产物,全部依赖内联成一个 <script>,
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(loadTemplate('shadcn.html'), ctx);
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
- // 登录页:与 / 、/shadcn 复用同一份构建产物,由前端按 pathname 自行渲染
4426
- // 登录表单,登录逻辑走下面的 POST /auth/login 接口
4427
- if (req.method === 'GET' && pathname === '/shadcn/auth/login') {
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
- sendRedirect(res, 302, '/shadcn/auth/login');
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, '/shadcn/auth/login', { 'Set-Cookie': getWebAuthClearCookie() });
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
- fs.mkdirSync(path.dirname(savePath), { recursive: true });
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
- fs.mkdirSync(path.dirname(savePath), { recursive: true });
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
- // shadcn/ui 版前端现在是默认主题,构建产物见 frontend-shadcn/(npm run build:web-shadcn)
6430
+ // 前端构建产物见 frontend/(npm run build:web),输出到 lib/web/index.html
5832
6431
  if (req.method === 'GET' && pathname === '/') {
5833
- sendHtml(res, 200, renderShadcnHtml(ctx));
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,