@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/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": "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,
@@ -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
- sendRedirect(res, 302, '/shadcn/auth/login');
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
- fs.mkdirSync(path.dirname(savePath), { recursive: true });
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
- fs.mkdirSync(path.dirname(savePath), { recursive: true });
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,