@co0ontty/wand 3.1.1 → 4.0.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.
Files changed (62) hide show
  1. package/dist/auth.d.ts +19 -5
  2. package/dist/auth.js +83 -45
  3. package/dist/build-info.json +3 -3
  4. package/dist/cert.d.ts +1 -1
  5. package/dist/cert.js +124 -74
  6. package/dist/config.js +25 -8
  7. package/dist/express-async.d.ts +6 -0
  8. package/dist/express-async.js +28 -0
  9. package/dist/git-quick-commit.d.ts +2 -0
  10. package/dist/git-quick-commit.js +215 -76
  11. package/dist/git-utils.d.ts +4 -0
  12. package/dist/git-utils.js +60 -11
  13. package/dist/git-worktree.d.ts +8 -1
  14. package/dist/git-worktree.js +406 -41
  15. package/dist/models.d.ts +34 -4
  16. package/dist/models.js +334 -48
  17. package/dist/process-manager.d.ts +22 -30
  18. package/dist/process-manager.js +374 -441
  19. package/dist/provider-history-scanner.d.ts +54 -0
  20. package/dist/provider-history-scanner.js +354 -0
  21. package/dist/request-limits.d.ts +1 -0
  22. package/dist/request-limits.js +8 -0
  23. package/dist/resume-policy.d.ts +2 -0
  24. package/dist/resume-policy.js +5 -0
  25. package/dist/runtime-config.d.ts +16 -0
  26. package/dist/runtime-config.js +49 -0
  27. package/dist/server-file-routes.d.ts +17 -0
  28. package/dist/server-file-routes.js +653 -0
  29. package/dist/server-session-routes.d.ts +16 -3
  30. package/dist/server-session-routes.js +170 -149
  31. package/dist/server-settings-routes.d.ts +43 -0
  32. package/dist/server-settings-routes.js +225 -0
  33. package/dist/server-update-routes.d.ts +61 -0
  34. package/dist/server-update-routes.js +215 -0
  35. package/dist/server.d.ts +6 -4
  36. package/dist/server.js +350 -1313
  37. package/dist/session-logger.d.ts +32 -2
  38. package/dist/session-logger.js +145 -15
  39. package/dist/session-registry.d.ts +27 -0
  40. package/dist/session-registry.js +153 -0
  41. package/dist/session-transport.d.ts +31 -0
  42. package/dist/session-transport.js +82 -0
  43. package/dist/storage.d.ts +24 -6
  44. package/dist/storage.js +291 -44
  45. package/dist/structured-claude-adapter.d.ts +19 -0
  46. package/dist/structured-claude-adapter.js +117 -0
  47. package/dist/structured-codex-adapter.d.ts +3 -0
  48. package/dist/structured-codex-adapter.js +29 -0
  49. package/dist/structured-opencode-adapter.d.ts +11 -0
  50. package/dist/structured-opencode-adapter.js +115 -0
  51. package/dist/structured-provider-common.d.ts +11 -0
  52. package/dist/structured-provider-common.js +77 -0
  53. package/dist/structured-session-manager.d.ts +32 -35
  54. package/dist/structured-session-manager.js +551 -605
  55. package/dist/types.d.ts +10 -0
  56. package/dist/update-helper.js +5 -1
  57. package/dist/web-ui/content/scripts.js +32 -32
  58. package/dist/web-ui/embedded-assets.d.ts +1 -1
  59. package/dist/web-ui/embedded-assets.js +2 -2
  60. package/dist/ws-broadcast.d.ts +16 -1
  61. package/dist/ws-broadcast.js +124 -58
  62. package/package.json +2 -1
package/dist/server.js CHANGED
@@ -2,31 +2,35 @@ import crypto from "node:crypto";
2
2
  import { compareApkInstallOrder, compareSemver, extractSemver } from "./version-utils.js";
3
3
  import compression from "compression";
4
4
  import express from "express";
5
- import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
6
- import { lstat, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { mkdir, readdir, readFile, stat } from "node:fs/promises";
7
7
  import { createServer as createHttpServer } from "node:http";
8
8
  import { createServer as createHttpsServer } from "node:https";
9
- import { exec, spawn } from "node:child_process";
9
+ import { spawn } from "node:child_process";
10
10
  import os from "node:os";
11
- import { promisify } from "node:util";
12
11
  import path from "node:path";
13
12
  import process from "node:process";
14
13
  import { WebSocketServer } from "ws";
15
- import { createSession, readSessionCookie, revokeSession, SESSION_COOKIE_HTTP, SESSION_COOKIE_HTTPS, SESSION_COOKIE_LEGACY, setAuthStorage, validateSession, } from "./auth.js";
14
+ import { AuthService, BROWSER_ADMIN_PRINCIPAL, CONNECTED_APP_PRINCIPAL, principalHasScope, readSessionCookie, SESSION_COOKIE_HTTP, SESSION_COOKIE_HTTPS, SESSION_COOKIE_LEGACY, } from "./auth.js";
16
15
  import { ensureCertificates } from "./cert.js";
17
- import { buildChildEnv } from "./env-utils.js";
18
- import { getDefaultModelForProvider, getProviderDefaultModels, isExecutionMode, normalizeMode, PREFERENCE_KEYS, resolveConfigDir, saveConfig, writePreferenceToStorage, } from "./config.js";
19
- import { getCachedModels, refreshModels } from "./models.js";
16
+ import { getDefaultModelForProvider, getProviderDefaultModels, isExecutionMode, resolveConfigDir, } from "./config.js";
17
+ import { refreshModels } from "./models.js";
20
18
  import { ProcessManager } from "./process-manager.js";
21
19
  import { SessionLogger } from "./session-logger.js";
20
+ import { SessionRegistry } from "./session-registry.js";
22
21
  import { StructuredSessionManager } from "./structured-session-manager.js";
22
+ import { recordRecentPath, registerFileRoutes } from "./server-file-routes.js";
23
+ import { registerSettingsRoutes } from "./server-settings-routes.js";
24
+ import { refreshProviderCliUpdateState, registerAdminUpdateRoutes, registerPublicUpdateRoutes, ServerUpdateState, } from "./server-update-routes.js";
23
25
  import { parseSessionCreationOrigin, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
24
26
  import { getErrorMessage } from "./error-utils.js";
27
+ import { asyncRoute, jsonErrorHandler } from "./express-async.js";
25
28
  import { checkPackageUpdateAsync, installPackageGloballyAsync, normalizeUpdateChannel, resolveGlobalWandCli, } from "./npm-update-utils.js";
26
29
  import { repairServiceUnitAfterUpdate } from "./service-self-repair.js";
27
30
  import { computeRelaunch } from "./relaunch.js";
31
+ import { RuntimeConfigState } from "./runtime-config.js";
28
32
  import { isServiceInstalled } from "./tui/commands.js";
29
- import { canUseDetachedUpdateHelper, checkManagedServiceUpdatePreflight, startDetachedUpdateHelper, } from "./update-helper.js";
33
+ import { checkManagedServiceUpdatePreflight, } from "./update-helper.js";
30
34
  import { registerUploadRoutes } from "./upload-routes.js";
31
35
  import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
32
36
  import { resolveDatabasePath, WandStorage } from "./storage.js";
@@ -37,9 +41,7 @@ import { EMBEDDED_WEB_ASSETS } from "./web-ui/embedded-assets.js";
37
41
  import { renderApp } from "./web-ui/index.js";
38
42
  import { WsBroadcastManager } from "./ws-broadcast.js";
39
43
  import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
40
- import { isPathWithinBase, isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
41
- import { checkProviderCliUpdates, updateProviderClis, verifyProviderCliUpdateResults, } from "./provider-cli-updater.js";
42
- const execAsync = promisify(exec);
44
+ import { updateProviderClis, verifyProviderCliUpdateResults, } from "./provider-cli-updater.js";
43
45
  const SERVER_MODULE_DIR = path.dirname(new URL(import.meta.url).pathname);
44
46
  const RUNTIME_ROOT_DIR = path.resolve(SERVER_MODULE_DIR, "..");
45
47
  // ── Package info ──
@@ -359,103 +361,83 @@ async function buildStructuredChatPersonaPayload(configPath, config) {
359
361
  return undefined;
360
362
  return { user, assistant };
361
363
  }
362
- // ── Git helpers ──
363
- async function getGitRepoRoot(dirPath) {
364
- try {
365
- const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd: dirPath });
366
- return stdout.trim();
367
- }
368
- catch {
369
- return null;
370
- }
371
- }
372
- async function getGitStatusMap(gitRoot) {
373
- const statusMap = new Map();
374
- try {
375
- const { stdout: stagedStdout } = await execAsync("git status --porcelain -uno", { cwd: gitRoot });
376
- const { stdout: untrackedStdout } = await execAsync("git ls-files --others --exclude-standard", { cwd: gitRoot });
377
- const lines = stagedStdout.split("\n").filter((line) => line.trim());
378
- for (const line of lines) {
379
- if (line.length < 4)
380
- continue;
381
- const stagedChar = line[0];
382
- const unstagedChar = line[1];
383
- const filePath = line.slice(3).trim();
384
- if (!filePath)
385
- continue;
386
- const status = {};
387
- if (stagedChar === "M")
388
- status.staged = "modified";
389
- else if (stagedChar === "A")
390
- status.staged = "added";
391
- else if (stagedChar === "D")
392
- status.staged = "deleted";
393
- else if (stagedChar === "R")
394
- status.staged = "renamed";
395
- if (unstagedChar === "M")
396
- status.unstaged = "modified";
397
- else if (unstagedChar === "D")
398
- status.unstaged = "deleted";
399
- statusMap.set(filePath, status);
400
- }
401
- const untrackedFiles = untrackedStdout.split("\n").filter((line) => line.trim());
402
- for (const filePath of untrackedFiles) {
403
- const existing = statusMap.get(filePath);
404
- if (existing) {
405
- existing.untracked = true;
406
- }
407
- else {
408
- statusMap.set(filePath, { untracked: true });
409
- }
410
- }
411
- return statusMap;
412
- }
413
- catch {
414
- return statusMap;
415
- }
416
- }
417
- async function enrichWithGitStatus(items, dirPath) {
418
- try {
419
- const gitRoot = await getGitRepoRoot(dirPath);
420
- if (!gitRoot)
421
- return items;
422
- const gitStatusMap = await getGitStatusMap(gitRoot);
423
- return items.map((item) => {
424
- const relativePath = path.relative(gitRoot, item.path);
425
- const normalizedPath = relativePath.replace(/\\/g, "/");
426
- const gitStatus = gitStatusMap.get(normalizedPath);
427
- return { ...item, gitStatus: gitStatus || undefined };
428
- });
429
- }
430
- catch {
431
- return items;
432
- }
433
- }
434
364
  // ── Auth helpers ──
435
- function buildRequireAuth(useHttps, storage, config) {
365
+ const requestPrincipals = new WeakMap();
366
+ function buildRequireAuth(useHttps, storage, config, authService) {
436
367
  return function requireAuth(req, res, next) {
437
- if (!validateSession(readSessionCookie(req, useHttps)) && !validateBearerAppToken(req, storage, config)) {
368
+ const principal = authService.authenticateSession(readSessionCookie(req, useHttps))
369
+ ?? authenticateBearerAppToken(req, storage, config);
370
+ if (!principal) {
371
+ res.status(401).json({ error: "未授权,请先登录。" });
372
+ return;
373
+ }
374
+ requestPrincipals.set(req, principal);
375
+ next();
376
+ };
377
+ }
378
+ function buildRequireScope(scope) {
379
+ return function requireScope(req, res, next) {
380
+ const principal = requestPrincipals.get(req);
381
+ if (!principal) {
438
382
  res.status(401).json({ error: "未授权,请先登录。" });
439
383
  return;
440
384
  }
385
+ if (!principalHasScope(principal, scope)) {
386
+ res.status(403).json({ error: "当前连接没有执行此操作的权限。" });
387
+ return;
388
+ }
441
389
  next();
442
390
  };
443
391
  }
392
+ const CONNECTED_APP_PREFERENCE_KEYS = new Set([
393
+ "defaultMode",
394
+ "defaultModel",
395
+ "defaultCodexModel",
396
+ "defaultOpenCodeModel",
397
+ "defaultModels",
398
+ "defaultThinkingEffort",
399
+ "defaultProvider",
400
+ "defaultSessionKind",
401
+ ]);
402
+ function requireAdminOrSessionPreferences(req, res, next) {
403
+ const principal = requestPrincipals.get(req);
404
+ if (!principal) {
405
+ res.status(401).json({ error: "未授权,请先登录。" });
406
+ return;
407
+ }
408
+ if (principalHasScope(principal, "admin")) {
409
+ next();
410
+ return;
411
+ }
412
+ const body = req.body && typeof req.body === "object" && !Array.isArray(req.body)
413
+ ? req.body
414
+ : {};
415
+ const keys = Object.keys(body);
416
+ if (!principalHasScope(principal, "session-preferences")
417
+ || keys.length === 0
418
+ || keys.some((key) => !CONNECTED_APP_PREFERENCE_KEYS.has(key))) {
419
+ res.status(403).json({ error: "当前连接只能修改新会话默认偏好。" });
420
+ return;
421
+ }
422
+ next();
423
+ }
444
424
  function getEffectivePassword(storage, config) {
445
425
  return storage.getPassword() ?? config.password;
446
426
  }
447
- function validateBearerAppToken(req, storage, config) {
427
+ function authenticateBearerAppToken(req, storage, config) {
448
428
  const header = firstHeaderValue(req.headers.authorization);
449
429
  if (!header?.startsWith("Bearer "))
450
- return false;
430
+ return null;
451
431
  const token = header.slice("Bearer ".length).trim();
452
432
  if (!token)
453
- return false;
433
+ return null;
454
434
  try {
455
- return verifyAppToken(token, getEffectivePassword(storage, config), config.appSecret ?? "");
435
+ return verifyAppToken(token, getEffectivePassword(storage, config), config.appSecret ?? "")
436
+ ? { ...CONNECTED_APP_PRINCIPAL, scopes: [...CONNECTED_APP_PRINCIPAL.scopes] }
437
+ : null;
456
438
  }
457
439
  catch {
458
- return false;
440
+ return null;
459
441
  }
460
442
  }
461
443
  function appTokenLoginPayload(storage, config) {
@@ -780,29 +762,6 @@ async function resolveMacosDmgAsset(configDir, config, configPath) {
780
762
  source: "local",
781
763
  };
782
764
  }
783
- async function listPathSuggestions(input, fallbackCwd) {
784
- const normalizedInput = input.trim();
785
- const baseInput = normalizedInput || fallbackCwd;
786
- const resolvedInput = normalizeFolderPath(baseInput);
787
- const endsWithSeparator = /[\\/]$/.test(normalizedInput);
788
- let searchDir = resolvedInput;
789
- let partialName = "";
790
- if (!endsWithSeparator) {
791
- searchDir = path.dirname(resolvedInput);
792
- partialName = path.basename(resolvedInput);
793
- }
794
- const entries = await readdir(searchDir, { withFileTypes: true });
795
- return entries
796
- .filter((entry) => entry.isDirectory())
797
- .filter((entry) => !partialName || entry.name.toLowerCase().startsWith(partialName.toLowerCase()))
798
- .sort((a, b) => a.name.localeCompare(b.name))
799
- .slice(0, 8)
800
- .map((entry) => ({
801
- path: path.join(searchDir, entry.name),
802
- name: entry.name,
803
- isDirectory: true,
804
- }));
805
- }
806
765
  // ── Startup error handling ──
807
766
  process.on("uncaughtException", (err) => {
808
767
  wandError("服务器异常", err.message, "请检查配置是否正确,或尝试重启服务。");
@@ -835,218 +794,6 @@ function wandWarn(message, hint) {
835
794
  if (hint)
836
795
  process.stderr.write(` 提示:${hint}\n`);
837
796
  }
838
- function parseStoredPathList(raw) {
839
- if (!raw)
840
- return [];
841
- try {
842
- const parsed = JSON.parse(raw);
843
- return Array.isArray(parsed) ? parsed : [];
844
- }
845
- catch {
846
- return [];
847
- }
848
- }
849
- const MAX_RECENT_PATHS = 10;
850
- /** Persist a cwd to recent paths. Used by both REST and session creation hooks. */
851
- export function recordRecentPath(storage, cwd) {
852
- if (!cwd)
853
- return;
854
- const trimmed = cwd.trim();
855
- if (!trimmed)
856
- return;
857
- let resolved;
858
- try {
859
- resolved = normalizeFolderPath(trimmed);
860
- }
861
- catch {
862
- return;
863
- }
864
- if (isBlockedFolderPath(resolved))
865
- return;
866
- const stored = storage.getConfigValue("recent_paths");
867
- let recent = parseStoredPathList(stored);
868
- recent = recent.filter((r) => normalizeFolderPath(r.path) !== resolved);
869
- recent.unshift({
870
- path: resolved,
871
- name: path.basename(resolved),
872
- lastUsedAt: new Date().toISOString(),
873
- });
874
- recent = recent.slice(0, MAX_RECENT_PATHS);
875
- storage.setConfigValue("recent_paths", JSON.stringify(recent));
876
- }
877
- // ── File language detection ──
878
- function getLanguageFromExt(ext, filePath) {
879
- const map = {
880
- ".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "jsx",
881
- ".json": "json", ".html": "html", ".htm": "html",
882
- ".css": "css", ".scss": "scss", ".less": "less",
883
- ".py": "python", ".rb": "ruby", ".go": "go", ".rs": "rust",
884
- ".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
885
- ".cs": "csharp", ".swift": "swift", ".kt": "kotlin", ".scala": "scala",
886
- ".php": "php", ".sh": "bash", ".bash": "bash", ".zsh": "bash",
887
- ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".ini": "ini",
888
- ".xml": "xml", ".sql": "sql", ".graphql": "graphql",
889
- ".md": "markdown", ".markdown": "markdown", ".mdown": "markdown",
890
- ".mkd": "markdown", ".mkdn": "markdown",
891
- ".dockerfile": "dockerfile", ".gitignore": "plaintext",
892
- ".diff": "diff", ".patch": "diff", ".proto": "protobuf",
893
- ".env": "bash", ".editorconfig": "ini",
894
- ".mdx": "markdown", ".vue": "html", ".svelte": "html",
895
- };
896
- const baseName = path.basename(filePath).toLowerCase();
897
- if (baseName === "dockerfile")
898
- return "dockerfile";
899
- if (baseName === ".gitignore")
900
- return "plaintext";
901
- return map[ext] || "plaintext";
902
- }
903
- // ── File preview classification ──
904
- const TEXT_PREVIEWABLE_EXTS = new Set([
905
- ".md", ".markdown", ".mdown", ".mkd", ".mkdn", ".mdx",
906
- ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
907
- ".json", ".jsonc", ".html", ".htm", ".css", ".scss", ".less",
908
- ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
909
- ".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh", ".fish",
910
- ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
911
- ".xml", ".sql", ".graphql", ".proto",
912
- ".dockerfile", ".gitignore", ".editorconfig",
913
- ".vue", ".svelte",
914
- ".txt", ".log", ".diff", ".patch",
915
- ".lua", ".r", ".dart", ".pl", ".pm",
916
- ]);
917
- const IMAGE_EXTS = new Set([
918
- ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif",
919
- ".bmp", ".ico", ".heic", ".heif",
920
- ]);
921
- const VIDEO_EXTS = new Set([
922
- ".mp4", ".webm", ".mov", ".mkv", ".m4v", ".ogv",
923
- ]);
924
- const AUDIO_EXTS = new Set([
925
- ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".opus",
926
- ]);
927
- const PDF_EXTS = new Set([".pdf"]);
928
- const MIME_BY_EXT = {
929
- ".png": "image/png",
930
- ".jpg": "image/jpeg",
931
- ".jpeg": "image/jpeg",
932
- ".gif": "image/gif",
933
- ".webp": "image/webp",
934
- ".svg": "image/svg+xml",
935
- ".avif": "image/avif",
936
- ".bmp": "image/bmp",
937
- ".ico": "image/x-icon",
938
- ".heic": "image/heic",
939
- ".heif": "image/heif",
940
- ".pdf": "application/pdf",
941
- ".mp4": "video/mp4",
942
- ".webm": "video/webm",
943
- ".mov": "video/quicktime",
944
- ".mkv": "video/x-matroska",
945
- ".m4v": "video/x-m4v",
946
- ".ogv": "video/ogg",
947
- ".mp3": "audio/mpeg",
948
- ".wav": "audio/wav",
949
- ".ogg": "audio/ogg",
950
- ".m4a": "audio/mp4",
951
- ".flac": "audio/flac",
952
- ".aac": "audio/aac",
953
- ".opus": "audio/opus",
954
- };
955
- const TEXT_BASENAME_ALLOW = new Set([
956
- "dockerfile", ".gitignore", ".dockerignore", ".env", ".env.local",
957
- ".env.development", ".env.production", ".env.test",
958
- "makefile", "readme", "license", "changelog",
959
- ]);
960
- function classifyFile(ext, baseName) {
961
- const lowerExt = ext.toLowerCase();
962
- const lowerBase = baseName.toLowerCase();
963
- if (IMAGE_EXTS.has(lowerExt))
964
- return "image";
965
- if (PDF_EXTS.has(lowerExt))
966
- return "pdf";
967
- if (VIDEO_EXTS.has(lowerExt))
968
- return "video";
969
- if (AUDIO_EXTS.has(lowerExt))
970
- return "audio";
971
- if (TEXT_PREVIEWABLE_EXTS.has(lowerExt))
972
- return "text";
973
- if (TEXT_BASENAME_ALLOW.has(lowerBase))
974
- return "text";
975
- // Files with no extension that look like text-y dotfiles
976
- if (lowerExt === "" && /^[a-z0-9._-]+$/i.test(lowerBase))
977
- return "text";
978
- return "binary";
979
- }
980
- function mimeForExt(ext) {
981
- return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
982
- }
983
- function parseByteRange(rangeHeader, total) {
984
- if (!rangeHeader)
985
- return null;
986
- const trimmed = rangeHeader.trim();
987
- if (!trimmed.startsWith("bytes="))
988
- return null;
989
- const match = /^bytes=(\d*)-(\d*)$/.exec(trimmed);
990
- if (!match || (match[1] === "" && match[2] === ""))
991
- return "invalid";
992
- let start;
993
- let end;
994
- if (match[1] === "") {
995
- const suffixLength = Number(match[2]);
996
- if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0)
997
- return "invalid";
998
- start = Math.max(0, total - suffixLength);
999
- end = total - 1;
1000
- }
1001
- else {
1002
- start = Number(match[1]);
1003
- end = match[2] === "" ? total - 1 : Math.min(Number(match[2]), total - 1);
1004
- }
1005
- if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || start >= total) {
1006
- return "invalid";
1007
- }
1008
- return { start, end };
1009
- }
1010
- function streamFileWithRange(req, res, options) {
1011
- res.setHeader("Content-Type", options.contentType);
1012
- if (options.disposition)
1013
- res.setHeader("Content-Disposition", options.disposition);
1014
- for (const [name, value] of Object.entries(options.headers ?? {})) {
1015
- res.setHeader(name, value);
1016
- }
1017
- res.setHeader("Accept-Ranges", "bytes");
1018
- if (options.size === 0) {
1019
- if (req.headers.range?.trim().startsWith("bytes=")) {
1020
- res.status(416).setHeader("Content-Range", "bytes */0").end();
1021
- return;
1022
- }
1023
- res.setHeader("Content-Length", "0");
1024
- res.end();
1025
- return;
1026
- }
1027
- const parsedRange = parseByteRange(req.headers.range, options.size);
1028
- if (parsedRange === "invalid") {
1029
- res.status(416).setHeader("Content-Range", `bytes */${options.size}`).end();
1030
- return;
1031
- }
1032
- const start = parsedRange?.start ?? 0;
1033
- const end = parsedRange?.end ?? options.size - 1;
1034
- if (parsedRange) {
1035
- res.status(206);
1036
- res.setHeader("Content-Range", `bytes ${start}-${end}/${options.size}`);
1037
- }
1038
- res.setHeader("Content-Length", String(end - start + 1));
1039
- const stream = createReadStream(options.filePath, { start, end });
1040
- stream.on("error", (err) => {
1041
- if (!res.headersSent) {
1042
- res.status(500).json({ error: getErrorMessage(err, options.readErrorMessage ?? "读取文件失败。") });
1043
- }
1044
- else {
1045
- res.destroy();
1046
- }
1047
- });
1048
- stream.pipe(res);
1049
- }
1050
797
  export class PortInUseError extends Error {
1051
798
  port;
1052
799
  host;
@@ -1064,7 +811,7 @@ export function isPortInUseError(error) {
1064
811
  && typeof error === "object"
1065
812
  && error.code === "EADDRINUSE");
1066
813
  }
1067
- export async function startServer(config, configPath) {
814
+ export async function startServer(config, configPath, options = {}) {
1068
815
  // 关键:在创建 ProcessManager / 任何 spawn 之前先修 PATH。
1069
816
  // 服务被注册为 systemd / launchd 时,unit 文件里的 PATH 是安装那一刻烧死的,
1070
817
  // 之后用户切 node 版本 / 重装 wand / 把 claude 装到新位置都不会更新 unit,
@@ -1087,27 +834,57 @@ export async function startServer(config, configPath) {
1087
834
  process.stdout.write(`[wand] ${formatPathRepairSummary(pathRepair)}\n`);
1088
835
  }
1089
836
  const app = express();
837
+ let shuttingDown = false;
1090
838
  app.set("trust proxy", "loopback, 172.16.0.0/12");
1091
839
  const storage = new WandStorage(resolveDatabasePath(configPath));
1092
- setAuthStorage(storage);
840
+ const runtimeConfig = new RuntimeConfigState(config);
841
+ const authService = new AuthService(storage);
842
+ const getModelRefreshOptions = () => {
843
+ const injected = options.modelRefreshOptions?.() ?? {};
844
+ return {
845
+ storage,
846
+ inheritEnv: config.inheritEnv !== false,
847
+ apiKey: process.env.ANTHROPIC_API_KEY,
848
+ ...injected,
849
+ configuredClaudeModels: [
850
+ getProviderDefaultModels(config).claude,
851
+ config.commitCli === "claude" ? config.commitModel : undefined,
852
+ ...(injected.configuredClaudeModels ?? []),
853
+ ],
854
+ };
855
+ };
1093
856
  const configDir = resolveConfigDir(configPath);
1094
857
  const processes = new ProcessManager(config, storage, configDir);
1095
858
  const structuredLogger = new SessionLogger(configDir, config.shortcutLogMaxBytes);
1096
859
  const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger);
1097
- let providerCliUpdateCache = null;
1098
- let providerCliUpdateInFlight = false;
1099
- let updateInFlight = false;
860
+ const sessionRegistry = new SessionRegistry(processes, structuredSessions, storage);
861
+ const updateState = new ServerUpdateState();
862
+ const getUpdateChannel = () => normalizeUpdateChannel(storage.getConfigValue("updateChannel"));
863
+ let disconnectAuthenticatedSockets = () => { };
1100
864
  const refreshProviderCliUpdates = async () => {
1101
- const items = await checkProviderCliUpdates({ inheritEnv: config.inheritEnv !== false });
1102
- const result = { items, checkedAt: new Date().toISOString() };
1103
- providerCliUpdateCache = result;
1104
- return result;
865
+ return refreshProviderCliUpdateState(updateState, config);
1105
866
  };
1106
867
  const useHttps = config.https === true;
1107
868
  const protocol = useHttps ? "https" : "http";
1108
- const requireAuth = buildRequireAuth(useHttps, storage, config);
869
+ const requireAuth = buildRequireAuth(useHttps, storage, config, authService);
870
+ const requireAdmin = buildRequireScope("admin");
871
+ const requireSessions = buildRequireScope("sessions");
872
+ const requireFiles = buildRequireScope("files");
873
+ const requirePasswordVault = buildRequireScope("password-vault");
874
+ // Route-specific parsers must run before the global parser. Once body-parser
875
+ // has consumed a request, a later express.json() cannot tighten or widen it.
876
+ app.use("/api/optimize-prompt", express.json({ limit: "256kb" }));
877
+ app.use("/api/file-write", express.json({ limit: "2mb" }));
1109
878
  app.use(express.json({ limit: "1mb" }));
1110
879
  app.use(compression({ threshold: 1024 }));
880
+ app.use((_req, res, next) => {
881
+ if (!shuttingDown) {
882
+ next();
883
+ return;
884
+ }
885
+ res.setHeader("Connection", "close");
886
+ res.status(503).json({ error: "Server is shutting down." });
887
+ });
1111
888
  app.use((req, res, next) => {
1112
889
  const origin = firstHeaderValue(req.headers.origin);
1113
890
  if (origin && isBrowserExtensionOrigin(origin)) {
@@ -1136,7 +913,7 @@ export async function startServer(config, configPath) {
1136
913
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
1137
914
  res.type("html").send(renderApp(configPath));
1138
915
  });
1139
- app.get("/api/structured-chat-avatar/:role", async (req, res) => {
916
+ app.get("/api/structured-chat-avatar/:role", asyncRoute(async (req, res) => {
1140
917
  const role = req.params.role === "user" || req.params.role === "assistant"
1141
918
  ? req.params.role
1142
919
  : null;
@@ -1179,7 +956,7 @@ export async function startServer(config, configPath) {
1179
956
  catch {
1180
957
  res.status(404).end();
1181
958
  }
1182
- });
959
+ }));
1183
960
  // ── Auth routes ──
1184
961
  app.post("/api/login", (req, res) => {
1185
962
  const clientIp = req.ip || req.socket.remoteAddress || "unknown";
@@ -1189,26 +966,29 @@ export async function startServer(config, configPath) {
1189
966
  }
1190
967
  const { password, appToken, client } = req.body;
1191
968
  const effectivePassword = getEffectivePassword(storage, config);
1192
- // App token login derived from password, so password change invalidates it
1193
- let authenticated = false;
969
+ // App token login is intentionally restricted even though the token remains
970
+ // password-derived for compatibility with existing connect codes.
971
+ let principal = null;
1194
972
  if (appToken) {
1195
973
  try {
1196
- authenticated = verifyAppToken(appToken, effectivePassword, config.appSecret ?? "");
974
+ if (verifyAppToken(appToken, effectivePassword, config.appSecret ?? "")) {
975
+ principal = { ...CONNECTED_APP_PRINCIPAL, scopes: [...CONNECTED_APP_PRINCIPAL.scopes] };
976
+ }
1197
977
  }
1198
978
  catch {
1199
- authenticated = false;
979
+ principal = null;
1200
980
  }
1201
981
  }
1202
- if (!authenticated) {
982
+ if (!principal) {
1203
983
  if (password !== effectivePassword) {
1204
984
  recordFailedLogin(clientIp);
1205
985
  res.status(401).json({ error: "密码错误,请重试。" });
1206
986
  return;
1207
987
  }
1208
- authenticated = true;
988
+ principal = { ...BROWSER_ADMIN_PRINCIPAL, scopes: [...BROWSER_ADMIN_PRINCIPAL.scopes] };
1209
989
  }
1210
990
  resetRateLimit(clientIp);
1211
- const token = createSession();
991
+ const token = authService.createSession(principal);
1212
992
  const cookieOpts = {
1213
993
  httpOnly: true,
1214
994
  sameSite: "strict",
@@ -1231,117 +1011,70 @@ export async function startServer(config, configPath) {
1231
1011
  }
1232
1012
  res.json({
1233
1013
  ok: true,
1014
+ principal,
1234
1015
  ...(client === "browser-extension" ? appTokenLoginPayload(storage, config) : {}),
1235
1016
  });
1236
1017
  });
1237
1018
  app.post("/api/logout", (req, res) => {
1238
- revokeSession(readSessionCookie(req, useHttps));
1019
+ authService.revokeSession(readSessionCookie(req, useHttps));
1239
1020
  // 全部名字都清一遍,避免遗留 cookie 在下次同源访问时被回放。
1240
1021
  for (const name of [SESSION_COOKIE_HTTPS, SESSION_COOKIE_HTTP, SESSION_COOKIE_LEGACY]) {
1241
1022
  res.clearCookie(name, { path: "/" });
1242
1023
  }
1243
1024
  res.json({ ok: true });
1244
1025
  });
1245
- app.post("/api/set-password", requireAuth, (req, res) => {
1026
+ app.post("/api/set-password", requireAuth, requireAdmin, (req, res) => {
1246
1027
  const { password } = req.body;
1247
1028
  if (!password || password.length < 6) {
1248
1029
  res.status(400).json({ error: "密码长度至少为 6 个字符。" });
1249
1030
  return;
1250
1031
  }
1251
1032
  storage.setPassword(password);
1252
- res.json({ ok: true });
1253
- });
1254
- // ── Android APK update & download (no auth required) ──
1255
- app.get("/api/android-apk-update", async (req, res) => {
1256
- const currentVersion = req.query.currentVersion?.trim();
1257
- if (!currentVersion) {
1258
- res.status(400).json({ error: "Missing currentVersion query parameter." });
1259
- return;
1260
- }
1261
- // 更新通道:beta 包含 -debug.* 构建,stable(默认,含不传参的老客户端)只推正式版。
1262
- const channel = parseApkChannel(req.query.channel);
1263
- const latest = await resolveLatestApkVersion(configDir, config, channel, configPath);
1264
- if (!latest) {
1265
- res.json({ updateAvailable: false, currentVersion, latestVersion: null, downloadUrl: null, source: null, channel });
1266
- return;
1267
- }
1268
- // 安装序比较(镜像 versionCode),不是标准 semver:只在系统安装器真能装上时才提示,
1269
- // 避免「提示升级 → 下载 → 被按降级拒装」的死循环(如已装 1.55.0-debug 提示装 1.55.0)。
1270
- const updateAvailable = compareApkInstallOrder(latest.version, currentVersion) > 0;
1271
- res.json({
1272
- updateAvailable,
1273
- currentVersion,
1274
- latestVersion: latest.version,
1275
- downloadUrl: updateAvailable ? latest.downloadUrl : null,
1276
- fileName: updateAvailable ? latest.fileName : null,
1277
- size: updateAvailable ? latest.size : null,
1278
- source: latest.source,
1279
- channel,
1280
- releaseNotes: updateAvailable ? (latest.releaseNotes ?? null) : null,
1281
- });
1282
- });
1283
- app.get("/android/download", async (req, res) => {
1284
- // 更新弹窗的下载链接由 /api/android-apk-update 按通道生成(始终带 ?channel=)。
1285
- // 裸 /android/download(网页下载页、二维码落地页)不带参时默认 beta ——
1286
- // 保持「下载页拿到的就是目录里真正最新的包」的旧行为。
1287
- const channel = req.query.channel === "stable" ? "stable" : "beta";
1288
- const androidApk = await resolveAndroidApkAsset(configDir, config, channel, configPath);
1289
- if (!androidApk) {
1290
- res.status(404).json({ error: "当前没有可下载的 APK 文件。" });
1291
- return;
1292
- }
1293
- streamFileWithRange(req, res, {
1294
- filePath: androidApk.filePath,
1295
- size: androidApk.size,
1296
- contentType: "application/vnd.android.package-archive",
1297
- disposition: `attachment; filename="${encodeURIComponent(androidApk.fileName)}"`,
1298
- readErrorMessage: "读取 APK 文件失败。",
1299
- });
1300
- });
1301
- // ── macOS DMG update & download (no auth required) ──
1302
- app.get("/api/macos-dmg-update", async (req, res) => {
1303
- const currentVersion = req.query.currentVersion?.trim();
1304
- if (!currentVersion) {
1305
- res.status(400).json({ error: "Missing currentVersion query parameter." });
1306
- return;
1307
- }
1308
- const latest = await resolveLatestDmgVersion(configDir, config, configPath);
1309
- if (!latest) {
1310
- res.json({ updateAvailable: false, currentVersion, latestVersion: null, downloadUrl: null, source: null });
1311
- return;
1033
+ authService.revokeAllSessions();
1034
+ disconnectAuthenticatedSockets();
1035
+ for (const name of [SESSION_COOKIE_HTTPS, SESSION_COOKIE_HTTP, SESSION_COOKIE_LEGACY]) {
1036
+ res.clearCookie(name, { path: "/" });
1312
1037
  }
1313
- const updateAvailable = compareSemver(latest.version, currentVersion) > 0;
1314
- res.json({
1315
- updateAvailable,
1316
- currentVersion,
1317
- latestVersion: latest.version,
1318
- downloadUrl: updateAvailable ? latest.downloadUrl : null,
1319
- fileName: updateAvailable ? latest.fileName : null,
1320
- size: updateAvailable ? latest.size : null,
1321
- source: latest.source,
1322
- });
1038
+ res.json({ ok: true, reauthenticationRequired: true });
1323
1039
  });
1324
- app.get("/macos/download", async (req, res) => {
1325
- const macosDmg = await resolveMacosDmgAsset(configDir, config, configPath);
1326
- if (!macosDmg) {
1327
- res.status(404).json({ error: "当前没有可下载的 DMG 文件。" });
1328
- return;
1329
- }
1330
- streamFileWithRange(req, res, {
1331
- filePath: macosDmg.filePath,
1332
- size: macosDmg.size,
1333
- contentType: "application/x-apple-diskimage",
1334
- disposition: `attachment; filename="${encodeURIComponent(macosDmg.fileName)}"`,
1335
- readErrorMessage: "读取 DMG 文件失败。",
1336
- });
1040
+ // ── Android APK update & download (no auth required) ──
1041
+ registerPublicUpdateRoutes(app, {
1042
+ resolveLatestApk: (channel) => resolveLatestApkVersion(configDir, config, channel, configPath),
1043
+ resolveAndroidDownload: (channel) => resolveAndroidApkAsset(configDir, config, channel, configPath),
1044
+ resolveLatestDmg: () => resolveLatestDmgVersion(configDir, config, configPath),
1045
+ resolveMacosDownload: () => resolveMacosDmgAsset(configDir, config, configPath),
1337
1046
  });
1338
1047
  // Public probe so the unauthenticated browser does not log a 401 on /api/config
1339
1048
  app.get("/api/session-check", (req, res) => {
1340
- res.json({ authed: validateSession(readSessionCookie(req, useHttps)) });
1049
+ res.json({ authed: authService.validateSession(readSessionCookie(req, useHttps)) });
1341
1050
  });
1342
1051
  app.use("/api", requireAuth);
1052
+ // Connected apps receive only the route families used by native clients and
1053
+ // the browser extension. Browser-admin sessions implicitly satisfy all scopes.
1054
+ app.use([
1055
+ "/api/config",
1056
+ "/api/models",
1057
+ "/api/sessions",
1058
+ "/api/structured-sessions",
1059
+ "/api/commands",
1060
+ "/api/claude-history",
1061
+ "/api/codex-history",
1062
+ "/api/claude-sessions",
1063
+ "/api/codex-sessions",
1064
+ "/api/optimize-prompt",
1065
+ ], requireSessions);
1066
+ app.use([
1067
+ "/api/directory",
1068
+ "/api/folders",
1069
+ "/api/path-suggestions",
1070
+ "/api/recent-paths",
1071
+ "/api/file-preview",
1072
+ "/api/file-raw",
1073
+ "/api/file-write",
1074
+ ], requireFiles);
1075
+ app.use("/api/browser-extension", requirePasswordVault);
1343
1076
  // ── Config & Session info ──
1344
- app.get("/api/config", async (_req, res) => {
1077
+ app.get("/api/config", asyncRoute(async (_req, res) => {
1345
1078
  const structuredChatPersona = await buildStructuredChatPersonaPayload(configPath, config);
1346
1079
  const defaultModels = getProviderDefaultModels(config);
1347
1080
  res.json({
@@ -1375,7 +1108,7 @@ export async function startServer(config, configPath) {
1375
1108
  packageVersion: PKG_VERSION,
1376
1109
  serverInstanceId: SERVER_INSTANCE_ID,
1377
1110
  });
1378
- });
1111
+ }));
1379
1112
  // ── Browser extension password vault endpoints ──
1380
1113
  app.get("/api/browser-extension/status", (_req, res) => {
1381
1114
  res.json({
@@ -1490,17 +1223,10 @@ export async function startServer(config, configPath) {
1490
1223
  res.json({ report: buildPasswordSecurityReport(storage.listPasswordItems({ includeArchived: false, limit: 200 })) });
1491
1224
  });
1492
1225
  // ── Settings endpoints ──
1493
- app.get("/api/settings", async (_req, res) => {
1494
- const certPaths = {
1495
- keyPath: path.join(configDir, "server.key"),
1496
- certPath: path.join(configDir, "server.crt"),
1497
- };
1498
- const { password: _pw, ...safeConfig } = config;
1499
- const defaultModels = getProviderDefaultModels(config);
1226
+ const getDistributionSettings = async () => {
1500
1227
  const localApk = await resolveAndroidApkAsset(configDir, config, "beta", configPath);
1501
1228
  const ghApk = await fetchGitHubLatestApk();
1502
1229
  const apkDir = resolveAndroidApkDir(configDir, config);
1503
- // Backward-compatible: pick best available for hasApk/version/downloadUrl
1504
1230
  const resolvedApk = localApk
1505
1231
  ? { hasApk: true, fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl, source: "local" }
1506
1232
  : ghApk
@@ -1514,34 +1240,7 @@ export async function startServer(config, configPath) {
1514
1240
  : ghDmg
1515
1241
  ? { hasDmg: true, fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, updatedAt: null, downloadUrl: ghDmg.downloadUrl, source: "github" }
1516
1242
  : null;
1517
- res.json({
1518
- version: DISPLAY_VERSION,
1519
- packageName: PKG_NAME,
1520
- nodeVersion: PKG_NODE_REQ,
1521
- repoUrl: PKG_REPO_URL,
1522
- config: {
1523
- ...safeConfig,
1524
- defaultModel: defaultModels.claude,
1525
- defaultCodexModel: defaultModels.codex,
1526
- defaultOpenCodeModel: defaultModels.opencode,
1527
- defaultModels,
1528
- },
1529
- hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
1530
- updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
1531
- latestVersion: cachedUpdateInfo?.latest ?? null,
1532
- updateChannel: getUpdateChannel(),
1533
- build: {
1534
- commit: BUILD_INFO.commit,
1535
- shortCommit: BUILD_INFO.commit ? BUILD_INFO.commit.slice(0, 7) : null,
1536
- builtAt: BUILD_INFO.builtAt,
1537
- channel: BUILD_INFO.channel,
1538
- },
1539
- autoUpdate: {
1540
- web: storage.getConfigValue("autoUpdateWeb") === "true",
1541
- apk: storage.getConfigValue("autoUpdateApk") === "true",
1542
- dmg: storage.getConfigValue("autoUpdateDmg") === "true",
1543
- cli: storage.getConfigValue("autoUpdateProviderClis") === "true",
1544
- },
1243
+ return {
1545
1244
  androidApk: {
1546
1245
  enabled: config.android?.enabled === true,
1547
1246
  apkDir,
@@ -1568,295 +1267,47 @@ export async function startServer(config, configPath) {
1568
1267
  local: localDmg ? { fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl } : null,
1569
1268
  github: ghDmg ? { fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, downloadUrl: ghDmg.downloadUrl } : null,
1570
1269
  },
1571
- });
1572
- });
1573
- app.get("/api/android-apk", async (_req, res) => {
1574
- const localApk = await resolveAndroidApkAsset(configDir, config, "beta", configPath);
1575
- const ghApk = await fetchGitHubLatestApk();
1576
- const apkDir = resolveAndroidApkDir(configDir, config);
1577
- const resolvedApk = localApk
1578
- ? { hasApk: true, fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl, source: "local" }
1579
- : ghApk
1580
- ? { hasApk: true, fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, updatedAt: null, downloadUrl: ghApk.downloadUrl, source: "github" }
1581
- : null;
1582
- res.json({
1583
- enabled: config.android?.enabled === true,
1584
- apkDir,
1585
- hasApk: resolvedApk?.hasApk ?? false,
1586
- fileName: resolvedApk?.fileName ?? null,
1587
- version: resolvedApk?.version ?? null,
1588
- size: resolvedApk?.size ?? null,
1589
- updatedAt: resolvedApk?.updatedAt ?? null,
1590
- downloadUrl: resolvedApk?.downloadUrl ?? null,
1591
- source: resolvedApk?.source ?? null,
1592
- local: localApk ? { fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl } : null,
1593
- github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
1594
- });
1595
- });
1596
- app.get("/api/macos-dmg", async (_req, res) => {
1597
- const localDmg = await resolveMacosDmgAsset(configDir, config, configPath);
1598
- const ghDmg = await fetchGitHubLatestDmg();
1599
- const dmgDir = resolveMacosDmgDir(configDir, config);
1600
- const resolvedDmg = localDmg
1601
- ? { hasDmg: true, fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl, source: "local" }
1602
- : ghDmg
1603
- ? { hasDmg: true, fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, updatedAt: null, downloadUrl: ghDmg.downloadUrl, source: "github" }
1604
- : null;
1605
- res.json({
1606
- enabled: config.macos?.enabled === true,
1607
- dmgDir,
1608
- hasDmg: resolvedDmg?.hasDmg ?? false,
1609
- fileName: resolvedDmg?.fileName ?? null,
1610
- version: resolvedDmg?.version ?? null,
1611
- size: resolvedDmg?.size ?? null,
1612
- updatedAt: resolvedDmg?.updatedAt ?? null,
1613
- downloadUrl: resolvedDmg?.downloadUrl ?? null,
1614
- source: resolvedDmg?.source ?? null,
1615
- local: localDmg ? { fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl } : null,
1616
- github: ghDmg ? { fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, downloadUrl: ghDmg.downloadUrl } : null,
1617
- });
1618
- });
1619
- // 返回当前 inheritEnv 配置下,wand 启动 PTY / 结构化子进程时实际会传给
1620
- // claude / codex 的环境变量集合。值会按下面的规则做掩码:
1621
- // - 名字里含 KEY/TOKEN/SECRET/PASSWORD/AUTH/CREDENTIAL/COOKIE/SESSION 的视为敏感
1622
- // - 敏感值默认显示为 ***(保留长度提示),可通过 ?reveal=1 取消掩码
1623
- // 即使开启 reveal,仍只对已认证用户可见(路由由全局 requireAuth 保护)。
1624
- app.get("/api/settings/env-preview", (req, res) => {
1625
- const inheritEnv = config.inheritEnv !== false;
1626
- // 复用与 process-manager / structured-session-manager 相同的组装逻辑,
1627
- // 这样 UI 上看到的就是真正会被注入到子进程的那一份环境。
1628
- const env = buildChildEnv(inheritEnv, {
1629
- // PTY runner 还会注入 WAND_* 用于 mode 协调,这里也展示出来便于排查。
1630
- WAND_MODE: "<runtime>",
1631
- WAND_AUTO_CONFIRM: "<runtime>",
1632
- WAND_AUTO_EDIT: "<runtime>",
1633
- });
1634
- const reveal = req.query.reveal === "1" || req.query.reveal === "true";
1635
- const SENSITIVE_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|AUTH|CREDENTIAL|COOKIE|SESSION)/i;
1636
- const entries = Object.keys(env)
1637
- .sort()
1638
- .map((name) => {
1639
- const raw = env[name] ?? "";
1640
- const sensitive = SENSITIVE_PATTERN.test(name);
1641
- const masked = sensitive && !reveal;
1642
- // WAND_* 占位值不算敏感,保持原样。
1643
- const isPlaceholder = raw.startsWith("<") && raw.endsWith(">");
1644
- return {
1645
- name,
1646
- value: masked && !isPlaceholder ? "***" : raw,
1647
- length: raw.length,
1648
- sensitive,
1649
- };
1650
- });
1651
- res.json({
1652
- inheritEnv,
1653
- total: entries.length,
1654
- reveal,
1655
- entries,
1656
- });
1657
- });
1658
- app.get("/api/app-connect-code", requireAuth, (req, res) => {
1659
- const effectivePassword = getEffectivePassword(storage, config);
1660
- const protocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
1661
- const host = getPublicRequestHost(req, config);
1662
- const browserOrigin = normalizePublicOrigin(firstQueryStringValue(req.query.origin));
1663
- const serverUrl = resolveAppConnectOrigin(browserOrigin ?? `${protocol}://${host}`, config);
1664
- const appSecret = config.appSecret ?? "";
1665
- const token = generateAppToken(effectivePassword, appSecret);
1666
- const code = encodeConnectCode(serverUrl, token);
1667
- res.json({ code, url: serverUrl });
1668
- });
1669
- app.post("/api/settings/config", async (req, res) => {
1670
- const body = req.body;
1671
- // 部署字段:写 JSON,需要重启服务才生效(host/port/https 影响监听,shell 影响新 PTY)
1672
- const deployFields = ["host", "port", "https", "shell"];
1673
- let touchedDeployField = false;
1674
- let touchedPreferenceField = false;
1675
- for (const field of deployFields) {
1676
- if (!(field in body) || body[field] === undefined)
1677
- continue;
1678
- if (field === "port") {
1679
- const p = Number(body.port);
1680
- if (!Number.isInteger(p) || p < 1 || p > 65535) {
1681
- res.status(400).json({ error: `无效端口号: ${body.port}` });
1682
- return;
1683
- }
1684
- config.port = p;
1685
- }
1686
- else if (field === "https") {
1687
- config.https = body.https === true;
1688
- }
1689
- else if (field === "host") {
1690
- config.host = String(body.host);
1691
- }
1692
- else if (field === "shell") {
1693
- config.shell = String(body.shell);
1694
- }
1695
- touchedDeployField = true;
1696
- }
1697
- // 偏好字段:写 SQLite app_config,立即热生效(manager 持有 config 同一引用)。
1698
- // defaultMode 单独做严格校验以保留 400 错误响应,其余字段走 writePreferenceToStorage 的统一类型化处理。
1699
- if (body.defaultMode !== undefined && !isExecutionMode(body.defaultMode)) {
1700
- res.status(400).json({ error: `无效执行模式: ${body.defaultMode}` });
1701
- return;
1702
- }
1703
- if (body.defaultModels && typeof body.defaultModels === "object") {
1704
- const modelDefaults = body.defaultModels;
1705
- try {
1706
- if (Object.prototype.hasOwnProperty.call(modelDefaults, "claude")) {
1707
- writePreferenceToStorage(config, storage, "defaultModel", modelDefaults.claude);
1708
- touchedPreferenceField = true;
1709
- }
1710
- if (Object.prototype.hasOwnProperty.call(modelDefaults, "codex")) {
1711
- writePreferenceToStorage(config, storage, "defaultCodexModel", modelDefaults.codex);
1712
- touchedPreferenceField = true;
1713
- }
1714
- if (Object.prototype.hasOwnProperty.call(modelDefaults, "opencode")) {
1715
- writePreferenceToStorage(config, storage, "defaultOpenCodeModel", modelDefaults.opencode);
1716
- touchedPreferenceField = true;
1717
- }
1718
- }
1719
- catch (err) {
1720
- res.status(400).json({ error: getErrorMessage(err, "默认模型配置校验失败") });
1721
- return;
1722
- }
1723
- }
1724
- for (const field of PREFERENCE_KEYS) {
1725
- if (!(field in body) || body[field] === undefined)
1726
- continue;
1727
- try {
1728
- writePreferenceToStorage(config, storage, field, body[field]);
1729
- }
1730
- catch (err) {
1731
- res.status(400).json({ error: getErrorMessage(err, `字段 ${field} 校验失败`) });
1732
- return;
1733
- }
1734
- touchedPreferenceField = true;
1735
- }
1736
- if (!touchedDeployField && !touchedPreferenceField) {
1737
- res.status(400).json({ error: "没有可更新的配置字段。" });
1738
- return;
1739
- }
1740
- try {
1741
- if (touchedDeployField) {
1742
- await saveConfig(configPath, config);
1743
- }
1744
- const { password: _pw, ...safeConfig } = config;
1745
- const defaultModels = getProviderDefaultModels(config);
1746
- // 只有部署字段才需要重启;偏好字段已经热生效。
1747
- res.json({
1748
- ok: true,
1749
- config: {
1750
- ...safeConfig,
1751
- defaultModel: defaultModels.claude,
1752
- defaultCodexModel: defaultModels.codex,
1753
- defaultOpenCodeModel: defaultModels.opencode,
1754
- defaultModels,
1755
- },
1756
- restartRequired: touchedDeployField,
1757
- });
1758
- }
1759
- catch (error) {
1760
- res.status(500).json({ error: getErrorMessage(error, "保存配置失败。") });
1761
- }
1762
- });
1763
- app.get("/api/models", (_req, res) => {
1764
- const cached = getCachedModels();
1765
- const defaultModels = getProviderDefaultModels(config);
1766
- res.json({
1767
- models: cached.models,
1768
- codexModels: cached.codexModels,
1769
- opencodeModels: cached.opencodeModels,
1770
- claudeVersion: cached.claudeVersion,
1771
- opencodeVersion: cached.opencodeVersion,
1772
- refreshedAt: cached.refreshedAt,
1773
- defaultModel: defaultModels.claude,
1774
- defaultCodexModel: defaultModels.codex,
1775
- defaultOpenCodeModel: defaultModels.opencode,
1776
- defaultModels,
1777
- });
1778
- });
1779
- app.post("/api/models/refresh", async (_req, res) => {
1780
- try {
1781
- const refreshed = await refreshModels();
1782
- const defaultModels = getProviderDefaultModels(config);
1783
- res.json({
1784
- models: refreshed.models,
1785
- codexModels: refreshed.codexModels,
1786
- opencodeModels: refreshed.opencodeModels,
1787
- claudeVersion: refreshed.claudeVersion,
1788
- opencodeVersion: refreshed.opencodeVersion,
1789
- refreshedAt: refreshed.refreshedAt,
1790
- defaultModel: defaultModels.claude,
1791
- defaultCodexModel: defaultModels.codex,
1792
- defaultOpenCodeModel: defaultModels.opencode,
1793
- defaultModels,
1794
- });
1795
- }
1796
- catch (error) {
1797
- res.status(500).json({ error: getErrorMessage(error, "刷新模型列表失败。") });
1798
- }
1799
- });
1800
- app.get("/api/provider-cli-updates", async (req, res) => {
1801
- try {
1802
- const force = req.query.refresh === "1" || !providerCliUpdateCache;
1803
- const data = force ? await refreshProviderCliUpdates() : providerCliUpdateCache;
1804
- res.json({
1805
- ...data,
1806
- updating: providerCliUpdateInFlight,
1807
- autoUpdate: storage.getConfigValue("autoUpdateProviderClis") === "true",
1808
- });
1809
- }
1810
- catch (error) {
1811
- res.status(500).json({ error: getErrorMessage(error, "检查 CLI 更新失败。") });
1812
- }
1813
- });
1814
- app.post("/api/provider-cli-updates", async (req, res) => {
1815
- if (providerCliUpdateInFlight || updateInFlight) {
1816
- res.status(409).json({ error: "CLI 更新正在进行中,请稍候。" });
1817
- return;
1818
- }
1819
- const rawIds = Array.isArray(req.body?.ids) ? req.body.ids : [];
1820
- const ids = rawIds.filter((value) => value === "claude" || value === "codex" || value === "opencode");
1821
- providerCliUpdateInFlight = true;
1822
- try {
1823
- const before = await refreshProviderCliUpdates();
1824
- const commandResults = await updateProviderClis(before.items, ids.length ? ids : undefined, {
1825
- inheritEnv: config.inheritEnv !== false,
1826
- onLog: (line) => process.stdout.write(`[wand] ${line}\n`),
1827
- });
1828
- const after = await refreshProviderCliUpdates();
1829
- const results = verifyProviderCliUpdateResults(commandResults, after.items);
1830
- refreshModels().catch(() => { });
1831
- res.json({ ok: results.every((item) => item.ok), results, ...after, autoUpdate: storage.getConfigValue("autoUpdateProviderClis") === "true" });
1832
- }
1833
- catch (error) {
1834
- res.status(500).json({ error: getErrorMessage(error, "更新 CLI 失败。") });
1835
- }
1836
- finally {
1837
- providerCliUpdateInFlight = false;
1838
- }
1270
+ };
1271
+ };
1272
+ registerSettingsRoutes(app, {
1273
+ storage,
1274
+ config,
1275
+ runtimeConfig,
1276
+ configPath,
1277
+ configDir,
1278
+ requireAdmin,
1279
+ requireAdminOrSessionPreferences,
1280
+ packageInfo: { version: DISPLAY_VERSION, name: PKG_NAME, nodeVersion: PKG_NODE_REQ, repoUrl: PKG_REPO_URL },
1281
+ buildInfo: BUILD_INFO,
1282
+ getCachedUpdateInfo: () => cachedUpdateInfo,
1283
+ getUpdateChannel,
1284
+ getDistributionSettings,
1285
+ getModelRefreshOptions,
1286
+ resolveAppConnectCode: (req) => {
1287
+ const effectivePassword = getEffectivePassword(storage, config);
1288
+ const requestProtocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
1289
+ const requestHost = getPublicRequestHost(req, config);
1290
+ const browserOrigin = normalizePublicOrigin(firstQueryStringValue(req.query.origin));
1291
+ const serverUrl = resolveAppConnectOrigin(browserOrigin ?? `${requestProtocol}://${requestHost}`, config);
1292
+ const token = generateAppToken(effectivePassword, config.appSecret ?? "");
1293
+ return { code: encodeConnectCode(serverUrl, token), url: serverUrl };
1294
+ },
1839
1295
  });
1840
- app.post("/api/settings/upload-cert", async (req, res) => {
1841
- const { key, cert } = req.body;
1842
- if (!key || !cert) {
1843
- res.status(400).json({ error: "请提供 key 和 cert 内容。" });
1844
- return;
1845
- }
1846
- if (!key.includes("-----BEGIN") || !cert.includes("-----BEGIN")) {
1847
- res.status(400).json({ error: "证书内容格式无效,请上传 PEM 格式的文件。" });
1848
- return;
1849
- }
1850
- try {
1851
- const keyPath = path.join(configDir, "server.key");
1852
- const certPath = path.join(configDir, "server.crt");
1853
- writeFileSync(keyPath, key, { mode: 0o600 });
1854
- writeFileSync(certPath, cert, { mode: 0o644 });
1855
- res.json({ ok: true, restartRequired: true });
1856
- }
1857
- catch (error) {
1858
- res.status(500).json({ error: getErrorMessage(error, "保存证书失败。") });
1859
- }
1296
+ registerAdminUpdateRoutes(app, {
1297
+ storage,
1298
+ config,
1299
+ configPath,
1300
+ requireAdmin,
1301
+ state: updateState,
1302
+ getDistributionSettings,
1303
+ getModelRefreshOptions,
1304
+ getUpdateChannel,
1305
+ checkLatestPackageVersion,
1306
+ buildInfo: BUILD_INFO,
1307
+ serverInstanceId: SERVER_INSTANCE_ID,
1308
+ emitSystemNotification: (data) => {
1309
+ wsManager.emitEvent({ type: "notification", sessionId: "__system__", data });
1310
+ },
1860
1311
  });
1861
1312
  // ── Global npm install (with leftover cleanup + ENOTEMPTY fallback) ──
1862
1313
  // 把所有恢复逻辑下沉到 ./npm-update-utils,TUI 和 server 共用,确保自动更新、
@@ -1866,92 +1317,12 @@ export async function startServer(config, configPath) {
1866
1317
  process.stdout.write(`${line}\n`);
1867
1318
  });
1868
1319
  }
1869
- /** 当前更新通道:stable(npm @latest,纯 tag)或 beta(npm @beta,tag + commit 尾标)。 */
1870
- const getUpdateChannel = () => normalizeUpdateChannel(storage.getConfigValue("updateChannel"));
1871
- app.get("/api/check-update", async (_req, res) => {
1872
- try {
1873
- const channel = getUpdateChannel();
1874
- const info = await checkLatestPackageVersion(channel, true);
1875
- res.json({
1876
- ...info,
1877
- build: {
1878
- commit: BUILD_INFO.commit,
1879
- shortCommit: BUILD_INFO.commit ? BUILD_INFO.commit.slice(0, 7) : null,
1880
- builtAt: BUILD_INFO.builtAt,
1881
- channel: BUILD_INFO.channel,
1882
- },
1883
- });
1884
- }
1885
- catch (error) {
1886
- res.status(500).json({ error: getErrorMessage(error, "检查更新失败。") });
1887
- }
1888
- });
1889
- app.post("/api/update", async (_req, res) => {
1890
- if (updateInFlight || providerCliUpdateInFlight) {
1891
- res.status(409).json({ error: "更新正在进行中,请稍候。" });
1892
- return;
1893
- }
1894
- updateInFlight = true;
1895
- try {
1896
- const channel = getUpdateChannel();
1897
- const info = await checkLatestPackageVersion(channel, true);
1898
- if (!info.latest) {
1899
- res.status(502).json({ error: "无法连接到 npm registry。" });
1900
- return;
1901
- }
1902
- const targetLabel = info.latest;
1903
- const reinstalling = !info.updateAvailable;
1904
- if (!canUseDetachedUpdateHelper()) {
1905
- res.status(500).json({ error: "当前平台暂不支持 Web 异步更新,请在终端运行 install.sh 更新。" });
1906
- return;
1907
- }
1908
- const helper = startDetachedUpdateHelper({
1909
- installSpec: info.installSpec,
1910
- configPath,
1911
- parentPid: process.pid,
1912
- cliArgs: process.argv.slice(2),
1913
- cwd: process.cwd(),
1914
- env: process.env,
1915
- timeoutMs: 300000,
1916
- });
1917
- if (!helper.started) {
1918
- res.status(500).json({ error: helper.message, detail: `script=${helper.scriptPath}\nlog=${helper.logPath}` });
1919
- return;
1920
- }
1921
- process.stdout.write(`[wand] ${helper.message}\n`);
1922
- wsManager.emitEvent({
1923
- type: "notification",
1924
- sessionId: "__system__",
1925
- data: {
1926
- kind: "auto-update-restart",
1927
- current: info.current,
1928
- latest: targetLabel,
1929
- previousInstanceId: SERVER_INSTANCE_ID,
1930
- },
1931
- });
1932
- res.json({
1933
- ok: true,
1934
- message: reinstalling ? `已开始重新安装 ${targetLabel}` : `已开始更新到 ${targetLabel}`,
1935
- restartRequired: false,
1936
- detachedUpdate: true,
1937
- version: targetLabel,
1938
- previousInstanceId: SERVER_INSTANCE_ID,
1939
- logPath: helper.logPath,
1940
- });
1941
- }
1942
- catch (error) {
1943
- res.status(500).json({ error: getErrorMessage(error, "更新失败。") });
1944
- }
1945
- finally {
1946
- updateInFlight = false;
1947
- }
1948
- });
1949
- registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode, config, (cwd) => {
1320
+ registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode, config, sessionRegistry, (cwd) => {
1950
1321
  recordRecentPath(storage, cwd);
1951
1322
  });
1952
- registerClaudeHistoryRoutes(app, processes, storage);
1323
+ registerClaudeHistoryRoutes(app, processes, structuredSessions, storage, sessionRegistry);
1953
1324
  registerUploadRoutes(app, processes);
1954
- app.post("/api/optimize-prompt", express.json({ limit: "256kb" }), async (req, res) => {
1325
+ app.post("/api/optimize-prompt", asyncRoute(async (req, res) => {
1955
1326
  const body = (req.body ?? {});
1956
1327
  const text = typeof body.text === "string" ? body.text : "";
1957
1328
  let cwd;
@@ -1972,425 +1343,8 @@ export async function startServer(config, configPath) {
1972
1343
  }
1973
1344
  res.status(500).json({ error: getErrorMessage(error, "提示词优化失败。") });
1974
1345
  }
1975
- });
1976
- // ── Path suggestion ──
1977
- app.get("/api/path-suggestions", async (req, res) => {
1978
- const query = typeof req.query.q === "string" ? req.query.q : "";
1979
- try {
1980
- const suggestions = await listPathSuggestions(query, config.defaultCwd);
1981
- res.json(suggestions);
1982
- }
1983
- catch (error) {
1984
- res.status(400).json({ error: getErrorMessage(error, "无法加载路径建议。") });
1985
- }
1986
- });
1987
- // ── File browsing ──
1988
- const DIRECTORY_MAX_ITEMS = 200;
1989
- app.get("/api/directory", async (req, res) => {
1990
- const q = typeof req.query.q === "string" ? req.query.q : "";
1991
- const includeGitStatus = req.query.gitStatus === "true";
1992
- const targetPath = path.resolve(q || config.defaultCwd);
1993
- try {
1994
- const entries = await readdir(targetPath, { withFileTypes: true });
1995
- const sorted = entries.sort((a, b) => {
1996
- if (a.isDirectory() && !b.isDirectory())
1997
- return -1;
1998
- if (!a.isDirectory() && b.isDirectory())
1999
- return 1;
2000
- return a.name.localeCompare(b.name);
2001
- });
2002
- const total = sorted.length;
2003
- const truncated = total > DIRECTORY_MAX_ITEMS;
2004
- const sliced = sorted.slice(0, DIRECTORY_MAX_ITEMS);
2005
- // Fetch size/mtime in parallel; tolerate per-entry failures.
2006
- let items = await Promise.all(sliced.map(async (entry) => {
2007
- const fullPath = path.join(targetPath, entry.name);
2008
- const isDir = entry.isDirectory();
2009
- const base = {
2010
- path: fullPath,
2011
- name: entry.name,
2012
- type: isDir ? "dir" : "file",
2013
- };
2014
- if (isDir)
2015
- return base;
2016
- try {
2017
- const st = await lstat(fullPath);
2018
- base.size = st.size;
2019
- base.mtime = st.mtime.toISOString();
2020
- }
2021
- catch {
2022
- // Permission errors etc — leave size/mtime undefined.
2023
- }
2024
- return base;
2025
- }));
2026
- if (includeGitStatus) {
2027
- items = await enrichWithGitStatus(items, targetPath);
2028
- }
2029
- const payload = { items, truncated, total };
2030
- res.json(payload);
2031
- }
2032
- catch (error) {
2033
- res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
2034
- }
2035
- });
2036
- const MAX_TEXT_PREVIEW_SIZE = 512 * 1024;
2037
- app.get("/api/file-preview", async (req, res) => {
2038
- const filePath = typeof req.query.path === "string" ? req.query.path : "";
2039
- if (!filePath) {
2040
- res.status(400).json({ error: "Missing path parameter" });
2041
- return;
2042
- }
2043
- const resolvedPath = path.resolve(filePath);
2044
- if (isBlockedFolderPath(resolvedPath)) {
2045
- res.status(403).json({ error: "Access denied" });
2046
- return;
2047
- }
2048
- try {
2049
- const fileStat = await stat(resolvedPath);
2050
- if (fileStat.isDirectory()) {
2051
- res.status(400).json({ error: "Cannot preview a directory" });
2052
- return;
2053
- }
2054
- const ext = path.extname(filePath).toLowerCase();
2055
- const baseName = path.basename(filePath);
2056
- const kind = classifyFile(ext, baseName);
2057
- const mime = mimeForExt(ext);
2058
- // Non-text kinds: respond with metadata so the client can pick a renderer.
2059
- if (kind !== "text") {
2060
- const payload = {
2061
- kind,
2062
- path: resolvedPath,
2063
- name: baseName,
2064
- ext,
2065
- size: fileStat.size,
2066
- mime,
2067
- };
2068
- res.json(payload);
2069
- return;
2070
- }
2071
- // Text/code preview path — still subject to the 512 KB cap.
2072
- if (fileStat.size > MAX_TEXT_PREVIEW_SIZE) {
2073
- res.status(413).json({
2074
- error: "文件太大,无法在线预览(限 512 KB)。",
2075
- truncated: true,
2076
- size: fileStat.size,
2077
- maxSize: MAX_TEXT_PREVIEW_SIZE,
2078
- });
2079
- return;
2080
- }
2081
- const content = await readFile(resolvedPath, "utf-8");
2082
- const lang = getLanguageFromExt(ext, filePath);
2083
- const payload = {
2084
- kind: "text",
2085
- path: resolvedPath,
2086
- name: baseName,
2087
- ext,
2088
- size: fileStat.size,
2089
- mime,
2090
- lang,
2091
- content,
2092
- };
2093
- res.json(payload);
2094
- }
2095
- catch (error) {
2096
- res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
2097
- }
2098
- });
2099
- // Write/overwrite a text file's content. Used by the file-preview modal's
2100
- // edit mode. Only text-classified files are writable, and only when the file
2101
- // already exists (we never create files via this endpoint to keep the surface
2102
- // narrow). Atomic via tmp-file + rename to avoid partial writes.
2103
- const MAX_TEXT_WRITE_SIZE = 1024 * 1024; // 1 MB cap for safety
2104
- app.post("/api/file-write", express.json({ limit: "2mb" }), async (req, res) => {
2105
- const body = (req.body ?? {});
2106
- const filePath = typeof body.path === "string" ? body.path : "";
2107
- const content = typeof body.content === "string" ? body.content : null;
2108
- if (!filePath || content === null) {
2109
- res.status(400).json({ error: "缺少 path 或 content 参数。" });
2110
- return;
2111
- }
2112
- const resolvedPath = path.resolve(filePath);
2113
- if (isBlockedFolderPath(resolvedPath)) {
2114
- res.status(403).json({ error: "访问被拒绝:无法修改系统目录下的文件。" });
2115
- return;
2116
- }
2117
- // Encode-size check (UTF-8 byte length, not character length).
2118
- const byteLength = Buffer.byteLength(content, "utf-8");
2119
- if (byteLength > MAX_TEXT_WRITE_SIZE) {
2120
- res.status(413).json({
2121
- error: `内容超出保存上限(${Math.round(MAX_TEXT_WRITE_SIZE / 1024)} KB)。`,
2122
- size: byteLength,
2123
- maxSize: MAX_TEXT_WRITE_SIZE,
2124
- });
2125
- return;
2126
- }
2127
- try {
2128
- const fileStat = await stat(resolvedPath);
2129
- if (fileStat.isDirectory()) {
2130
- res.status(400).json({ error: "目标是目录,无法写入。" });
2131
- return;
2132
- }
2133
- if (!fileStat.isFile()) {
2134
- res.status(400).json({ error: "目标不是普通文件。" });
2135
- return;
2136
- }
2137
- const ext = path.extname(resolvedPath).toLowerCase();
2138
- const baseName = path.basename(resolvedPath);
2139
- const kind = classifyFile(ext, baseName);
2140
- if (kind !== "text") {
2141
- res.status(415).json({ error: "仅支持编辑文本类文件。" });
2142
- return;
2143
- }
2144
- // Atomic write: dump to a sibling temp file, then rename.
2145
- const dir = path.dirname(resolvedPath);
2146
- const tmpPath = path.join(dir, `.${baseName}.wand-tmp-${crypto.randomBytes(6).toString("hex")}`);
2147
- try {
2148
- await writeFile(tmpPath, content, { encoding: "utf-8", mode: fileStat.mode & 0o777 });
2149
- await rename(tmpPath, resolvedPath);
2150
- }
2151
- catch (writeError) {
2152
- // Best-effort cleanup if rename failed but tmp got created.
2153
- try {
2154
- await unlink(tmpPath);
2155
- }
2156
- catch { }
2157
- throw writeError;
2158
- }
2159
- const newStat = await stat(resolvedPath);
2160
- res.json({
2161
- ok: true,
2162
- path: resolvedPath,
2163
- size: newStat.size,
2164
- mtime: newStat.mtime.toISOString(),
2165
- });
2166
- }
2167
- catch (error) {
2168
- res.status(400).json({ error: getErrorMessage(error, "保存文件失败。") });
2169
- }
2170
- });
2171
- // Streams the raw bytes of a file for inline media previews (image/PDF/video/audio)
2172
- // and downloads. Honors HTTP Range so video/audio scrubbing works.
2173
- const RAW_MAX_BYTES_BY_KIND = {
2174
- text: 5 * 1024 * 1024,
2175
- image: 50 * 1024 * 1024,
2176
- pdf: 50 * 1024 * 1024,
2177
- video: 200 * 1024 * 1024,
2178
- audio: 200 * 1024 * 1024,
2179
- binary: 50 * 1024 * 1024,
2180
- };
2181
- app.get("/api/file-raw", async (req, res) => {
2182
- const filePath = typeof req.query.path === "string" ? req.query.path : "";
2183
- const asDownload = req.query.download === "1" || req.query.download === "true";
2184
- if (!filePath) {
2185
- res.status(400).json({ error: "Missing path parameter" });
2186
- return;
2187
- }
2188
- const resolvedPath = path.resolve(filePath);
2189
- if (isBlockedFolderPath(resolvedPath)) {
2190
- res.status(403).json({ error: "Access denied" });
2191
- return;
2192
- }
2193
- try {
2194
- const fileStat = await stat(resolvedPath);
2195
- if (!fileStat.isFile()) {
2196
- res.status(400).json({ error: "Not a regular file" });
2197
- return;
2198
- }
2199
- const ext = path.extname(filePath).toLowerCase();
2200
- const baseName = path.basename(filePath);
2201
- const kind = classifyFile(ext, baseName);
2202
- const cap = RAW_MAX_BYTES_BY_KIND[kind] ?? RAW_MAX_BYTES_BY_KIND.binary;
2203
- if (fileStat.size > cap) {
2204
- res.status(413).json({
2205
- error: `文件超出可在线预览的上限(${Math.round(cap / 1024 / 1024)} MB)。`,
2206
- size: fileStat.size,
2207
- maxSize: cap,
2208
- });
2209
- return;
2210
- }
2211
- const mime = mimeForExt(ext);
2212
- // SVG can be served with its proper type; binary fallback uses octet-stream.
2213
- const contentType = kind === "binary" ? "application/octet-stream" : mime;
2214
- // Encode the filename for Content-Disposition (RFC 5987).
2215
- const encodedName = encodeURIComponent(baseName);
2216
- const disposition = asDownload
2217
- ? `attachment; filename*=UTF-8''${encodedName}`
2218
- : `inline; filename*=UTF-8''${encodedName}`;
2219
- streamFileWithRange(req, res, {
2220
- filePath: resolvedPath,
2221
- size: fileStat.size,
2222
- contentType,
2223
- disposition,
2224
- headers: {
2225
- "Cache-Control": "private, max-age=60",
2226
- "X-Content-Type-Options": "nosniff",
2227
- },
2228
- readErrorMessage: "Failed to read file",
2229
- });
2230
- }
2231
- catch (error) {
2232
- res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
2233
- }
2234
- });
2235
- app.get("/api/folders", async (req, res) => {
2236
- const q = typeof req.query.q === "string" ? req.query.q : "/tmp";
2237
- const targetPath = normalizeFolderPath(q);
2238
- if (isBlockedFolderPath(targetPath)) {
2239
- res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
2240
- return;
2241
- }
2242
- try {
2243
- const entries = await readdir(targetPath, { withFileTypes: true });
2244
- const items = [];
2245
- const parentPath = path.dirname(targetPath);
2246
- if (parentPath !== targetPath) {
2247
- items.push({ path: parentPath, name: "..", type: "parent", isParent: true });
2248
- }
2249
- entries
2250
- .filter((entry) => entry.isDirectory())
2251
- .sort((a, b) => a.name.localeCompare(b.name))
2252
- .slice(0, 100)
2253
- .forEach((entry) => {
2254
- items.push({ path: path.join(targetPath, entry.name), name: entry.name, type: "dir" });
2255
- });
2256
- res.json({ currentPath: targetPath, items });
2257
- }
2258
- catch (error) {
2259
- const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
2260
- if (code === "ENOENT") {
2261
- res.status(404).json({ error: "路径不存在:" + q, currentPath: q, items: [] });
2262
- }
2263
- else if (code === "EACCES") {
2264
- res.status(403).json({ error: "权限不足,无法访问:" + q, currentPath: q, items: [] });
2265
- }
2266
- else {
2267
- res.status(400).json({ error: "无法读取目录:" + getErrorMessage(error, "未知错误"), currentPath: q, items: [] });
2268
- }
2269
- }
2270
- });
2271
- app.get("/api/quick-paths", async (_req, res) => {
2272
- const home = process.env.HOME || process.env.USERPROFILE || "/home";
2273
- res.json([
2274
- { path: "/tmp", name: "临时目录", icon: "🗑️" },
2275
- { path: home, name: "主目录", icon: "🏠" },
2276
- { path: process.cwd(), name: "当前目录", icon: "📂" },
2277
- { path: "/", name: "根目录", icon: "📁" },
2278
- ]);
2279
- });
2280
- app.get("/api/recent-paths", (_req, res) => {
2281
- const stored = storage.getConfigValue("recent_paths");
2282
- const recent = parseStoredPathList(stored);
2283
- res.json(recent.filter((item) => !isBlockedFolderPath(normalizeFolderPath(item.path))));
2284
- });
2285
- app.post("/api/recent-paths", (req, res) => {
2286
- const { path: usedPath } = req.body;
2287
- if (!usedPath) {
2288
- res.status(400).json({ error: "路径不能为空。" });
2289
- return;
2290
- }
2291
- const resolvedRecentPath = normalizeFolderPath(usedPath);
2292
- if (isBlockedFolderPath(resolvedRecentPath)) {
2293
- res.status(403).json({ error: "访问被拒绝:无法保存系统敏感目录。" });
2294
- return;
2295
- }
2296
- recordRecentPath(storage, resolvedRecentPath);
2297
- res.json({
2298
- path: resolvedRecentPath,
2299
- name: path.basename(resolvedRecentPath),
2300
- lastUsedAt: new Date().toISOString(),
2301
- });
2302
- });
2303
- app.get("/api/validate-path", async (req, res) => {
2304
- const inputPath = typeof req.query.path === "string" ? req.query.path : "";
2305
- if (!inputPath.trim()) {
2306
- res.json({ valid: false, error: "路径不能为空" });
2307
- return;
2308
- }
2309
- try {
2310
- const resolvedPath = normalizeFolderPath(inputPath);
2311
- if (isBlockedFolderPath(resolvedPath)) {
2312
- res.json({ valid: false, error: "访问被拒绝:无法访问系统敏感目录。", resolvedPath });
2313
- return;
2314
- }
2315
- const stats = await import("node:fs/promises").then((fs) => fs.stat(resolvedPath));
2316
- if (!stats.isDirectory()) {
2317
- res.json({ valid: false, error: "路径不是目录", resolvedPath });
2318
- return;
2319
- }
2320
- try {
2321
- await readdir(resolvedPath);
2322
- res.json({ valid: true, resolvedPath, name: path.basename(resolvedPath) });
2323
- }
2324
- catch {
2325
- res.json({ valid: false, error: "没有读取权限", resolvedPath });
2326
- }
2327
- }
2328
- catch (error) {
2329
- const err = error;
2330
- if (err.code === "ENOENT") {
2331
- res.json({ valid: false, error: "路径不存在" });
2332
- }
2333
- else if (err.code === "EACCES") {
2334
- res.json({ valid: false, error: "没有访问权限" });
2335
- }
2336
- else {
2337
- res.json({ valid: false, error: `无效路径: ${err.message}` });
2338
- }
2339
- }
2340
- });
2341
- app.get("/api/file-search", async (req, res) => {
2342
- const query = typeof req.query.q === "string" ? req.query.q : "";
2343
- const cwd = typeof req.query.cwd === "string" ? req.query.cwd : process.cwd();
2344
- const maxDepth = typeof req.query.depth === "string" ? parseInt(req.query.depth, 10) : 5;
2345
- const maxResults = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
2346
- const allowedBase = process.cwd();
2347
- const resolvedCwd = path.resolve(allowedBase, cwd);
2348
- if (!isPathWithinBase(resolvedCwd, allowedBase)) {
2349
- res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
2350
- return;
2351
- }
2352
- if (!query) {
2353
- res.json({ results: [], query: "", cwd: resolvedCwd });
2354
- return;
2355
- }
2356
- try {
2357
- const results = [];
2358
- const queryLower = query.toLowerCase();
2359
- async function searchDir(dirPath, currentDepth) {
2360
- if (currentDepth > maxDepth || results.length >= maxResults)
2361
- return;
2362
- const entries = await readdir(dirPath, { withFileTypes: true });
2363
- for (const entry of entries) {
2364
- if (results.length >= maxResults)
2365
- break;
2366
- const entryPath = path.join(dirPath, entry.name);
2367
- const nameLower = entry.name.toLowerCase();
2368
- const matchIndex = nameLower.indexOf(queryLower);
2369
- if (matchIndex !== -1) {
2370
- results.push({
2371
- path: entryPath,
2372
- name: entry.name,
2373
- type: entry.isDirectory() ? "dir" : "file",
2374
- matchScore: matchIndex,
2375
- });
2376
- }
2377
- if (entry.isDirectory()) {
2378
- await searchDir(entryPath, currentDepth + 1);
2379
- }
2380
- }
2381
- }
2382
- await searchDir(resolvedCwd, 0);
2383
- results.sort((a, b) => {
2384
- if (a.matchScore !== b.matchScore)
2385
- return a.matchScore - b.matchScore;
2386
- return a.name.localeCompare(b.name);
2387
- });
2388
- res.json({ results: results.slice(0, maxResults), query, cwd: resolvedCwd });
2389
- }
2390
- catch (error) {
2391
- res.status(400).json({ error: getErrorMessage(error, "搜索失败。可能原因:路径不存在或权限不足。") });
2392
- }
2393
- });
1346
+ }));
1347
+ registerFileRoutes(app, { storage, defaultCwd: config.defaultCwd });
2394
1348
  // ── Session control ──
2395
1349
  app.post("/api/commands", (req, res) => {
2396
1350
  const body = req.body;
@@ -2398,6 +1352,10 @@ export async function startServer(config, configPath) {
2398
1352
  res.status(400).json({ error: "请输入要执行的命令。" });
2399
1353
  return;
2400
1354
  }
1355
+ if (body.mode !== undefined && !isExecutionMode(body.mode)) {
1356
+ res.status(400).json({ error: `无效执行模式: ${String(body.mode)}` });
1357
+ return;
1358
+ }
2401
1359
  const initialInput = body.initialInput?.trim();
2402
1360
  try {
2403
1361
  const origin = parseSessionCreationOrigin(body);
@@ -2410,7 +1368,7 @@ export async function startServer(config, configPath) {
2410
1368
  const effectiveModel = rawModel || getDefaultModelForProvider(config, provider) || undefined;
2411
1369
  const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
2412
1370
  const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
2413
- const snapshot = processes.start(body.command, body.cwd, normalizeMode(body.mode, config.defaultMode), initialInput || undefined, {
1371
+ const snapshot = processes.start(body.command, body.cwd, body.mode ?? config.defaultMode, initialInput || undefined, {
2414
1372
  worktreeEnabled: body.worktreeEnabled === true,
2415
1373
  provider,
2416
1374
  model: effectiveModel,
@@ -2464,14 +1422,19 @@ export async function startServer(config, configPath) {
2464
1422
  const wss = new WebSocketServer({
2465
1423
  server,
2466
1424
  path: "/ws",
1425
+ // Incoming frames are control messages (subscribe/resync/pong), never
1426
+ // transcripts. Bound them so a single client cannot allocate arbitrarily
1427
+ // large buffers before JSON parsing.
1428
+ maxPayload: 256 * 1024,
2467
1429
  perMessageDeflate: {
2468
1430
  zlibDeflateOptions: { level: 1 },
2469
1431
  threshold: 512,
2470
1432
  concurrencyLimit: 10,
2471
1433
  },
2472
1434
  });
2473
- const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {}, useHttps);
2474
- wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
1435
+ const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {}, useHttps, authService);
1436
+ wsManager.setup((id) => sessionRegistry.get(id));
1437
+ disconnectAuthenticatedSockets = () => wsManager.disconnectAll();
2475
1438
  wss.on("error", (err) => {
2476
1439
  if (err.code === "EADDRINUSE")
2477
1440
  return;
@@ -2504,13 +1467,7 @@ export async function startServer(config, configPath) {
2504
1467
  serviceInstalled: safeServiceInstalled(),
2505
1468
  globalCli: resolveGlobalWandCli(),
2506
1469
  });
2507
- try {
2508
- wss.clients.forEach((client) => client.close());
2509
- }
2510
- catch {
2511
- /* noop */
2512
- }
2513
- server.close(() => {
1470
+ void close().finally(() => {
2514
1471
  if (plan.mode === "spawn") {
2515
1472
  spawn(process.execPath, [plan.bin ?? "", ...(plan.args ?? [])], {
2516
1473
  detached: true,
@@ -2522,23 +1479,42 @@ export async function startServer(config, configPath) {
2522
1479
  process.exit(0);
2523
1480
  });
2524
1481
  // Force exit after 5s if graceful shutdown stalls
2525
- setTimeout(() => process.exit(0), 5000);
1482
+ const forceExitTimer = setTimeout(() => process.exit(0), 5000);
1483
+ forceExitTimer.unref?.();
2526
1484
  }
2527
- app.post("/api/restart", async (_req, res) => {
1485
+ app.post("/api/restart", requireAdmin, asyncRoute(async (_req, res) => {
2528
1486
  res.json({ ok: true, message: "服务正在重启..." });
2529
1487
  wsManager.emitEvent({
2530
1488
  type: "notification",
2531
1489
  sessionId: "__system__",
2532
1490
  data: { kind: "restart" },
2533
1491
  });
2534
- setTimeout(() => {
1492
+ const restartTimer = setTimeout(() => {
2535
1493
  relaunchAfterShutdown();
2536
1494
  }, 600);
2537
- });
1495
+ restartTimer.unref?.();
1496
+ }));
2538
1497
  let bindAddr = config.host === "0.0.0.0" ? "0.0.0.0" : config.host;
2539
1498
  const collectedUrls = [];
2540
1499
  await new Promise((resolve, reject) => {
2541
1500
  const cleanupFailedListen = () => {
1501
+ shuttingDown = true;
1502
+ try {
1503
+ processes.dispose();
1504
+ }
1505
+ catch { /* noop */ }
1506
+ try {
1507
+ structuredSessions.dispose();
1508
+ }
1509
+ catch { /* noop */ }
1510
+ try {
1511
+ structuredLogger.dispose();
1512
+ }
1513
+ catch { /* noop */ }
1514
+ try {
1515
+ wsManager.dispose();
1516
+ }
1517
+ catch { /* noop */ }
2542
1518
  try {
2543
1519
  wss.close();
2544
1520
  }
@@ -2547,6 +1523,7 @@ export async function startServer(config, configPath) {
2547
1523
  server.close();
2548
1524
  }
2549
1525
  catch { /* noop */ }
1526
+ authService.dispose();
2550
1527
  try {
2551
1528
  storage.close();
2552
1529
  }
@@ -2590,17 +1567,17 @@ export async function startServer(config, configPath) {
2590
1567
  }
2591
1568
  // Pre-warm model cache (probes claude --version + codex debug models).
2592
1569
  if (!testMode) {
2593
- refreshModels().catch(() => { });
1570
+ refreshModels(getModelRefreshOptions()).catch(() => { });
2594
1571
  }
2595
1572
  // ── Auto-update endpoints ──
2596
- app.get("/api/auto-update", (_req, res) => {
1573
+ app.get("/api/auto-update", requireAdmin, (_req, res) => {
2597
1574
  const web = storage.getConfigValue("autoUpdateWeb") === "true";
2598
1575
  const apk = storage.getConfigValue("autoUpdateApk") === "true";
2599
1576
  const dmg = storage.getConfigValue("autoUpdateDmg") === "true";
2600
1577
  const cli = storage.getConfigValue("autoUpdateProviderClis") === "true";
2601
1578
  res.json({ web, apk, dmg, cli });
2602
1579
  });
2603
- app.post("/api/auto-update", (req, res) => {
1580
+ app.post("/api/auto-update", requireAdmin, (req, res) => {
2604
1581
  const { web, apk, dmg, cli } = req.body;
2605
1582
  if (typeof web === "boolean") {
2606
1583
  storage.setConfigValue("autoUpdateWeb", String(web));
@@ -2622,7 +1599,7 @@ export async function startServer(config, configPath) {
2622
1599
  });
2623
1600
  });
2624
1601
  // ── Update channel (stable / beta) ──
2625
- app.get("/api/update-channel", (_req, res) => {
1602
+ app.get("/api/update-channel", requireAdmin, (_req, res) => {
2626
1603
  res.json({
2627
1604
  channel: getUpdateChannel(),
2628
1605
  build: {
@@ -2633,16 +1610,24 @@ export async function startServer(config, configPath) {
2633
1610
  },
2634
1611
  });
2635
1612
  });
2636
- app.post("/api/update-channel", (req, res) => {
1613
+ app.post("/api/update-channel", requireAdmin, (req, res) => {
2637
1614
  const body = (req.body ?? {});
2638
1615
  const channel = body.channel === "beta" ? "beta" : "stable";
2639
1616
  storage.setConfigValue("updateChannel", channel);
2640
1617
  res.json({ channel });
2641
1618
  });
1619
+ // Express 4 does not forward rejected route promises automatically. Every
1620
+ // async route above is wrapped with asyncRoute, and this final middleware
1621
+ // keeps parser, synchronous middleware, and async failures JSON-shaped.
1622
+ app.use(jsonErrorHandler);
2642
1623
  // ── Auto-update logic ──
2643
1624
  async function performAutoUpdate() {
1625
+ if (shuttingDown)
1626
+ return;
2644
1627
  const channel = getUpdateChannel();
2645
1628
  const info = await checkLatestPackageVersion(channel, true);
1629
+ if (shuttingDown)
1630
+ return;
2646
1631
  cachedUpdateInfo = info;
2647
1632
  if (!info.latest || !info.updateAvailable)
2648
1633
  return;
@@ -2686,6 +1671,8 @@ export async function startServer(config, configPath) {
2686
1671
  });
2687
1672
  try {
2688
1673
  await npmInstallGlobal(info.installSpec, 120000);
1674
+ if (shuttingDown)
1675
+ return;
2689
1676
  // 镜像 install.sh:装完用全局安装刷新服务 unit(ExecStart/PATH),重启才会跑到新版。
2690
1677
  const repair = repairServiceUnitAfterUpdate(configPath);
2691
1678
  if (repair.scope)
@@ -2702,9 +1689,10 @@ export async function startServer(config, configPath) {
2702
1689
  },
2703
1690
  });
2704
1691
  // Restart after a brief delay
2705
- setTimeout(() => {
1692
+ const restartTimer = setTimeout(() => {
2706
1693
  relaunchAfterShutdown();
2707
1694
  }, 1000);
1695
+ restartTimer.unref?.();
2708
1696
  }
2709
1697
  catch (error) {
2710
1698
  const msg = getErrorMessage(error, "未知错误");
@@ -2718,11 +1706,13 @@ export async function startServer(config, configPath) {
2718
1706
  }
2719
1707
  }
2720
1708
  async function performProviderCliAutoUpdate() {
2721
- if (storage.getConfigValue("autoUpdateProviderClis") !== "true" || providerCliUpdateInFlight || updateInFlight)
1709
+ if (shuttingDown || storage.getConfigValue("autoUpdateProviderClis") !== "true" || updateState.providerCliUpdateInFlight || updateState.updateInFlight)
2722
1710
  return;
2723
- providerCliUpdateInFlight = true;
1711
+ updateState.providerCliUpdateInFlight = true;
2724
1712
  try {
2725
1713
  const before = await refreshProviderCliUpdates();
1714
+ if (shuttingDown)
1715
+ return;
2726
1716
  const available = before.items.filter((item) => item.updateAvailable && item.updateSupported);
2727
1717
  if (!available.length)
2728
1718
  return;
@@ -2730,18 +1720,20 @@ export async function startServer(config, configPath) {
2730
1720
  inheritEnv: config.inheritEnv !== false,
2731
1721
  onLog: (line) => process.stdout.write(`[wand] ${line}\n`),
2732
1722
  });
1723
+ if (shuttingDown)
1724
+ return;
2733
1725
  const after = await refreshProviderCliUpdates();
2734
1726
  const results = verifyProviderCliUpdateResults(commandResults, after.items);
2735
1727
  for (const result of results) {
2736
1728
  process.stdout.write(`[wand] CLI 自动更新 ${result.ok ? "完成" : "失败"}: ${result.message}\n`);
2737
1729
  }
2738
- refreshModels().catch(() => { });
1730
+ refreshModels(getModelRefreshOptions()).catch(() => { });
2739
1731
  }
2740
1732
  catch (error) {
2741
1733
  process.stdout.write(`[wand] CLI 自动更新失败: ${getErrorMessage(error)}\n`);
2742
1734
  }
2743
1735
  finally {
2744
- providerCliUpdateInFlight = false;
1736
+ updateState.providerCliUpdateInFlight = false;
2745
1737
  }
2746
1738
  }
2747
1739
  let updateCheckTimer = null;
@@ -2764,42 +1756,87 @@ export async function startServer(config, configPath) {
2764
1756
  }, 2 * 60 * 1000);
2765
1757
  providerCliUpdateTimer.unref();
2766
1758
  }
2767
- const close = () => new Promise((resolve) => {
2768
- let done = false;
2769
- const finish = () => {
2770
- if (done)
2771
- return;
2772
- done = true;
1759
+ let closePromise = null;
1760
+ const close = () => {
1761
+ if (closePromise)
1762
+ return closePromise;
1763
+ closePromise = (async () => {
1764
+ shuttingDown = true;
1765
+ if (updateCheckTimer) {
1766
+ clearInterval(updateCheckTimer);
1767
+ updateCheckTimer = null;
1768
+ }
1769
+ if (providerCliUpdateTimer) {
1770
+ clearTimeout(providerCliUpdateTimer);
1771
+ providerCliUpdateTimer = null;
1772
+ }
1773
+ // Stop accepting requests first. Existing requests get a short grace
1774
+ // period while managers flush and active runners are cancelled.
1775
+ const serverClosed = new Promise((resolve) => {
1776
+ let settled = false;
1777
+ let fallbackTimer = null;
1778
+ const finish = () => {
1779
+ if (settled)
1780
+ return;
1781
+ settled = true;
1782
+ if (fallbackTimer)
1783
+ clearTimeout(fallbackTimer);
1784
+ resolve();
1785
+ };
1786
+ fallbackTimer = setTimeout(() => {
1787
+ try {
1788
+ server.closeAllConnections?.();
1789
+ }
1790
+ catch { /* ignore */ }
1791
+ finish();
1792
+ }, 3000);
1793
+ fallbackTimer.unref?.();
1794
+ try {
1795
+ server.close(() => finish());
1796
+ }
1797
+ catch {
1798
+ finish();
1799
+ }
1800
+ });
2773
1801
  try {
2774
- storage.close();
1802
+ processes.dispose();
1803
+ }
1804
+ catch { /* best-effort shutdown */ }
1805
+ try {
1806
+ structuredSessions.dispose();
1807
+ }
1808
+ catch { /* best-effort shutdown */ }
1809
+ try {
1810
+ structuredLogger.dispose();
1811
+ }
1812
+ catch { /* best-effort shutdown */ }
1813
+ try {
1814
+ wsManager.dispose();
1815
+ }
1816
+ catch { /* best-effort shutdown */ }
1817
+ try {
1818
+ wss.close();
2775
1819
  }
2776
1820
  catch { /* ignore */ }
2777
- resolve();
2778
- };
2779
- try {
2780
- wss.clients.forEach((c) => c.close());
2781
- }
2782
- catch { /* ignore */ }
2783
- try {
2784
- wss.close();
2785
- }
2786
- catch { /* ignore */ }
2787
- if (updateCheckTimer)
2788
- clearInterval(updateCheckTimer);
2789
- if (providerCliUpdateTimer)
2790
- clearInterval(providerCliUpdateTimer);
2791
- try {
2792
- server.close(() => finish());
2793
- }
2794
- catch {
2795
- finish();
2796
- return;
2797
- }
2798
- setTimeout(finish, 3000); // 兜底:3s 内未关完强制 resolve
2799
- });
1821
+ try {
1822
+ await serverClosed;
1823
+ }
1824
+ finally {
1825
+ // Auth cleanup must precede DatabaseSync.close() so its cleanup timer
1826
+ // can never retain or call a closed storage instance.
1827
+ authService.dispose();
1828
+ try {
1829
+ storage.close();
1830
+ }
1831
+ catch { /* ignore */ }
1832
+ }
1833
+ })();
1834
+ return closePromise;
1835
+ };
2800
1836
  return {
2801
1837
  processManager: processes,
2802
1838
  structuredSessions,
1839
+ authService,
2803
1840
  configPath,
2804
1841
  dbPath: resolveDatabasePath(configPath),
2805
1842
  urls: collectedUrls,