@co0ontty/wand 1.31.3 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.d.ts CHANGED
@@ -1,4 +1,24 @@
1
1
  import { WandStorage } from "./storage.js";
2
+ /**
3
+ * Cookie 名按 scheme 隔离 —— 解决浏览器 Strict Secure Cookies 在 HTTPS↔HTTP 切换时
4
+ * "新 Set-Cookie 被同名 Secure 旧 cookie 静默丢弃"导致登录后立刻 401 的问题。
5
+ *
6
+ * - HTTPS 模式:`__Host-wand_session`(强制 Secure + Path=/,安全性更高)
7
+ * - HTTP 模式:`wand_session_local`(独立名字,不会被 HTTPS 留下的 Secure cookie 拦截)
8
+ * - `wand_session`:legacy 名字。HTTPS 模式下仍写一份用于兼容老 macOS APP(写死了找
9
+ * `wand_session`),同时让升级前的老登录态在过渡期不被踢。
10
+ *
11
+ * 读取顺序按"当前 scheme 主名字 → legacy"逐项 fallback。
12
+ */
13
+ export declare const SESSION_COOKIE_HTTPS = "__Host-wand_session";
14
+ export declare const SESSION_COOKIE_HTTP = "wand_session_local";
15
+ export declare const SESSION_COOKIE_LEGACY = "wand_session";
16
+ /** 解析 Cookie 头,按候选名字顺序返回第一个匹配到的 token 值。 */
17
+ export declare function readSessionCookie(req: {
18
+ headers: {
19
+ cookie?: string;
20
+ };
21
+ }, useHttps: boolean): string | undefined;
2
22
  export declare function createSession(): string;
3
23
  export declare function validateSession(token: string | undefined): boolean;
4
24
  export declare function revokeSession(token: string | undefined): void;
package/dist/auth.js CHANGED
@@ -2,6 +2,37 @@ import crypto from "node:crypto";
2
2
  const sessions = new Map();
3
3
  const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
4
4
  let storage = null;
5
+ /**
6
+ * Cookie 名按 scheme 隔离 —— 解决浏览器 Strict Secure Cookies 在 HTTPS↔HTTP 切换时
7
+ * "新 Set-Cookie 被同名 Secure 旧 cookie 静默丢弃"导致登录后立刻 401 的问题。
8
+ *
9
+ * - HTTPS 模式:`__Host-wand_session`(强制 Secure + Path=/,安全性更高)
10
+ * - HTTP 模式:`wand_session_local`(独立名字,不会被 HTTPS 留下的 Secure cookie 拦截)
11
+ * - `wand_session`:legacy 名字。HTTPS 模式下仍写一份用于兼容老 macOS APP(写死了找
12
+ * `wand_session`),同时让升级前的老登录态在过渡期不被踢。
13
+ *
14
+ * 读取顺序按"当前 scheme 主名字 → legacy"逐项 fallback。
15
+ */
16
+ export const SESSION_COOKIE_HTTPS = "__Host-wand_session";
17
+ export const SESSION_COOKIE_HTTP = "wand_session_local";
18
+ export const SESSION_COOKIE_LEGACY = "wand_session";
19
+ /** 解析 Cookie 头,按候选名字顺序返回第一个匹配到的 token 值。 */
20
+ export function readSessionCookie(req, useHttps) {
21
+ const cookie = req.headers.cookie;
22
+ if (!cookie)
23
+ return undefined;
24
+ const parts = cookie.split(";").map((part) => part.trim());
25
+ const order = useHttps
26
+ ? [SESSION_COOKIE_HTTPS, SESSION_COOKIE_LEGACY, SESSION_COOKIE_HTTP]
27
+ : [SESSION_COOKIE_HTTP, SESSION_COOKIE_LEGACY, SESSION_COOKIE_HTTPS];
28
+ for (const name of order) {
29
+ const prefix = `${name}=`;
30
+ const hit = parts.find((part) => part.startsWith(prefix));
31
+ if (hit)
32
+ return hit.slice(prefix.length);
33
+ }
34
+ return undefined;
35
+ }
5
36
  // Periodic cleanup every 10 minutes
6
37
  const sessionCleanupTimer = setInterval(() => {
7
38
  cleanupExpiredSessions();
package/dist/cert.d.ts CHANGED
@@ -1,8 +1,23 @@
1
1
  export interface SSLConfig {
2
2
  key: Buffer;
3
3
  cert: Buffer;
4
+ /** 实际生效的证书路径,用于 `/cert/server.crt` 下载路由。 */
5
+ certPath: string;
6
+ /** SHA-256 指纹(大写 + 冒号分隔),方便用户在浏览器里核对。 */
7
+ fingerprint: string;
8
+ /** 是否走的是用户自备证书。true = 自带,false = wand 自签。 */
9
+ userProvided: boolean;
10
+ }
11
+ export interface EnsureCertificatesOptions {
12
+ /** 用户自带证书路径(PEM)。配了且存在就直接用。 */
13
+ userCertPath?: string;
14
+ userKeyPath?: string;
4
15
  }
5
16
  /**
6
- * Ensure certificates exist, generate if not
17
+ * 主入口:装载 TLS 证书。优先级(高 低):
18
+ * 1. options.userCertPath / userKeyPath(config.tls)
19
+ * 2. 配置目录下已存在的 server.crt + server.key
20
+ * 3. 用 openssl 现场生成自签
21
+ * 4. node crypto 兜底(产出非法证书,主要避免崩溃)
7
22
  */
8
- export declare function ensureCertificates(configDir: string): SSLConfig;
23
+ export declare function ensureCertificates(configDir: string, options?: EnsureCertificatesOptions): SSLConfig;
package/dist/cert.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { execSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import os from "node:os";
3
5
  import path from "node:path";
4
6
  function getCertificatePaths(configDir) {
5
7
  return {
@@ -10,38 +12,85 @@ function getCertificatePaths(configDir) {
10
12
  function certificatesExist(paths) {
11
13
  return existsSync(paths.keyPath) && existsSync(paths.certPath);
12
14
  }
13
- function loadCertificates(paths) {
15
+ function readPair(keyPath, certPath) {
14
16
  try {
15
- if (!certificatesExist(paths)) {
17
+ if (!existsSync(keyPath) || !existsSync(certPath))
16
18
  return null;
17
- }
18
- return {
19
- key: readFileSync(paths.keyPath),
20
- cert: readFileSync(paths.certPath)
21
- };
19
+ return { key: readFileSync(keyPath), cert: readFileSync(certPath) };
22
20
  }
23
21
  catch {
24
22
  return null;
25
23
  }
26
24
  }
27
25
  /**
28
- * Generate self-signed certificate using openssl
26
+ * 收集本机所有可外部访问的 IPv4 地址 + hostname,作为自签证书的 SAN。
27
+ * 之前 SAN 只有 `localhost,127.0.0.1`,从手机/局域网用 `https://192.168.x.x` 访问
28
+ * 时浏览器会直接拒绝(NET::ERR_CERT_COMMON_NAME_INVALID),连页面都打不开。
29
+ */
30
+ function collectSanEntries() {
31
+ const dns = new Set(["localhost"]);
32
+ const ip = new Set(["127.0.0.1", "::1"]);
33
+ const hostname = os.hostname();
34
+ if (hostname && hostname !== "localhost") {
35
+ dns.add(hostname);
36
+ // 部分 mDNS 环境下 hostname.local 也能解析
37
+ if (!hostname.endsWith(".local"))
38
+ dns.add(`${hostname}.local`);
39
+ }
40
+ const ifaces = os.networkInterfaces();
41
+ for (const list of Object.values(ifaces)) {
42
+ if (!list)
43
+ continue;
44
+ for (const entry of list) {
45
+ if (entry.internal)
46
+ continue;
47
+ // Node 18+ entry.family 是 "IPv4" | "IPv6",旧版本是 4 | 6
48
+ const fam = entry.family;
49
+ if (fam === "IPv4" || fam === 4 || fam === "IPv6" || fam === 6) {
50
+ ip.add(entry.address.split("%")[0]); // 去掉 zone-id
51
+ }
52
+ }
53
+ }
54
+ return { dns: [...dns], ip: [...ip] };
55
+ }
56
+ function buildSanArg() {
57
+ const { dns, ip } = collectSanEntries();
58
+ const parts = [];
59
+ for (const d of dns)
60
+ parts.push(`DNS:${d}`);
61
+ for (const i of ip)
62
+ parts.push(`IP:${i}`);
63
+ return parts.join(",");
64
+ }
65
+ function computeFingerprint(certPem) {
66
+ const text = certPem.toString("utf8");
67
+ const match = text.match(/-----BEGIN CERTIFICATE-----([\s\S]+?)-----END CERTIFICATE-----/);
68
+ if (!match)
69
+ return "(unavailable)";
70
+ const der = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
71
+ const hex = createHash("sha256").update(der).digest("hex").toUpperCase();
72
+ return hex.match(/.{2}/g).join(":");
73
+ }
74
+ /**
75
+ * 生成 self-signed 证书(OpenSSL 路径)。SAN 覆盖本机 hostname / LAN IP,
76
+ * 这样从手机/局域网设备访问 `https://<LAN IP>:port/` 时不会撞 SAN 不匹配。
29
77
  */
30
78
  function generateWithOpenSSL(paths) {
31
79
  try {
32
- // Check if openssl is available
33
80
  execSync("openssl version", { stdio: "pipe" });
34
81
  const dir = path.dirname(paths.keyPath);
35
- if (!existsSync(dir)) {
82
+ if (!existsSync(dir))
36
83
  mkdirSync(dir, { recursive: true });
37
- }
38
- // Generate private key
39
84
  execSync(`openssl genrsa -out "${paths.keyPath}" 2048`, { stdio: "pipe" });
40
- // Generate self-signed certificate (valid for 365 days)
41
- execSync(`openssl req -new -x509 -key "${paths.keyPath}" -out "${paths.certPath}" -days 365 -subj "/CN=localhost/O=Wand Local Development" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"`, { stdio: "pipe" });
85
+ const san = buildSanArg();
86
+ execSync(`openssl req -new -x509 -key "${paths.keyPath}" -out "${paths.certPath}" -days 825 ` +
87
+ `-subj "/CN=wand-local/O=Wand Local Development" ` +
88
+ `-addext "subjectAltName=${san}" ` +
89
+ `-addext "extendedKeyUsage=serverAuth" ` +
90
+ `-addext "basicConstraints=critical,CA:FALSE"`, { stdio: "pipe" });
42
91
  return {
43
92
  key: readFileSync(paths.keyPath),
44
- cert: readFileSync(paths.certPath)
93
+ cert: readFileSync(paths.certPath),
45
94
  };
46
95
  }
47
96
  catch {
@@ -49,76 +98,83 @@ function generateWithOpenSSL(paths) {
49
98
  }
50
99
  }
51
100
  /**
52
- * Generate a simple self-signed certificate without openssl
53
- * Uses Node.js crypto to create RSA key and a basic certificate
101
+ * 没有 openssl 时的兜底:用 node 内置 crypto 生成 RSA 密钥,证书部分写一个
102
+ * 明显的 placeholder PEM。这条路径产出的证书**无法被浏览器接受**,主要是为了
103
+ * 不让 HTTPS 监听直接崩;启动日志会强烈建议用户装 openssl 或自备证书。
54
104
  */
55
105
  function generateWithoutOpenSSL(paths) {
56
106
  const dir = path.dirname(paths.keyPath);
57
- if (!existsSync(dir)) {
107
+ if (!existsSync(dir))
58
108
  mkdirSync(dir, { recursive: true });
59
- }
60
- // Use Node.js built-in crypto to generate key
61
- // For certificate, we'll create a minimal PEM structure
62
- // This is a simplified approach - for production, use proper tools
63
109
  const { generateKeyPairSync } = require("node:crypto");
64
- const { privateKey, publicKey } = generateKeyPairSync("rsa", {
110
+ const { privateKey } = generateKeyPairSync("rsa", {
65
111
  modulusLength: 2048,
66
112
  publicKeyEncoding: { type: "spki", format: "pem" },
67
- privateKeyEncoding: { type: "pkcs8", format: "pem" }
113
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
68
114
  });
69
- // Create a minimal certificate (browsers will warn, but it works)
70
- const cert = createMinimalCert(privateKey);
115
+ const cert = "-----BEGIN CERTIFICATE-----\n" +
116
+ "MIIBkTCB+wIJAKHBfPOPlvfoMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnNh\n" +
117
+ "bmRib3gwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjARMQ8wDQYDVQQD\n" +
118
+ "DAZzYW5kYm94MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALLgGbUPZxEvLPLXZQrz\n" +
119
+ "KxLhP5EoaUuB7V8FYA5JQZbRE6RkxEKkR8jFQHOcQYevGQYEbXvKZ0WxR2BqMJsC\n" +
120
+ "AwEAAaMgMB4wDQYJKoZIhvcNAQELBQADQQBpMq0NweMwF7fh0TiMwFCTzC/wK7fR\n" +
121
+ "e0WxR2BqMJsC\n" +
122
+ "-----END CERTIFICATE-----\n";
71
123
  writeFileSync(paths.keyPath, privateKey, { mode: 0o600 });
72
124
  writeFileSync(paths.certPath, cert, { mode: 0o644 });
73
- return {
74
- key: Buffer.from(privateKey),
75
- cert: Buffer.from(cert)
76
- };
77
- }
78
- /**
79
- * Create a minimal self-signed certificate
80
- * Note: This creates a very basic cert structure
81
- */
82
- function createMinimalCert(privateKeyPem) {
83
- // For a proper cert, we'd need node-forge or similar
84
- // Instead, create a placeholder that prompts the user
85
- const placeholder = `-----BEGIN CERTIFICATE-----
86
- MIIBkTCB+wIJAKHBfPOPlvfoMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnNh
87
- bmRib3gwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjARMQ8wDQYDVQQD
88
- DAZzYW5kYm94MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALLgGbUPZxEvLPLXZQrz
89
- KxLhP5EoaUuB7V8FYA5JQZbRE6RkxEKkR8jFQHOcQYevGQYEbXvKZ0WxR2BqMJsC
90
- AwEAAaMgMB4wDQYJKoZIhvcNAQELBQADQQBpMq0NweMwF7fh0TiMwFCTzC/wK7fR
91
- e0WxR2BqMJsC
92
- -----END CERTIFICATE-----`;
93
- process.stderr.write("\x1b[33m[wand] Warning: Generated basic certificate. For better compatibility,\n" +
94
- "[wand] install openssl or provide your own certificate files.\n" +
95
- "[wand] Certificate files should be at:\n" +
96
- "[wand] - server.key (private key)\n" +
97
- "[wand] - server.crt (certificate)\x1b[0m\n");
98
- return placeholder;
125
+ process.stderr.write("\x1b[33m[wand] 警告:未检测到 openssl,写出的是无效占位证书。\n" +
126
+ "[wand] 请安装 openssl,或在 config.json 里配 tls.certPath / tls.keyPath\n" +
127
+ "[wand] 指向自备证书(推荐 mkcert:mkcert -install && mkcert localhost <hostname> <LAN-IP>)。\x1b[0m\n");
128
+ return { key: Buffer.from(privateKey), cert: Buffer.from(cert) };
99
129
  }
100
130
  /**
101
- * Ensure certificates exist, generate if not
131
+ * 主入口:装载 TLS 证书。优先级(高 低):
132
+ * 1. options.userCertPath / userKeyPath(config.tls)
133
+ * 2. 配置目录下已存在的 server.crt + server.key
134
+ * 3. 用 openssl 现场生成自签
135
+ * 4. node crypto 兜底(产出非法证书,主要避免崩溃)
102
136
  */
103
- export function ensureCertificates(configDir) {
137
+ export function ensureCertificates(configDir, options = {}) {
138
+ // 1. 用户自备证书
139
+ if (options.userCertPath && options.userKeyPath) {
140
+ const pair = readPair(options.userKeyPath, options.userCertPath);
141
+ if (pair) {
142
+ const fingerprint = computeFingerprint(pair.cert);
143
+ process.stdout.write(`[wand] 使用 config.tls 指定的证书:${options.userCertPath}\n` +
144
+ `[wand] SHA-256 指纹: ${fingerprint}\n`);
145
+ return { ...pair, certPath: options.userCertPath, fingerprint, userProvided: true };
146
+ }
147
+ process.stderr.write(`\x1b[33m[wand] 警告:config.tls 指向的证书文件无法读取(cert=${options.userCertPath}, key=${options.userKeyPath}),回退到默认自签流程。\x1b[0m\n`);
148
+ }
104
149
  const paths = getCertificatePaths(configDir);
105
- // Try to load existing certificates
106
- const existing = loadCertificates(paths);
150
+ // 2. 已存在的自签
151
+ const existing = readPair(paths.keyPath, paths.certPath);
107
152
  if (existing) {
108
- return existing;
153
+ return {
154
+ ...existing,
155
+ certPath: paths.certPath,
156
+ fingerprint: computeFingerprint(existing.cert),
157
+ userProvided: false,
158
+ };
109
159
  }
110
- process.stdout.write("[wand] Generating self-signed HTTPS certificate...\n");
111
- // Try openssl first
160
+ process.stdout.write("[wand] 正在生成 self-signed HTTPS 证书…\n");
161
+ // 3. OpenSSL
112
162
  const ssl = generateWithOpenSSL(paths);
113
163
  if (ssl) {
114
- process.stdout.write(`[wand] Certificate saved to ${paths.certPath}\n`);
115
- process.stdout.write("[wand] Note: Browsers will show a security warning for self-signed certificates.\n");
116
- process.stdout.write("[wand] You can replace these files with your own certificates if needed.\n");
117
- return ssl;
164
+ const fingerprint = computeFingerprint(ssl.cert);
165
+ process.stdout.write(`[wand] 证书已写入 ${paths.certPath}\n` +
166
+ `[wand] SHA-256 指纹: ${fingerprint}\n` +
167
+ `[wand] 注意:自签证书浏览器会标红;PWA / Service Worker 需要把它导入受信任根证书才能工作。\n` +
168
+ `[wand] HTTPS 启用后可通过 GET /cert/server.crt 在客户端下载安装。\n`);
169
+ return { ...ssl, certPath: paths.certPath, fingerprint, userProvided: false };
118
170
  }
119
- // Fallback to basic generation
120
- process.stdout.write("[wand] OpenSSL not found, using basic certificate generation...\n");
121
- const basicSsl = generateWithoutOpenSSL(paths);
122
- process.stdout.write(`[wand] Certificate saved to ${paths.certPath}\n`);
123
- return basicSsl;
171
+ // 4. 兜底
172
+ process.stdout.write("[wand] 未检测到 openssl,使用 node crypto 兜底(产出占位证书,仅防崩)。\n");
173
+ const fallback = generateWithoutOpenSSL(paths);
174
+ return {
175
+ ...fallback,
176
+ certPath: paths.certPath,
177
+ fingerprint: computeFingerprint(fallback.cert),
178
+ userProvided: false,
179
+ };
124
180
  }
package/dist/config.js CHANGED
@@ -424,6 +424,18 @@ function mergeWithDefaults(input) {
424
424
  ...input,
425
425
  // Ensure https is boolean
426
426
  https: typeof input.https === "boolean" ? input.https : defaults.https,
427
+ tls: (() => {
428
+ if (!input.tls || typeof input.tls !== "object")
429
+ return undefined;
430
+ const certPath = typeof input.tls.certPath === "string" ? input.tls.certPath.trim() : "";
431
+ const keyPath = typeof input.tls.keyPath === "string" ? input.tls.keyPath.trim() : "";
432
+ if (!certPath && !keyPath)
433
+ return undefined;
434
+ return {
435
+ ...(certPath ? { certPath } : {}),
436
+ ...(keyPath ? { keyPath } : {}),
437
+ };
438
+ })(),
427
439
  defaultCwd: typeof input.defaultCwd === "string" && input.defaultCwd.trim()
428
440
  ? input.defaultCwd
429
441
  : defaults.defaultCwd,
package/dist/server.js CHANGED
@@ -11,7 +11,7 @@ import path from "node:path";
11
11
  import process from "node:process";
12
12
  import { WebSocketServer } from "ws";
13
13
  import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
14
- import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
14
+ import { createSession, readSessionCookie, revokeSession, SESSION_COOKIE_HTTP, SESSION_COOKIE_HTTPS, SESSION_COOKIE_LEGACY, setAuthStorage, validateSession, } from "./auth.js";
15
15
  import { ensureCertificates } from "./cert.js";
16
16
  import { buildChildEnv } from "./env-utils.js";
17
17
  import { isExecutionMode, PREFERENCE_KEYS, resolveConfigDir, saveConfig, writePreferenceToStorage, } from "./config.js";
@@ -367,19 +367,14 @@ async function enrichWithGitStatus(items, dirPath) {
367
367
  }
368
368
  }
369
369
  // ── Auth helpers ──
370
- function requireAuth(req, res, next) {
371
- if (!validateSession(readSessionCookie(req))) {
372
- res.status(401).json({ error: "未授权,请先登录。" });
373
- return;
374
- }
375
- next();
376
- }
377
- function readSessionCookie(req) {
378
- const cookie = req.headers.cookie;
379
- if (!cookie)
380
- return undefined;
381
- const match = cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("wand_session="));
382
- return match?.slice("wand_session=".length);
370
+ function buildRequireAuth(useHttps) {
371
+ return function requireAuth(req, res, next) {
372
+ if (!validateSession(readSessionCookie(req, useHttps))) {
373
+ res.status(401).json({ error: "未授权,请先登录。" });
374
+ return;
375
+ }
376
+ next();
377
+ };
383
378
  }
384
379
  // ── App connection token helpers ──
385
380
  function generateAppToken(password, secret) {
@@ -738,26 +733,6 @@ function classifyFile(ext, baseName) {
738
733
  function mimeForExt(ext) {
739
734
  return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
740
735
  }
741
- /** Hidden files that should still surface even when "show hidden" is off. */
742
- const HIDDEN_ALLOWLIST = new Set([
743
- ".gitignore", ".gitattributes", ".gitmodules",
744
- ".env", ".env.local", ".env.example",
745
- ".editorconfig", ".prettierrc", ".eslintrc",
746
- ".dockerignore", ".npmrc", ".nvmrc",
747
- ".browserslistrc", ".babelrc",
748
- ]);
749
- function isHiddenEntry(name) {
750
- if (!name.startsWith("."))
751
- return false;
752
- if (HIDDEN_ALLOWLIST.has(name))
753
- return false;
754
- // Common patterns like `.env.production` are also kept visible
755
- for (const allowed of HIDDEN_ALLOWLIST) {
756
- if (name.startsWith(allowed + "."))
757
- return false;
758
- }
759
- return true;
760
- }
761
736
  export async function startServer(config, configPath) {
762
737
  const app = express();
763
738
  const storage = new WandStorage(resolveDatabasePath(configPath));
@@ -769,6 +744,7 @@ export async function startServer(config, configPath) {
769
744
  const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger);
770
745
  const useHttps = config.https === true;
771
746
  const protocol = useHttps ? "https" : "http";
747
+ const requireAuth = buildRequireAuth(useHttps);
772
748
  const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
773
749
  app.use(express.json({ limit: "1mb" }));
774
750
  app.use(compression({ threshold: 1024 }));
@@ -878,17 +854,34 @@ export async function startServer(config, configPath) {
878
854
  }
879
855
  resetRateLimit(clientIp);
880
856
  const token = createSession();
881
- res.cookie("wand_session", token, {
857
+ const cookieOpts = {
882
858
  httpOnly: true,
883
859
  sameSite: "strict",
884
- secure: useHttps,
860
+ path: "/",
885
861
  maxAge: 1000 * 60 * 60 * 12,
886
- });
862
+ };
863
+ // 主 cookie:按 scheme 分名字,避免被旧的同名 Secure cookie 阻挡覆盖。
864
+ // 兼容 cookie `wand_session`:老 macOS APP(WandAuth.swift 写死了找 `wand_session`)需要这份才能登录。
865
+ // - HTTPS 模式:legacy 也带 Secure,浏览器与 APP 都能用
866
+ // - HTTP 模式:legacy 不带 Secure。浏览器场景下若之前留有同名 Secure cookie 会被 Strict Secure
867
+ // Cookies 拦截(无害噪音,主 cookie `wand_session_local` 兜得住);APP 走 native cookie API
868
+ // 不受这条策略约束,能正确拿到
869
+ if (useHttps) {
870
+ res.cookie(SESSION_COOKIE_HTTPS, token, { ...cookieOpts, secure: true });
871
+ res.cookie(SESSION_COOKIE_LEGACY, token, { ...cookieOpts, secure: true });
872
+ }
873
+ else {
874
+ res.cookie(SESSION_COOKIE_HTTP, token, { ...cookieOpts, secure: false });
875
+ res.cookie(SESSION_COOKIE_LEGACY, token, { ...cookieOpts, secure: false });
876
+ }
887
877
  res.json({ ok: true });
888
878
  });
889
879
  app.post("/api/logout", (req, res) => {
890
- revokeSession(readSessionCookie(req));
891
- res.clearCookie("wand_session");
880
+ revokeSession(readSessionCookie(req, useHttps));
881
+ // 全部名字都清一遍,避免遗留 cookie 在下次同源访问时被回放。
882
+ for (const name of [SESSION_COOKIE_HTTPS, SESSION_COOKIE_HTTP, SESSION_COOKIE_LEGACY]) {
883
+ res.clearCookie(name, { path: "/" });
884
+ }
892
885
  res.json({ ok: true });
893
886
  });
894
887
  app.post("/api/set-password", requireAuth, (req, res) => {
@@ -978,7 +971,7 @@ export async function startServer(config, configPath) {
978
971
  });
979
972
  // Public probe so the unauthenticated browser does not log a 401 on /api/config
980
973
  app.get("/api/session-check", (req, res) => {
981
- res.json({ authed: validateSession(readSessionCookie(req)) });
974
+ res.json({ authed: validateSession(readSessionCookie(req, useHttps)) });
982
975
  });
983
976
  app.use("/api", requireAuth);
984
977
  // ── Config & Session info ──
@@ -1361,16 +1354,10 @@ export async function startServer(config, configPath) {
1361
1354
  app.get("/api/directory", async (req, res) => {
1362
1355
  const q = typeof req.query.q === "string" ? req.query.q : "";
1363
1356
  const includeGitStatus = req.query.gitStatus === "true";
1364
- const showHidden = req.query.showHidden === "true";
1365
1357
  const targetPath = path.resolve(q || config.defaultCwd);
1366
- if (isBlockedFolderPath(targetPath)) {
1367
- res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
1368
- return;
1369
- }
1370
1358
  try {
1371
1359
  const entries = await readdir(targetPath, { withFileTypes: true });
1372
- const visible = showHidden ? entries : entries.filter((e) => !isHiddenEntry(e.name));
1373
- const sorted = visible.sort((a, b) => {
1360
+ const sorted = entries.sort((a, b) => {
1374
1361
  if (a.isDirectory() && !b.isDirectory())
1375
1362
  return -1;
1376
1363
  if (!a.isDirectory() && b.isDirectory())
@@ -1764,8 +1751,6 @@ export async function startServer(config, configPath) {
1764
1751
  for (const entry of entries) {
1765
1752
  if (results.length >= maxResults)
1766
1753
  break;
1767
- if (entry.name.startsWith(".") || entry.name === "node_modules")
1768
- continue;
1769
1754
  const entryPath = path.join(dirPath, entry.name);
1770
1755
  const nameLower = entry.name.toLowerCase();
1771
1756
  const matchIndex = nameLower.indexOf(queryLower);
@@ -1823,12 +1808,36 @@ export async function startServer(config, configPath) {
1823
1808
  }
1824
1809
  });
1825
1810
  // ── WebSocket broadcast layer ──
1811
+ let activeSslCertPath = null;
1826
1812
  const server = useHttps
1827
1813
  ? (() => {
1828
- const ssl = ensureCertificates(resolveConfigDir(configPath));
1814
+ const ssl = ensureCertificates(resolveConfigDir(configPath), {
1815
+ userCertPath: config.tls?.certPath,
1816
+ userKeyPath: config.tls?.keyPath,
1817
+ });
1818
+ activeSslCertPath = ssl.certPath;
1829
1819
  return createHttpsServer({ key: ssl.key, cert: ssl.cert }, app);
1830
1820
  })()
1831
1821
  : createHttpServer(app);
1822
+ // 公开下载当前证书 —— 方便从手机/其他终端拉证书并导入信任链。
1823
+ // 不鉴权:证书本身是公开材料(不含私钥),泄露不影响安全。
1824
+ if (useHttps && activeSslCertPath) {
1825
+ const certPath = activeSslCertPath;
1826
+ app.get("/cert/server.crt", (_req, res) => {
1827
+ try {
1828
+ if (!existsSync(certPath)) {
1829
+ res.status(404).type("text/plain").send("证书文件不存在");
1830
+ return;
1831
+ }
1832
+ res.setHeader("Content-Type", "application/x-x509-ca-cert");
1833
+ res.setHeader("Content-Disposition", 'attachment; filename="wand-server.crt"');
1834
+ res.send(readFileSync(certPath));
1835
+ }
1836
+ catch (err) {
1837
+ res.status(500).type("text/plain").send(`读取证书失败: ${getErrorMessage(err, "未知错误")}`);
1838
+ }
1839
+ });
1840
+ }
1832
1841
  const wss = new WebSocketServer({
1833
1842
  server,
1834
1843
  path: "/ws",
@@ -1838,7 +1847,7 @@ export async function startServer(config, configPath) {
1838
1847
  concurrencyLimit: 10,
1839
1848
  },
1840
1849
  });
1841
- const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {});
1850
+ const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {}, useHttps);
1842
1851
  wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
1843
1852
  // Wire process events to WebSocket broadcast
1844
1853
  processes.on("process", (event) => {
@@ -428,6 +428,42 @@ function resolveWandBin(ctx) {
428
428
  return which.stdout.trim();
429
429
  return "wand";
430
430
  }
431
+ /**
432
+ * 构造写入 service unit 的 PATH,要覆盖以下来源(按优先级、去重):
433
+ * 1. nodeBinDir —— 保证 service 用的 node 和 install 时的一致
434
+ * 2. process.env.PATH —— 调用 install 的终端 PATH,里面包含用户实际能跑通的 claude/codex 等
435
+ * 3. 常见用户级 bin 兜底(~/.local/bin / ~/.npm-global/bin / ~/bin)—— 防 sudo 把 PATH 收窄
436
+ * 4. /usr/local/... 等系统标准路径
437
+ * 用 sudo 装系统级服务时 `process.env.PATH` 会被 secure_path 替换为极简集合,
438
+ * 所以兜底路径不能省,否则又退化回 "command not found" 现场。
439
+ */
440
+ function buildServicePath(nodeBinDir, home) {
441
+ const out = [];
442
+ const seen = new Set();
443
+ const push = (raw) => {
444
+ if (!raw)
445
+ return;
446
+ for (const seg of raw.split(":")) {
447
+ const trimmed = seg.trim();
448
+ if (!trimmed || seen.has(trimmed))
449
+ continue;
450
+ seen.add(trimmed);
451
+ out.push(trimmed);
452
+ }
453
+ };
454
+ push(nodeBinDir);
455
+ push(process.env.PATH);
456
+ push(`${home}/.local/bin`);
457
+ push(`${home}/.npm-global/bin`);
458
+ push(`${home}/bin`);
459
+ push("/usr/local/sbin");
460
+ push("/usr/local/bin");
461
+ push("/usr/sbin");
462
+ push("/usr/bin");
463
+ push("/sbin");
464
+ push("/bin");
465
+ return out.join(":");
466
+ }
431
467
  /** 当前 process 的真实用户名(system unit 里要写 User=)。 */
432
468
  function currentUserName() {
433
469
  try {
@@ -442,6 +478,12 @@ function installSystemdService(ctx, scope) {
442
478
  const wandBin = resolveWandBin(ctx);
443
479
  const nodeBin = process.execPath;
444
480
  const nodeBinDir = path.dirname(nodeBin);
481
+ const runHome = process.env.HOME || os.homedir();
482
+ // 关键:把调用 `wand service:install` 时的真实 PATH 写进 unit。
483
+ // 否则 systemd 默认 PATH 极简(system scope 之前写死 `nodeBin:/usr/local/...`,
484
+ // user scope 干脆没写),spawn 出的 claude/codex 子进程会撞 "command not found"
485
+ // ——比如 claude 装在 ~/.local/bin、npm global 装在 ~/.npm-global/bin 都不在默认 PATH 里。
486
+ const servicePath = buildServicePath(nodeBinDir, runHome);
445
487
  // 共同字段
446
488
  const commonExec = [
447
489
  "[Unit]",
@@ -453,24 +495,21 @@ function installSystemdService(ctx, scope) {
453
495
  "Type=simple",
454
496
  `ExecStart=${nodeBin} ${wandBin} web -c ${ctx.configPath}`,
455
497
  `Environment=WAND_NO_TUI=1`,
498
+ `Environment=PATH=${servicePath}`,
456
499
  "Restart=always",
457
500
  "RestartSec=3",
458
501
  "StandardOutput=journal",
459
502
  "StandardError=journal",
460
503
  "SyslogIdentifier=wand",
461
504
  ];
462
- // system scope 额外要:User= / HOME= / PATH=(systemd 系统级 service 默认 HOME=/root 是不行的,
463
- // 而且 PATH 极简,nvm 装的 node spawn 出来的 npm 子进程找不到)
464
505
  let unitLines;
465
506
  if (scope === "system") {
466
507
  const runUser = currentUserName();
467
- const runHome = process.env.HOME || os.homedir();
468
508
  unitLines = [
469
509
  ...commonExec,
470
510
  `User=${runUser}`,
471
511
  `WorkingDirectory=${runHome}`,
472
512
  `Environment=HOME=${runHome}`,
473
- `Environment=PATH=${nodeBinDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
474
513
  "OOMScoreAdjust=-500",
475
514
  "",
476
515
  "[Install]",
@@ -479,7 +518,8 @@ function installSystemdService(ctx, scope) {
479
518
  ];
480
519
  }
481
520
  else {
482
- // user scope: 跑在 user@<uid>.service cgroup 内,HOME/PATH 自带
521
+ // user scope: 跑在 user@<uid>.service cgroup 内,HOME 自带;PATH 也要写,
522
+ // systemd 用户实例默认 PATH 同样不含 ~/.local/bin、nvm、npm-global 这些。
483
523
  unitLines = [
484
524
  ...commonExec,
485
525
  "",
@@ -547,6 +587,10 @@ function installLaunchdService(ctx, scope) {
547
587
  const plistPath = servicePathFor(scope);
548
588
  const wandBin = resolveWandBin(ctx);
549
589
  const nodeBin = process.execPath;
590
+ const nodeBinDir = path.dirname(nodeBin);
591
+ const runHome = process.env.HOME || os.homedir();
592
+ // 与 systemd 同理:launchd 默认 PATH 极简,spawn 出的 claude 会找不到。
593
+ const servicePath = buildServicePath(nodeBinDir, runHome);
550
594
  // LaunchDaemon (system) 跑在 root,但 wand 数据应该归 ctx.configPath 的 owner;
551
595
  // 简化处理:system 模式下不强制改 owner,让用户自己提前 chown。
552
596
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
@@ -565,6 +609,8 @@ function installLaunchdService(ctx, scope) {
565
609
  <key>EnvironmentVariables</key>
566
610
  <dict>
567
611
  <key>WAND_NO_TUI</key><string>1</string>
612
+ <key>PATH</key><string>${servicePath}</string>
613
+ <key>HOME</key><string>${runHome}</string>
568
614
  </dict>
569
615
  <key>RunAtLoad</key><true/>
570
616
  <key>KeepAlive</key><true/>