@co0ontty/wand 4.61.1 → 4.62.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/README.md CHANGED
@@ -216,6 +216,7 @@ npm install -g opencode-ai@latest
216
216
  - **个性化角色** — 像素风猫咪头像(赛博虎妞 / 勤劳初二),支持自定义对话角色名称
217
217
  - **消息排队** — 在 AI 思考时可继续输入,消息自动排队发送
218
218
  - **文件浏览器** — 内置路径浏览和搜索功能
219
+ - **本地预览** — 消息和终端里的本机链接可点击预览;顶栏也可手动打开本机 HTTP 端口或 HTML 目录
219
220
  - **快捷提交** — Git 变动快速提交入口,一键生成 commit message
220
221
 
221
222
  #### 部署与访问
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "14d0be2916773382d9b9c692d21ec271479d1fe4",
3
- "builtAt": "2026-09-14T06:29:08.871Z",
4
- "version": "4.61.1",
2
+ "commit": "21dc7e71b1a62f0b83dc1932ae726c53164a3009",
3
+ "builtAt": "2026-09-14T11:06:50.198Z",
4
+ "version": "4.62.0",
5
5
  "channel": "stable"
6
6
  }
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
8
8
  import { getErrorMessage } from "./error-utils.js";
9
9
  import { asyncRoute } from "./express-async.js";
10
10
  import { sendRouteError } from "./server-request.js";
11
- import { isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
11
+ import { isBlockedFolderPath, isPathWithinBase, normalizeFolderPath } from "./middleware/path-safety.js";
12
12
  import { parseBoundedInteger } from "./request-limits.js";
13
13
  const execAsync = promisify(exec);
14
14
  const DIRECTORY_MAX_ITEMS = 200;
@@ -400,6 +400,88 @@ export function registerFileRoutes(app, deps) {
400
400
  sendRouteError(res, error, "Failed to read file");
401
401
  }
402
402
  }));
403
+ // Serve a directory or a single HTML file as a small local static site. The
404
+ // base64url root keeps a URL-directory shape so browser-resolved relative
405
+ // assets stay on this endpoint instead of escaping to the Wand shell.
406
+ app.use("/api/local-file/:encodedRoot", asyncRoute(async (req, res) => {
407
+ if (req.method !== "GET" && req.method !== "HEAD") {
408
+ res.status(405).json({ error: "本地文件预览只支持 GET。" });
409
+ return;
410
+ }
411
+ const encodedRoot = String(req.params.encodedRoot ?? "");
412
+ if (!/^[A-Za-z0-9_-]*$/.test(encodedRoot)) {
413
+ res.status(400).json({ error: "本地预览路径格式无效。" });
414
+ return;
415
+ }
416
+ let rootPath = "";
417
+ try {
418
+ rootPath = Buffer.from(encodedRoot, "base64url").toString("utf8");
419
+ }
420
+ catch {
421
+ res.status(400).json({ error: "本地预览路径格式无效。" });
422
+ return;
423
+ }
424
+ if (!rootPath.trim()) {
425
+ res.status(400).json({ error: "缺少本地预览路径。" });
426
+ return;
427
+ }
428
+ const requestedRoot = normalizeFolderPath(rootPath);
429
+ if (isBlockedFolderPath(requestedRoot)) {
430
+ res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
431
+ return;
432
+ }
433
+ let relativePath = req.url.split("?")[0] ?? "/";
434
+ try {
435
+ relativePath = decodeURIComponent(relativePath);
436
+ }
437
+ catch {
438
+ res.status(400).json({ error: "本地预览资源路径无效。" });
439
+ return;
440
+ }
441
+ relativePath = relativePath.replace(/^\/+/, "");
442
+ try {
443
+ const rootStat = await stat(requestedRoot);
444
+ const baseRoot = rootStat.isFile() ? path.dirname(requestedRoot) : requestedRoot;
445
+ const requested = relativePath.trim() || (rootStat.isFile() ? path.basename(requestedRoot) : "index.html");
446
+ const resolvedPath = path.resolve(baseRoot, requested);
447
+ if (!isPathWithinBase(resolvedPath, baseRoot) || isBlockedFolderPath(resolvedPath)) {
448
+ res.status(403).json({ error: "访问被拒绝:路径越界。" });
449
+ return;
450
+ }
451
+ let fileStat = await stat(resolvedPath);
452
+ let filePath = resolvedPath;
453
+ if (fileStat.isDirectory()) {
454
+ filePath = path.join(resolvedPath, "index.html");
455
+ fileStat = await stat(filePath);
456
+ }
457
+ if (!fileStat.isFile()) {
458
+ res.status(400).json({ error: "目标不是普通文件。" });
459
+ return;
460
+ }
461
+ const ext = path.extname(filePath).toLowerCase();
462
+ const baseName = path.basename(filePath);
463
+ const encodedName = encodeURIComponent(baseName);
464
+ streamFileWithRange(req, res, {
465
+ filePath,
466
+ size: fileStat.size,
467
+ contentType: mimeForExt(ext),
468
+ disposition: `inline; filename*=UTF-8''${encodedName}`,
469
+ headers: {
470
+ "Cache-Control": "private, no-cache",
471
+ "X-Content-Type-Options": "nosniff",
472
+ },
473
+ readErrorMessage: "读取本地文件失败。",
474
+ });
475
+ }
476
+ catch (error) {
477
+ const err = error;
478
+ if (err.code === "ENOENT") {
479
+ res.status(404).json({ error: "本地预览入口或资源不存在。" });
480
+ return;
481
+ }
482
+ sendRouteError(res, error, "读取本地文件失败。");
483
+ }
484
+ }));
403
485
  app.get("/api/folders", asyncRoute(async (req, res) => {
404
486
  const q = typeof req.query.q === "string" ? req.query.q : "/tmp";
405
487
  const targetPath = normalizeFolderPath(q);
@@ -729,6 +811,10 @@ const TEXT_BASENAME_ALLOW = new Set([
729
811
  "makefile", "readme", "license", "changelog",
730
812
  ]);
731
813
  const MIME_BY_EXT = {
814
+ ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
815
+ ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
816
+ ".mjs": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8",
817
+ ".map": "application/json; charset=utf-8", ".xml": "application/xml; charset=utf-8",
732
818
  ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
733
819
  ".svg": "image/svg+xml", ".avif": "image/avif", ".bmp": "image/bmp", ".ico": "image/x-icon", ".heic": "image/heic",
734
820
  ".heif": "image/heif", ".pdf": "application/pdf", ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
@@ -0,0 +1,17 @@
1
+ import type { Express, RequestHandler } from "express";
2
+ export interface LocalPreviewTarget {
3
+ hostname: string;
4
+ port: number;
5
+ path: string;
6
+ }
7
+ /** Parse and constrain a proxy target to a loopback HTTP service. */
8
+ export declare function parseLocalPreviewTarget(rawHost: string, rawPort: string, rawPath: string): LocalPreviewTarget | null;
9
+ /**
10
+ * Reverse-proxy a user-selected loopback port. The route is mounted before the
11
+ * JSON body parser so POST/PUT bodies remain byte streams, including multipart
12
+ * uploads and other content types that Express does not need to inspect.
13
+ */
14
+ export declare function registerLocalPreviewRoutes(app: Express, dependencies: {
15
+ requireAuth: RequestHandler;
16
+ requireFiles: RequestHandler;
17
+ }): void;
@@ -0,0 +1,136 @@
1
+ import http from "node:http";
2
+ import { getErrorMessage } from "./error-utils.js";
3
+ import { asyncRoute } from "./express-async.js";
4
+ const MAX_PORT = 65535;
5
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
6
+ /** Headers that identify the original Wand request and must not leak downstream. */
7
+ const FILTERED_REQUEST_HEADERS = new Set([
8
+ "authorization",
9
+ "connection",
10
+ "cookie",
11
+ "expect",
12
+ "host",
13
+ "upgrade",
14
+ ]);
15
+ /** Hop-by-hop and browser-security headers are owned by the proxy response. */
16
+ const FILTERED_RESPONSE_HEADERS = new Set([
17
+ "connection",
18
+ "content-security-policy",
19
+ "content-security-policy-report-only",
20
+ "cross-origin-embedder-policy",
21
+ "cross-origin-opener-policy",
22
+ "cross-origin-resource-policy",
23
+ "keep-alive",
24
+ "permissions-policy",
25
+ "set-cookie",
26
+ "strict-transport-security",
27
+ "transfer-encoding",
28
+ "upgrade",
29
+ "x-frame-options",
30
+ ]);
31
+ /** Parse and constrain a proxy target to a loopback HTTP service. */
32
+ export function parseLocalPreviewTarget(rawHost, rawPort, rawPath) {
33
+ const hostname = rawHost.trim().toLowerCase();
34
+ if (!LOOPBACK_HOSTS.has(hostname))
35
+ return null;
36
+ if (!/^\d{1,5}$/.test(rawPort))
37
+ return null;
38
+ const port = Number(rawPort);
39
+ if (!Number.isInteger(port) || port < 1 || port > MAX_PORT)
40
+ return null;
41
+ const suffix = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
42
+ return {
43
+ hostname: hostname === "localhost" ? "127.0.0.1" : hostname === "[::1]" ? "::1" : hostname,
44
+ port,
45
+ path: suffix,
46
+ };
47
+ }
48
+ function proxyLocation(value, target) {
49
+ if (value.startsWith("/")) {
50
+ return `/api/local-preview/${target.hostname}/${target.port}${value}`;
51
+ }
52
+ try {
53
+ const url = new URL(value);
54
+ if (url.hostname === target.hostname && url.port === String(target.port)) {
55
+ return `/api/local-preview/${target.hostname}/${target.port}${url.pathname}${url.search}`;
56
+ }
57
+ }
58
+ catch {
59
+ // Leave non-HTTP schemes and opaque values untouched.
60
+ }
61
+ return value;
62
+ }
63
+ /**
64
+ * Reverse-proxy a user-selected loopback port. The route is mounted before the
65
+ * JSON body parser so POST/PUT bodies remain byte streams, including multipart
66
+ * uploads and other content types that Express does not need to inspect.
67
+ */
68
+ export function registerLocalPreviewRoutes(app, dependencies) {
69
+ const handler = asyncRoute(async (req, res, next) => {
70
+ const target = parseLocalPreviewTarget(String(req.params.host ?? ""), String(req.params.port ?? ""), req.url || "/");
71
+ if (!target) {
72
+ res.status(400).json({ error: "只允许预览本机(localhost)HTTP 服务。" });
73
+ return;
74
+ }
75
+ const headers = {};
76
+ for (const [key, value] of Object.entries(req.headers)) {
77
+ if (FILTERED_REQUEST_HEADERS.has(key) || value === undefined)
78
+ continue;
79
+ headers[key] = Array.isArray(value) ? [...value] : value;
80
+ }
81
+ headers["x-forwarded-for"] = req.ip || req.socket.remoteAddress || "";
82
+ headers["x-forwarded-host"] = req.headers.host ?? "";
83
+ headers["x-forwarded-proto"] = req.protocol;
84
+ let upstream;
85
+ try {
86
+ upstream = await new Promise((resolve, reject) => {
87
+ const proxyReq = http.request({
88
+ hostname: target.hostname,
89
+ port: target.port,
90
+ path: target.path,
91
+ method: req.method,
92
+ headers,
93
+ }, resolve);
94
+ proxyReq.once("error", reject);
95
+ req.once("error", reject);
96
+ req.pipe(proxyReq);
97
+ });
98
+ }
99
+ catch (error) {
100
+ if (!res.headersSent) {
101
+ const code = error.code;
102
+ const message = code === "ECONNREFUSED"
103
+ ? "本地服务未启动或端口不可用。"
104
+ : `无法连接本地服务:${getErrorMessage(error)}`;
105
+ res.status(502).json({ error: message });
106
+ }
107
+ else {
108
+ res.destroy();
109
+ }
110
+ return;
111
+ }
112
+ res.status(upstream.statusCode ?? 502);
113
+ for (const [key, value] of Object.entries(upstream.headers)) {
114
+ if (FILTERED_RESPONSE_HEADERS.has(key) || value === undefined)
115
+ continue;
116
+ if (key.toLowerCase() === "location" && typeof value === "string") {
117
+ res.setHeader(key, proxyLocation(value, target));
118
+ continue;
119
+ }
120
+ res.setHeader(key, value);
121
+ }
122
+ if (req.method === "HEAD" || !upstream.statusCode) {
123
+ upstream.destroy();
124
+ res.end();
125
+ return;
126
+ }
127
+ upstream.once("error", () => {
128
+ if (!res.writableEnded)
129
+ res.destroy();
130
+ });
131
+ upstream.pipe(res);
132
+ });
133
+ // req.url is the suffix after the mounted host/port prefix, preserving query
134
+ // strings and relative assets while keeping host and port as route params.
135
+ app.use("/api/local-preview/:host/:port", dependencies.requireAuth, dependencies.requireFiles, handler);
136
+ }
package/dist/server.js CHANGED
@@ -20,6 +20,7 @@ import { SessionRegistry } from "./session-registry.js";
20
20
  import { resolveSessionAiContext, resolveSystemAiContext } from "./session-ai-context.js";
21
21
  import { StructuredSessionManager } from "./structured-session-manager.js";
22
22
  import { recordRecentPath, registerFileRoutes } from "./server-file-routes.js";
23
+ import { registerLocalPreviewRoutes } from "./server-local-preview-routes.js";
23
24
  import { registerSettingsRoutes } from "./server-settings-routes.js";
24
25
  import { appTokenLoginPayload, buildStructuredChatPersonaPayload, isBrowserExtensionOrigin, resolveAppConnectCode, resolveStructuredChatAvatarPath, verifyAppToken, } from "./server-app-connect.js";
25
26
  import { firstHeaderValue, sendRouteError } from "./server-request.js";
@@ -325,6 +326,9 @@ export async function startServer(config, configPath, options = {}) {
325
326
  const requireSessions = buildRequireScope("sessions");
326
327
  const requireFiles = buildRequireScope("files");
327
328
  const requirePasswordVault = buildRequireScope("password-vault");
329
+ // Local preview traffic may contain arbitrary POST bodies. Mounting it before
330
+ // Express body parsers keeps uploads, SSE and non-JSON requests streamable.
331
+ registerLocalPreviewRoutes(app, { requireAuth, requireFiles });
328
332
  // Route-specific parsers must run before the global parser. Once body-parser
329
333
  // has consumed a request, a later express.json() cannot tighten or widen it.
330
334
  app.use("/api/optimize-prompt", express.json({ limit: "256kb" }));
@@ -544,6 +548,7 @@ export async function startServer(config, configPath, options = {}) {
544
548
  "/api/quick-paths",
545
549
  "/api/validate-path",
546
550
  "/api/file-search",
551
+ "/api/local-file",
547
552
  ], requireFiles);
548
553
  app.use("/api/browser-extension", requirePasswordVault);
549
554
  // ── Config & Session info ──