@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/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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
82
|
+
return clonePrincipal(cached.principal);
|
|
66
83
|
}
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-07-
|
|
4
|
-
"version": "
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
*
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|