@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.
- package/dist/auth.d.ts +19 -5
- package/dist/auth.js +83 -45
- package/dist/build-info.json +3 -3
- package/dist/cert.d.ts +1 -1
- package/dist/cert.js +124 -74
- package/dist/config.js +25 -8
- package/dist/express-async.d.ts +6 -0
- package/dist/express-async.js +28 -0
- package/dist/git-quick-commit.d.ts +2 -0
- package/dist/git-quick-commit.js +215 -76
- package/dist/git-utils.d.ts +4 -0
- package/dist/git-utils.js +60 -11
- package/dist/git-worktree.d.ts +8 -1
- package/dist/git-worktree.js +406 -41
- package/dist/models.d.ts +34 -4
- package/dist/models.js +334 -48
- package/dist/process-manager.d.ts +22 -30
- package/dist/process-manager.js +374 -441
- package/dist/provider-history-scanner.d.ts +54 -0
- package/dist/provider-history-scanner.js +354 -0
- package/dist/request-limits.d.ts +1 -0
- package/dist/request-limits.js +8 -0
- package/dist/resume-policy.d.ts +2 -0
- package/dist/resume-policy.js +5 -0
- package/dist/runtime-config.d.ts +16 -0
- package/dist/runtime-config.js +49 -0
- package/dist/server-file-routes.d.ts +17 -0
- package/dist/server-file-routes.js +653 -0
- package/dist/server-session-routes.d.ts +16 -3
- package/dist/server-session-routes.js +170 -149
- package/dist/server-settings-routes.d.ts +43 -0
- package/dist/server-settings-routes.js +225 -0
- package/dist/server-update-routes.d.ts +61 -0
- package/dist/server-update-routes.js +215 -0
- package/dist/server.d.ts +6 -4
- package/dist/server.js +350 -1313
- package/dist/session-logger.d.ts +32 -2
- package/dist/session-logger.js +145 -15
- package/dist/session-registry.d.ts +27 -0
- package/dist/session-registry.js +153 -0
- package/dist/session-transport.d.ts +31 -0
- package/dist/session-transport.js +82 -0
- package/dist/storage.d.ts +24 -6
- package/dist/storage.js +291 -44
- package/dist/structured-claude-adapter.d.ts +19 -0
- package/dist/structured-claude-adapter.js +117 -0
- package/dist/structured-codex-adapter.d.ts +3 -0
- package/dist/structured-codex-adapter.js +29 -0
- package/dist/structured-opencode-adapter.d.ts +11 -0
- package/dist/structured-opencode-adapter.js +115 -0
- package/dist/structured-provider-common.d.ts +11 -0
- package/dist/structured-provider-common.js +77 -0
- package/dist/structured-session-manager.d.ts +32 -35
- package/dist/structured-session-manager.js +551 -605
- package/dist/types.d.ts +10 -0
- package/dist/update-helper.js +5 -1
- package/dist/web-ui/content/scripts.js +32 -32
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.d.ts +16 -1
- package/dist/ws-broadcast.js +124 -58
- 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 {
|
|
6
|
-
import {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
18
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
365
|
+
const requestPrincipals = new WeakMap();
|
|
366
|
+
function buildRequireAuth(useHttps, storage, config, authService) {
|
|
436
367
|
return function requireAuth(req, res, next) {
|
|
437
|
-
|
|
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
|
|
427
|
+
function authenticateBearerAppToken(req, storage, config) {
|
|
448
428
|
const header = firstHeaderValue(req.headers.authorization);
|
|
449
429
|
if (!header?.startsWith("Bearer "))
|
|
450
|
-
return
|
|
430
|
+
return null;
|
|
451
431
|
const token = header.slice("Bearer ".length).trim();
|
|
452
432
|
if (!token)
|
|
453
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
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
|
|
1193
|
-
|
|
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
|
-
|
|
974
|
+
if (verifyAppToken(appToken, effectivePassword, config.appSecret ?? "")) {
|
|
975
|
+
principal = { ...CONNECTED_APP_PRINCIPAL, scopes: [...CONNECTED_APP_PRINCIPAL.scopes] };
|
|
976
|
+
}
|
|
1197
977
|
}
|
|
1198
978
|
catch {
|
|
1199
|
-
|
|
979
|
+
principal = null;
|
|
1200
980
|
}
|
|
1201
981
|
}
|
|
1202
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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,
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
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,
|