@co0ontty/wand 3.1.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/auth.d.ts +19 -5
  2. package/dist/auth.js +83 -45
  3. package/dist/build-info.json +3 -3
  4. package/dist/cert.d.ts +1 -1
  5. package/dist/cert.js +124 -74
  6. package/dist/config.js +25 -8
  7. package/dist/express-async.d.ts +6 -0
  8. package/dist/express-async.js +28 -0
  9. package/dist/git-quick-commit.d.ts +2 -0
  10. package/dist/git-quick-commit.js +215 -76
  11. package/dist/git-utils.d.ts +4 -0
  12. package/dist/git-utils.js +60 -11
  13. package/dist/git-worktree.d.ts +8 -1
  14. package/dist/git-worktree.js +406 -41
  15. package/dist/models.d.ts +34 -4
  16. package/dist/models.js +334 -48
  17. package/dist/process-manager.d.ts +22 -30
  18. package/dist/process-manager.js +374 -441
  19. package/dist/provider-history-scanner.d.ts +54 -0
  20. package/dist/provider-history-scanner.js +354 -0
  21. package/dist/request-limits.d.ts +1 -0
  22. package/dist/request-limits.js +8 -0
  23. package/dist/resume-policy.d.ts +2 -0
  24. package/dist/resume-policy.js +5 -0
  25. package/dist/runtime-config.d.ts +16 -0
  26. package/dist/runtime-config.js +49 -0
  27. package/dist/server-file-routes.d.ts +17 -0
  28. package/dist/server-file-routes.js +653 -0
  29. package/dist/server-session-routes.d.ts +16 -3
  30. package/dist/server-session-routes.js +170 -149
  31. package/dist/server-settings-routes.d.ts +43 -0
  32. package/dist/server-settings-routes.js +225 -0
  33. package/dist/server-update-routes.d.ts +61 -0
  34. package/dist/server-update-routes.js +215 -0
  35. package/dist/server.d.ts +6 -4
  36. package/dist/server.js +350 -1313
  37. package/dist/session-logger.d.ts +32 -2
  38. package/dist/session-logger.js +145 -15
  39. package/dist/session-registry.d.ts +27 -0
  40. package/dist/session-registry.js +153 -0
  41. package/dist/session-transport.d.ts +31 -0
  42. package/dist/session-transport.js +82 -0
  43. package/dist/storage.d.ts +24 -6
  44. package/dist/storage.js +291 -44
  45. package/dist/structured-claude-adapter.d.ts +19 -0
  46. package/dist/structured-claude-adapter.js +117 -0
  47. package/dist/structured-codex-adapter.d.ts +3 -0
  48. package/dist/structured-codex-adapter.js +29 -0
  49. package/dist/structured-opencode-adapter.d.ts +11 -0
  50. package/dist/structured-opencode-adapter.js +115 -0
  51. package/dist/structured-provider-common.d.ts +11 -0
  52. package/dist/structured-provider-common.js +77 -0
  53. package/dist/structured-session-manager.d.ts +32 -35
  54. package/dist/structured-session-manager.js +551 -605
  55. package/dist/types.d.ts +10 -0
  56. package/dist/update-helper.js +5 -1
  57. package/dist/web-ui/content/scripts.js +32 -32
  58. package/dist/web-ui/embedded-assets.d.ts +1 -1
  59. package/dist/web-ui/embedded-assets.js +2 -2
  60. package/dist/ws-broadcast.d.ts +16 -1
  61. package/dist/ws-broadcast.js +124 -58
  62. package/package.json +2 -1
package/dist/auth.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { WandStorage } from "./storage.js";
1
+ import { WandStorage, type AuthPrincipal, type AuthScope } from "./storage.js";
2
+ export declare const BROWSER_ADMIN_PRINCIPAL: AuthPrincipal;
3
+ export declare const CONNECTED_APP_PRINCIPAL: AuthPrincipal;
2
4
  /**
3
5
  * Cookie 名按 scheme 隔离 —— 解决浏览器 Strict Secure Cookies 在 HTTPS↔HTTP 切换时
4
6
  * "新 Set-Cookie 被同名 Secure 旧 cookie 静默丢弃"导致登录后立刻 401 的问题。
@@ -19,7 +21,19 @@ export declare function readSessionCookie(req: {
19
21
  cookie?: string;
20
22
  };
21
23
  }, useHttps: boolean): string | undefined;
22
- export declare function createSession(): string;
23
- export declare function validateSession(token: string | undefined): boolean;
24
- export declare function revokeSession(token: string | undefined): void;
25
- export declare function setAuthStorage(nextStorage: WandStorage): void;
24
+ export declare class AuthService {
25
+ private readonly storage;
26
+ private readonly sessionTtlMs;
27
+ private readonly sessions;
28
+ private readonly cleanupTimer;
29
+ private disposed;
30
+ constructor(storage: WandStorage, sessionTtlMs?: number, cleanupIntervalMs?: number);
31
+ createSession(principal?: AuthPrincipal): string;
32
+ authenticateSession(token: string | undefined): AuthPrincipal | null;
33
+ validateSession(token: string | undefined): boolean;
34
+ revokeSession(token: string | undefined): void;
35
+ revokeAllSessions(): void;
36
+ dispose(): void;
37
+ private cleanupExpiredSessions;
38
+ }
39
+ export declare function principalHasScope(principal: AuthPrincipal, scope: AuthScope): boolean;
package/dist/auth.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import crypto from "node:crypto";
2
- const sessions = new Map();
3
2
  const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
4
- let storage = null;
3
+ export const BROWSER_ADMIN_PRINCIPAL = {
4
+ kind: "browser-admin",
5
+ scopes: ["admin"],
6
+ };
7
+ export const CONNECTED_APP_PRINCIPAL = {
8
+ kind: "connected-app",
9
+ scopes: ["sessions", "files", "password-vault", "session-preferences"],
10
+ };
5
11
  /**
6
12
  * Cookie 名按 scheme 隔离 —— 解决浏览器 Strict Secure Cookies 在 HTTPS↔HTTP 切换时
7
13
  * "新 Set-Cookie 被同名 Secure 旧 cookie 静默丢弃"导致登录后立刻 401 的问题。
@@ -33,54 +39,86 @@ export function readSessionCookie(req, useHttps) {
33
39
  }
34
40
  return undefined;
35
41
  }
36
- // Periodic cleanup every 10 minutes
37
- const sessionCleanupTimer = setInterval(() => {
38
- cleanupExpiredSessions();
39
- }, 1000 * 60 * 10);
40
- sessionCleanupTimer.unref();
41
- export function createSession() {
42
- const token = crypto.randomBytes(24).toString("hex");
43
- const expiresAt = Date.now() + SESSION_TTL_MS;
44
- sessions.set(token, expiresAt);
45
- storage?.saveAuthSession(token, expiresAt);
46
- return token;
47
- }
48
- export function validateSession(token) {
49
- if (!token) {
50
- return false;
42
+ export class AuthService {
43
+ storage;
44
+ sessionTtlMs;
45
+ sessions = new Map();
46
+ cleanupTimer;
47
+ disposed = false;
48
+ constructor(storage, sessionTtlMs = SESSION_TTL_MS, cleanupIntervalMs = 1000 * 60 * 10) {
49
+ this.storage = storage;
50
+ this.sessionTtlMs = sessionTtlMs;
51
+ this.cleanupTimer = setInterval(() => this.cleanupExpiredSessions(), cleanupIntervalMs);
52
+ this.cleanupTimer.unref?.();
51
53
  }
52
- let expiresAt = sessions.get(token);
53
- if (typeof expiresAt === "undefined") {
54
- const persisted = storage?.getAuthSession(token);
55
- if (persisted) {
56
- sessions.set(token, persisted.expiresAt);
57
- expiresAt = persisted.expiresAt;
58
- }
54
+ createSession(principal = BROWSER_ADMIN_PRINCIPAL) {
55
+ if (this.disposed)
56
+ throw new Error("AuthService has been disposed.");
57
+ const token = crypto.randomBytes(24).toString("hex");
58
+ const expiresAt = Date.now() + this.sessionTtlMs;
59
+ const cached = { expiresAt, principal: clonePrincipal(principal) };
60
+ this.sessions.set(token, cached);
61
+ this.storage.saveAuthSession(token, expiresAt, cached.principal);
62
+ return token;
59
63
  }
60
- if (!expiresAt || expiresAt < Date.now()) {
61
- if (expiresAt) {
62
- sessions.delete(token);
63
- storage?.deleteAuthSession(token);
64
+ authenticateSession(token) {
65
+ if (!token || this.disposed)
66
+ return null;
67
+ let cached = this.sessions.get(token);
68
+ if (!cached) {
69
+ const persisted = this.storage.getAuthSession(token);
70
+ if (persisted) {
71
+ cached = { expiresAt: persisted.expiresAt, principal: clonePrincipal(persisted.principal) };
72
+ this.sessions.set(token, cached);
73
+ }
74
+ }
75
+ if (!cached || cached.expiresAt < Date.now()) {
76
+ if (cached) {
77
+ this.sessions.delete(token);
78
+ this.storage.deleteAuthSession(token);
79
+ }
80
+ return null;
64
81
  }
65
- return false;
82
+ return clonePrincipal(cached.principal);
66
83
  }
67
- return true;
68
- }
69
- export function revokeSession(token) {
70
- if (token) {
71
- sessions.delete(token);
72
- storage?.deleteAuthSession(token);
84
+ validateSession(token) {
85
+ return this.authenticateSession(token) !== null;
73
86
  }
74
- }
75
- export function setAuthStorage(nextStorage) {
76
- storage = nextStorage;
77
- }
78
- function cleanupExpiredSessions() {
79
- const now = Date.now();
80
- for (const [token, expiresAt] of sessions) {
81
- if (expiresAt < now) {
82
- sessions.delete(token);
87
+ revokeSession(token) {
88
+ if (!token || this.disposed)
89
+ return;
90
+ this.sessions.delete(token);
91
+ this.storage.deleteAuthSession(token);
92
+ }
93
+ revokeAllSessions() {
94
+ if (this.disposed)
95
+ return;
96
+ this.sessions.clear();
97
+ this.storage.deleteAllAuthSessions();
98
+ }
99
+ dispose() {
100
+ if (this.disposed)
101
+ return;
102
+ this.disposed = true;
103
+ clearInterval(this.cleanupTimer);
104
+ this.sessions.clear();
105
+ }
106
+ cleanupExpiredSessions() {
107
+ if (this.disposed)
108
+ return;
109
+ const now = Date.now();
110
+ for (const [token, cached] of this.sessions) {
111
+ if (cached.expiresAt < now)
112
+ this.sessions.delete(token);
83
113
  }
114
+ this.storage.deleteExpiredAuthSessions(now);
84
115
  }
85
- storage?.deleteExpiredAuthSessions(now);
116
+ }
117
+ export function principalHasScope(principal, scope) {
118
+ return principal.kind === "browser-admin"
119
+ || principal.scopes.includes("admin")
120
+ || principal.scopes.includes(scope);
121
+ }
122
+ function clonePrincipal(principal) {
123
+ return { kind: principal.kind, scopes: [...principal.scopes] };
86
124
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "18597f5a51ca36892eed295095956a44bb3da2c8",
3
- "builtAt": "2026-07-13T08:34:48.768Z",
4
- "version": "3.1.1",
2
+ "commit": "876fe5a6a89ec103eb7707fc1304592910640c35",
3
+ "builtAt": "2026-07-14T13:56:32.839Z",
4
+ "version": "4.0.0",
5
5
  "channel": "stable"
6
6
  }
package/dist/cert.d.ts CHANGED
@@ -18,6 +18,6 @@ export interface EnsureCertificatesOptions {
18
18
  * 1. options.userCertPath / userKeyPath(config.tls)
19
19
  * 2. 配置目录下已存在的 server.crt + server.key
20
20
  * 3. 用 openssl 现场生成自签
21
- * 4. node crypto 兜底(产出非法证书,主要避免崩溃)
21
+ * 无法可靠生成时明确失败;绝不写入无效或与私钥不匹配的占位证书。
22
22
  */
23
23
  export declare function ensureCertificates(configDir: string, options?: EnsureCertificatesOptions): SSLConfig;
package/dist/cert.js CHANGED
@@ -1,8 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { execSync } from "node:child_process";
3
- import { createHash } from "node:crypto";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash, createPrivateKey, randomUUID, X509Certificate } from "node:crypto";
4
+ import { isIP } from "node:net";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
7
+ import { createSecureContext } from "node:tls";
6
8
  function getCertificatePaths(configDir) {
7
9
  return {
8
10
  keyPath: path.join(configDir, "server.key"),
@@ -19,6 +21,38 @@ function readPair(keyPath, certPath) {
19
21
  return null;
20
22
  }
21
23
  }
24
+ function validateCertificatePair(pair) {
25
+ try {
26
+ // createSecureContext performs OpenSSL's full PEM parsing and key/certificate
27
+ // consistency checks. checkPrivateKey keeps the mismatch failure explicit.
28
+ createSecureContext({ key: pair.key, cert: pair.cert });
29
+ const certificate = new X509Certificate(pair.cert);
30
+ const privateKey = createPrivateKey(pair.key);
31
+ if (!certificate.checkPrivateKey(privateKey))
32
+ return "证书与私钥不匹配";
33
+ const now = Date.now();
34
+ const validFrom = Date.parse(certificate.validFrom);
35
+ const validTo = Date.parse(certificate.validTo);
36
+ if (!Number.isFinite(validFrom) || !Number.isFinite(validTo))
37
+ return "证书有效期无法解析";
38
+ if (now < validFrom)
39
+ return "证书尚未生效";
40
+ if (now >= validTo)
41
+ return "证书已过期";
42
+ return null;
43
+ }
44
+ catch (error) {
45
+ return error instanceof Error ? error.message : String(error);
46
+ }
47
+ }
48
+ function ensurePrivateDirectory(dir) {
49
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ chmodSync(dir, 0o700);
51
+ }
52
+ function hardenGeneratedPair(paths) {
53
+ chmodSync(paths.keyPath, 0o600);
54
+ chmodSync(paths.certPath, 0o600);
55
+ }
22
56
  /**
23
57
  * 收集本机所有可外部访问的 IPv4 地址 + hostname,作为自签证书的 SAN。
24
58
  * 之前 SAN 只有 `localhost,127.0.0.1`,从手机/局域网用 `https://192.168.x.x` 访问
@@ -27,7 +61,15 @@ function readPair(keyPath, certPath) {
27
61
  function collectSanEntries() {
28
62
  const dns = new Set(["localhost"]);
29
63
  const ip = new Set(["127.0.0.1", "::1"]);
30
- const hostname = os.hostname();
64
+ const rawHostname = os.hostname().trim().toLowerCase().replace(/\.$/, "");
65
+ // Hostnames originate outside this process and become part of OpenSSL's
66
+ // extension grammar. Keep only RFC-style ASCII DNS labels.
67
+ const hostname = rawHostname.length <= 253
68
+ && rawHostname.split(".").every((label) => (label.length >= 1
69
+ && label.length <= 63
70
+ && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)))
71
+ ? rawHostname
72
+ : "";
31
73
  if (hostname && hostname !== "localhost") {
32
74
  dns.add(hostname);
33
75
  // 部分 mDNS 环境下 hostname.local 也能解析
@@ -44,7 +86,9 @@ function collectSanEntries() {
44
86
  // Node 18+ entry.family 是 "IPv4" | "IPv6",旧版本是 4 | 6
45
87
  const fam = entry.family;
46
88
  if (fam === "IPv4" || fam === 4 || fam === "IPv6" || fam === 6) {
47
- ip.add(entry.address.split("%")[0]); // 去掉 zone-id
89
+ const address = entry.address.split("%")[0]; // 去掉 zone-id
90
+ if (isIP(address) !== 0)
91
+ ip.add(address);
48
92
  }
49
93
  }
50
94
  }
@@ -73,91 +117,101 @@ function computeFingerprint(certPem) {
73
117
  * 这样从手机/局域网设备访问 `https://<LAN IP>:port/` 时不会撞 SAN 不匹配。
74
118
  */
75
119
  function generateWithOpenSSL(paths) {
120
+ const dir = path.dirname(paths.keyPath);
121
+ ensurePrivateDirectory(dir);
122
+ const suffix = `${process.pid}-${randomUUID()}`;
123
+ const temporaryKeyPath = path.join(dir, `.server.key.${suffix}.tmp`);
124
+ const temporaryCertPath = path.join(dir, `.server.crt.${suffix}.tmp`);
125
+ const execOptions = {
126
+ stdio: "pipe",
127
+ timeout: 15_000,
128
+ windowsHide: true,
129
+ };
76
130
  try {
77
- execSync("openssl version", { stdio: "pipe" });
78
- const dir = path.dirname(paths.keyPath);
79
- if (!existsSync(dir))
80
- mkdirSync(dir, { recursive: true });
81
- execSync(`openssl genrsa -out "${paths.keyPath}" 2048`, { stdio: "pipe" });
82
- const san = buildSanArg();
83
- execSync(`openssl req -new -x509 -key "${paths.keyPath}" -out "${paths.certPath}" -days 825 ` +
84
- `-subj "/CN=wand-local/O=Wand Local Development" ` +
85
- `-addext "subjectAltName=${san}" ` +
86
- `-addext "extendedKeyUsage=serverAuth" ` +
87
- `-addext "basicConstraints=critical,CA:FALSE"`, { stdio: "pipe" });
88
- return {
89
- key: readFileSync(paths.keyPath),
90
- cert: readFileSync(paths.certPath),
131
+ // Pre-create with private modes. OpenSSL truncates existing output files and
132
+ // preserves their mode, so neither artifact is briefly world-readable.
133
+ writeFileSync(temporaryKeyPath, "", { flag: "wx", mode: 0o600 });
134
+ writeFileSync(temporaryCertPath, "", { flag: "wx", mode: 0o600 });
135
+ execFileSync("openssl", ["version"], execOptions);
136
+ execFileSync("openssl", ["genrsa", "-out", temporaryKeyPath, "2048"], execOptions);
137
+ execFileSync("openssl", [
138
+ "req",
139
+ "-new",
140
+ "-x509",
141
+ "-key", temporaryKeyPath,
142
+ "-out", temporaryCertPath,
143
+ "-days", "825",
144
+ "-subj", "/CN=wand-local/O=Wand Local Development",
145
+ "-addext", `subjectAltName=${buildSanArg()}`,
146
+ "-addext", "extendedKeyUsage=serverAuth",
147
+ "-addext", "basicConstraints=critical,CA:FALSE",
148
+ ], execOptions);
149
+ chmodSync(temporaryKeyPath, 0o600);
150
+ chmodSync(temporaryCertPath, 0o600);
151
+ const pair = {
152
+ key: readFileSync(temporaryKeyPath),
153
+ cert: readFileSync(temporaryCertPath),
91
154
  };
155
+ const validationError = validateCertificatePair(pair);
156
+ if (validationError) {
157
+ throw new Error(`OpenSSL 生成了不可用的证书:${validationError}`);
158
+ }
159
+ renameSync(temporaryKeyPath, paths.keyPath);
160
+ renameSync(temporaryCertPath, paths.certPath);
161
+ hardenGeneratedPair(paths);
162
+ return pair;
92
163
  }
93
- catch {
94
- return null;
164
+ finally {
165
+ rmSync(temporaryKeyPath, { force: true });
166
+ rmSync(temporaryCertPath, { force: true });
95
167
  }
96
168
  }
97
- /**
98
- * 没有 openssl 时的兜底:用 node 内置 crypto 生成 RSA 密钥,证书部分写一个
99
- * 明显的 placeholder PEM。这条路径产出的证书**无法被浏览器接受**,主要是为了
100
- * 不让 HTTPS 监听直接崩;启动日志会强烈建议用户装 openssl 或自备证书。
101
- */
102
- function generateWithoutOpenSSL(paths) {
103
- const dir = path.dirname(paths.keyPath);
104
- if (!existsSync(dir))
105
- mkdirSync(dir, { recursive: true });
106
- const { generateKeyPairSync } = require("node:crypto");
107
- const { privateKey } = generateKeyPairSync("rsa", {
108
- modulusLength: 2048,
109
- publicKeyEncoding: { type: "spki", format: "pem" },
110
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
111
- });
112
- const cert = "-----BEGIN CERTIFICATE-----\n" +
113
- "MIIBkTCB+wIJAKHBfPOPlvfoMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnNh\n" +
114
- "bmRib3gwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjARMQ8wDQYDVQQD\n" +
115
- "DAZzYW5kYm94MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALLgGbUPZxEvLPLXZQrz\n" +
116
- "KxLhP5EoaUuB7V8FYA5JQZbRE6RkxEKkR8jFQHOcQYevGQYEbXvKZ0WxR2BqMJsC\n" +
117
- "AwEAAaMgMB4wDQYJKoZIhvcNAQELBQADQQBpMq0NweMwF7fh0TiMwFCTzC/wK7fR\n" +
118
- "e0WxR2BqMJsC\n" +
119
- "-----END CERTIFICATE-----\n";
120
- writeFileSync(paths.keyPath, privateKey, { mode: 0o600 });
121
- writeFileSync(paths.certPath, cert, { mode: 0o644 });
122
- process.stderr.write("\x1b[33m[wand] 警告:未检测到 openssl,写出的是无效占位证书。\n" +
123
- "[wand] 请安装 openssl,或在 config.json 里配 tls.certPath / tls.keyPath\n" +
124
- "[wand] 指向自备证书(推荐 mkcert:mkcert -install && mkcert localhost <hostname> <LAN-IP>)。\x1b[0m\n");
125
- return { key: Buffer.from(privateKey), cert: Buffer.from(cert) };
126
- }
127
169
  /**
128
170
  * 主入口:装载 TLS 证书。优先级(高 → 低):
129
171
  * 1. options.userCertPath / userKeyPath(config.tls)
130
172
  * 2. 配置目录下已存在的 server.crt + server.key
131
173
  * 3. 用 openssl 现场生成自签
132
- * 4. node crypto 兜底(产出非法证书,主要避免崩溃)
174
+ * 无法可靠生成时明确失败;绝不写入无效或与私钥不匹配的占位证书。
133
175
  */
134
176
  export function ensureCertificates(configDir, options = {}) {
135
177
  // 1. 用户自备证书
136
178
  if (options.userCertPath && options.userKeyPath) {
137
179
  const pair = readPair(options.userKeyPath, options.userCertPath);
138
180
  if (pair) {
139
- const fingerprint = computeFingerprint(pair.cert);
140
- process.stdout.write(`[wand] 使用 config.tls 指定的证书:${options.userCertPath}\n` +
141
- `[wand] SHA-256 指纹: ${fingerprint}\n`);
142
- return { ...pair, certPath: options.userCertPath, fingerprint, userProvided: true };
181
+ const validationError = validateCertificatePair(pair);
182
+ if (!validationError) {
183
+ const fingerprint = computeFingerprint(pair.cert);
184
+ process.stdout.write(`[wand] 使用 config.tls 指定的证书:${options.userCertPath}\n` +
185
+ `[wand] SHA-256 指纹: ${fingerprint}\n`);
186
+ return { ...pair, certPath: options.userCertPath, fingerprint, userProvided: true };
187
+ }
188
+ process.stderr.write(`\x1b[33m[wand] 警告:config.tls 指向的证书不可用(${validationError}),回退到默认自签流程。\x1b[0m\n`);
189
+ }
190
+ else {
191
+ process.stderr.write(`\x1b[33m[wand] 警告:config.tls 指向的证书文件无法读取(cert=${options.userCertPath}, key=${options.userKeyPath}),回退到默认自签流程。\x1b[0m\n`);
143
192
  }
144
- process.stderr.write(`\x1b[33m[wand] 警告:config.tls 指向的证书文件无法读取(cert=${options.userCertPath}, key=${options.userKeyPath}),回退到默认自签流程。\x1b[0m\n`);
145
193
  }
146
194
  const paths = getCertificatePaths(configDir);
147
195
  // 2. 已存在的自签
148
196
  const existing = readPair(paths.keyPath, paths.certPath);
149
197
  if (existing) {
150
- return {
151
- ...existing,
152
- certPath: paths.certPath,
153
- fingerprint: computeFingerprint(existing.cert),
154
- userProvided: false,
155
- };
198
+ const validationError = validateCertificatePair(existing);
199
+ if (!validationError) {
200
+ ensurePrivateDirectory(path.dirname(paths.keyPath));
201
+ hardenGeneratedPair(paths);
202
+ return {
203
+ ...existing,
204
+ certPath: paths.certPath,
205
+ fingerprint: computeFingerprint(existing.cert),
206
+ userProvided: false,
207
+ };
208
+ }
209
+ process.stderr.write(`\x1b[33m[wand] 警告:已有自签证书不可用(${validationError}),正在重新生成。\x1b[0m\n`);
156
210
  }
157
211
  process.stdout.write("[wand] 正在生成 self-signed HTTPS 证书…\n");
158
212
  // 3. OpenSSL
159
- const ssl = generateWithOpenSSL(paths);
160
- if (ssl) {
213
+ try {
214
+ const ssl = generateWithOpenSSL(paths);
161
215
  const fingerprint = computeFingerprint(ssl.cert);
162
216
  process.stdout.write(`[wand] 证书已写入 ${paths.certPath}\n` +
163
217
  `[wand] SHA-256 指纹: ${fingerprint}\n` +
@@ -165,13 +219,9 @@ export function ensureCertificates(configDir, options = {}) {
165
219
  `[wand] HTTPS 启用后可通过 GET /cert/server.crt 在客户端下载安装。\n`);
166
220
  return { ...ssl, certPath: paths.certPath, fingerprint, userProvided: false };
167
221
  }
168
- // 4. 兜底
169
- process.stdout.write("[wand] 未检测到 openssl,使用 node crypto 兜底(产出占位证书,仅防崩)。\n");
170
- const fallback = generateWithoutOpenSSL(paths);
171
- return {
172
- ...fallback,
173
- certPath: paths.certPath,
174
- fingerprint: computeFingerprint(fallback.cert),
175
- userProvided: false,
176
- };
222
+ catch (error) {
223
+ const detail = error instanceof Error ? error.message : String(error);
224
+ throw new Error("无法生成 HTTPS 证书:需要可用的 OpenSSL,或在 config.tls 中配置有效且匹配的 certPath/keyPath。" +
225
+ ` OpenSSL 错误:${detail}`, { cause: error });
226
+ }
177
227
  }
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  import { isRunningAsRoot } from "./env-utils.js";
@@ -122,9 +122,12 @@ async function atomicWriteFile(filePath, content) {
122
122
  const dir = path.dirname(filePath);
123
123
  const base = path.basename(filePath);
124
124
  const tmpPath = path.join(dir, `.${base}.tmp-${crypto.randomBytes(6).toString("hex")}`);
125
- await writeFile(tmpPath, content, "utf8");
125
+ await writeFile(tmpPath, content, { encoding: "utf8", mode: 0o600 });
126
126
  try {
127
127
  await rename(tmpPath, filePath);
128
+ // rename preserves the temporary file mode, but chmod also repairs an
129
+ // existing config created by older versions or a permissive umask.
130
+ await chmod(filePath, 0o600);
128
131
  }
129
132
  catch (err) {
130
133
  try {
@@ -136,7 +139,9 @@ async function atomicWriteFile(filePath, content) {
136
139
  }
137
140
  /** saveConfig 写出时去掉偏好字段——这些已经移到 SQLite。 */
138
141
  export async function saveConfig(configPath, config) {
139
- await mkdir(path.dirname(configPath), { recursive: true });
142
+ const dir = path.dirname(configPath);
143
+ await mkdir(dir, { recursive: true, mode: 0o700 });
144
+ await chmod(dir, 0o700);
140
145
  await atomicWriteFile(configPath, `${JSON.stringify(stripPreferenceFields(config), null, 2)}\n`);
141
146
  }
142
147
  /**
@@ -149,7 +154,8 @@ export async function saveConfig(configPath, config) {
149
154
  */
150
155
  export async function loadConfigWithStorage(configPath, storage) {
151
156
  const dir = path.dirname(configPath);
152
- await mkdir(dir, { recursive: true });
157
+ await mkdir(dir, { recursive: true, mode: 0o700 });
158
+ await chmod(dir, 0o700);
153
159
  let rawInput = {};
154
160
  let hadFile = false;
155
161
  try {
@@ -175,7 +181,8 @@ export async function loadConfigWithStorage(configPath, storage) {
175
181
  applyStoragePassword(config, storage);
176
182
  // 如果 JSON 里有偏好字段(说明是老版本配置或刚迁移),重写一次干净版本
177
183
  const hasLegacyPrefs = PREFERENCE_KEYS.some((key) => key in rawInput);
178
- if (!hadFile || hasLegacyPrefs) {
184
+ const hasLegacySecrets = "password" in rawInput || "appSecret" in rawInput;
185
+ if (!hadFile || hasLegacyPrefs || hasLegacySecrets) {
179
186
  await saveConfig(configPath, config);
180
187
  }
181
188
  return config;
@@ -226,6 +233,10 @@ function stripPreferenceFields(config) {
226
233
  for (const key of PREFERENCE_KEYS) {
227
234
  delete out[key];
228
235
  }
236
+ // These values are DB-owned runtime secrets. They must never be copied back
237
+ // into config.json when a deployment setting is updated.
238
+ delete out.password;
239
+ delete out.appSecret;
229
240
  return out;
230
241
  }
231
242
  /**
@@ -387,13 +398,17 @@ export function writePreferenceToStorage(config, storage, key, value) {
387
398
  break;
388
399
  }
389
400
  case "defaultThinkingEffort": {
390
- const v = isThinkingEffort(value) ? value : "off";
401
+ if (!isThinkingEffort(value))
402
+ throw new Error(`无效思考深度: ${String(value)}`);
403
+ const v = value;
391
404
  storage.setPreference(dbKey, v);
392
405
  config.defaultThinkingEffort = v;
393
406
  break;
394
407
  }
395
408
  case "structuredRunner": {
396
- const v = value === "cli" ? "cli" : "sdk";
409
+ if (value !== "cli" && value !== "sdk")
410
+ throw new Error(`无效 structured runner: ${String(value)}`);
411
+ const v = value;
397
412
  storage.setPreference(dbKey, v);
398
413
  config.structuredRunner = v;
399
414
  break;
@@ -411,7 +426,9 @@ export function writePreferenceToStorage(config, storage, key, value) {
411
426
  break;
412
427
  }
413
428
  case "inheritEnv": {
414
- const v = value === false ? false : true;
429
+ if (typeof value !== "boolean")
430
+ throw new Error(`inheritEnv 必须是布尔值: ${String(value)}`);
431
+ const v = value;
415
432
  storage.setPreference(dbKey, v);
416
433
  config.inheritEnv = v;
417
434
  break;
@@ -0,0 +1,6 @@
1
+ import type { ErrorRequestHandler, NextFunction, Request, RequestHandler, Response } from "express";
2
+ export type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => unknown | Promise<unknown>;
3
+ /** Forward both synchronous throws and rejected route promises to Express. */
4
+ export declare function asyncRoute(handler: AsyncRouteHandler): RequestHandler;
5
+ /** Keep API failures JSON-shaped, including body-parser and async route errors. */
6
+ export declare const jsonErrorHandler: ErrorRequestHandler;
@@ -0,0 +1,28 @@
1
+ import { getErrorMessage } from "./error-utils.js";
2
+ /** Forward both synchronous throws and rejected route promises to Express. */
3
+ export function asyncRoute(handler) {
4
+ return (req, res, next) => {
5
+ try {
6
+ void Promise.resolve(handler(req, res, next)).catch(next);
7
+ }
8
+ catch (error) {
9
+ next(error);
10
+ }
11
+ };
12
+ }
13
+ /** Keep API failures JSON-shaped, including body-parser and async route errors. */
14
+ export const jsonErrorHandler = (error, _req, res, next) => {
15
+ if (res.headersSent) {
16
+ next(error);
17
+ return;
18
+ }
19
+ const status = typeof error?.status === "number"
20
+ ? error.status
21
+ : 500;
22
+ if (status === 413 || error?.type === "entity.too.large") {
23
+ res.status(413).json({ error: "请求内容过大。" });
24
+ return;
25
+ }
26
+ console.error("[wand] Unhandled request error:", getErrorMessage(error));
27
+ res.status(status >= 400 && status < 600 ? status : 500).json({ error: "请求处理失败。" });
28
+ };
@@ -1,6 +1,8 @@
1
1
  import { GitStatusResult, PushResult, QuickCommitResult, SessionProvider, SessionSnapshot, TagHeadResult } from "./types.js";
2
2
  export type QuickCommitErrorCode = "CWD_MISSING" | "NO_CWD" | "NOT_A_GIT_REPO" | "NO_COMMIT" | "NOTHING_TO_COMMIT" | "NOTHING_TO_PUSH" | "EMPTY_MESSAGE" | "EMPTY_TAG" | "EMPTY_AI_MESSAGE" | "INVALID_AI_TAG" | "TAG_EXISTS" | "GIT_ADD_FAILED" | "GIT_DIFF_FAILED" | "GIT_COMMIT_FAILED" | "GIT_TAG_FAILED" | "CLAUDE_CLI_MISSING" | "CLAUDE_CLI_FAILED" | "CLAUDE_TIMEOUT";
3
3
  export declare function getGitStatus(cwd: string): GitStatusResult;
4
+ /** Non-blocking status implementation used by HTTP routes. */
5
+ export declare function getGitStatusAsync(cwd: string): Promise<GitStatusResult>;
4
6
  interface QuickCommitOptions {
5
7
  cwd: string;
6
8
  language: string;