@co0ontty/wand 1.21.11 → 1.21.13

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/server.js CHANGED
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import compression from "compression";
3
3
  import express from "express";
4
4
  import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
5
- import { mkdir, readdir, readFile, stat } from "node:fs/promises";
5
+ import { lstat, mkdir, readdir, readFile, stat } from "node:fs/promises";
6
6
  import { createServer as createHttpServer } from "node:http";
7
7
  import { createServer as createHttpsServer } from "node:https";
8
8
  import { exec, spawn } from "node:child_process";
@@ -13,6 +13,7 @@ import { WebSocketServer } from "ws";
13
13
  import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
14
14
  import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
15
15
  import { ensureCertificates } from "./cert.js";
16
+ import { buildChildEnv } from "./env-utils.js";
16
17
  import { isExecutionMode, PREFERENCE_KEYS, resolveConfigDir, saveConfig, writePreferenceToStorage, } from "./config.js";
17
18
  import { getCachedModels, refreshModels } from "./models.js";
18
19
  import { ProcessManager } from "./process-manager.js";
@@ -535,6 +536,106 @@ function getLanguageFromExt(ext, filePath) {
535
536
  return "plaintext";
536
537
  return map[ext] || "plaintext";
537
538
  }
539
+ // ── File preview classification ──
540
+ const TEXT_PREVIEWABLE_EXTS = new Set([
541
+ ".md", ".markdown", ".mdown", ".mkd", ".mkdn", ".mdx",
542
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
543
+ ".json", ".jsonc", ".html", ".htm", ".css", ".scss", ".less",
544
+ ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
545
+ ".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh", ".fish",
546
+ ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
547
+ ".xml", ".sql", ".graphql", ".proto",
548
+ ".dockerfile", ".gitignore", ".editorconfig",
549
+ ".vue", ".svelte",
550
+ ".txt", ".log", ".diff", ".patch",
551
+ ".lua", ".r", ".dart", ".pl", ".pm",
552
+ ]);
553
+ const IMAGE_EXTS = new Set([
554
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif",
555
+ ".bmp", ".ico", ".heic", ".heif",
556
+ ]);
557
+ const VIDEO_EXTS = new Set([
558
+ ".mp4", ".webm", ".mov", ".mkv", ".m4v", ".ogv",
559
+ ]);
560
+ const AUDIO_EXTS = new Set([
561
+ ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".opus",
562
+ ]);
563
+ const PDF_EXTS = new Set([".pdf"]);
564
+ const MIME_BY_EXT = {
565
+ ".png": "image/png",
566
+ ".jpg": "image/jpeg",
567
+ ".jpeg": "image/jpeg",
568
+ ".gif": "image/gif",
569
+ ".webp": "image/webp",
570
+ ".svg": "image/svg+xml",
571
+ ".avif": "image/avif",
572
+ ".bmp": "image/bmp",
573
+ ".ico": "image/x-icon",
574
+ ".heic": "image/heic",
575
+ ".heif": "image/heif",
576
+ ".pdf": "application/pdf",
577
+ ".mp4": "video/mp4",
578
+ ".webm": "video/webm",
579
+ ".mov": "video/quicktime",
580
+ ".mkv": "video/x-matroska",
581
+ ".m4v": "video/x-m4v",
582
+ ".ogv": "video/ogg",
583
+ ".mp3": "audio/mpeg",
584
+ ".wav": "audio/wav",
585
+ ".ogg": "audio/ogg",
586
+ ".m4a": "audio/mp4",
587
+ ".flac": "audio/flac",
588
+ ".aac": "audio/aac",
589
+ ".opus": "audio/opus",
590
+ };
591
+ const TEXT_BASENAME_ALLOW = new Set([
592
+ "dockerfile", ".gitignore", ".dockerignore", ".env", ".env.local",
593
+ ".env.development", ".env.production", ".env.test",
594
+ "makefile", "readme", "license", "changelog",
595
+ ]);
596
+ function classifyFile(ext, baseName) {
597
+ const lowerExt = ext.toLowerCase();
598
+ const lowerBase = baseName.toLowerCase();
599
+ if (IMAGE_EXTS.has(lowerExt))
600
+ return "image";
601
+ if (PDF_EXTS.has(lowerExt))
602
+ return "pdf";
603
+ if (VIDEO_EXTS.has(lowerExt))
604
+ return "video";
605
+ if (AUDIO_EXTS.has(lowerExt))
606
+ return "audio";
607
+ if (TEXT_PREVIEWABLE_EXTS.has(lowerExt))
608
+ return "text";
609
+ if (TEXT_BASENAME_ALLOW.has(lowerBase))
610
+ return "text";
611
+ // Files with no extension that look like text-y dotfiles
612
+ if (lowerExt === "" && /^[a-z0-9._-]+$/i.test(lowerBase))
613
+ return "text";
614
+ return "binary";
615
+ }
616
+ function mimeForExt(ext) {
617
+ return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
618
+ }
619
+ /** Hidden files that should still surface even when "show hidden" is off. */
620
+ const HIDDEN_ALLOWLIST = new Set([
621
+ ".gitignore", ".gitattributes", ".gitmodules",
622
+ ".env", ".env.local", ".env.example",
623
+ ".editorconfig", ".prettierrc", ".eslintrc",
624
+ ".dockerignore", ".npmrc", ".nvmrc",
625
+ ".browserslistrc", ".babelrc",
626
+ ]);
627
+ function isHiddenEntry(name) {
628
+ if (!name.startsWith("."))
629
+ return false;
630
+ if (HIDDEN_ALLOWLIST.has(name))
631
+ return false;
632
+ // Common patterns like `.env.production` are also kept visible
633
+ for (const allowed of HIDDEN_ALLOWLIST) {
634
+ if (name.startsWith(allowed + "."))
635
+ return false;
636
+ }
637
+ return true;
638
+ }
538
639
  export async function startServer(config, configPath) {
539
640
  const app = express();
540
641
  const storage = new WandStorage(resolveDatabasePath(configPath));
@@ -804,6 +905,45 @@ export async function startServer(config, configPath) {
804
905
  github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
805
906
  });
806
907
  });
908
+ // 返回当前 inheritEnv 配置下,wand 启动 PTY / 结构化子进程时实际会传给
909
+ // claude / codex 的环境变量集合。值会按下面的规则做掩码:
910
+ // - 名字里含 KEY/TOKEN/SECRET/PASSWORD/AUTH/CREDENTIAL/COOKIE/SESSION 的视为敏感
911
+ // - 敏感值默认显示为 ***(保留长度提示),可通过 ?reveal=1 取消掩码
912
+ // 即使开启 reveal,仍只对已认证用户可见(路由由全局 requireAuth 保护)。
913
+ app.get("/api/settings/env-preview", (req, res) => {
914
+ const inheritEnv = config.inheritEnv !== false;
915
+ // 复用与 process-manager / structured-session-manager 相同的组装逻辑,
916
+ // 这样 UI 上看到的就是真正会被注入到子进程的那一份环境。
917
+ const env = buildChildEnv(inheritEnv, {
918
+ // PTY runner 还会注入 WAND_* 用于 mode 协调,这里也展示出来便于排查。
919
+ WAND_MODE: "<runtime>",
920
+ WAND_AUTO_CONFIRM: "<runtime>",
921
+ WAND_AUTO_EDIT: "<runtime>",
922
+ });
923
+ const reveal = req.query.reveal === "1" || req.query.reveal === "true";
924
+ const SENSITIVE_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|AUTH|CREDENTIAL|COOKIE|SESSION)/i;
925
+ const entries = Object.keys(env)
926
+ .sort()
927
+ .map((name) => {
928
+ const raw = env[name] ?? "";
929
+ const sensitive = SENSITIVE_PATTERN.test(name);
930
+ const masked = sensitive && !reveal;
931
+ // WAND_* 占位值不算敏感,保持原样。
932
+ const isPlaceholder = raw.startsWith("<") && raw.endsWith(">");
933
+ return {
934
+ name,
935
+ value: masked && !isPlaceholder ? "***" : raw,
936
+ length: raw.length,
937
+ sensitive,
938
+ };
939
+ });
940
+ res.json({
941
+ inheritEnv,
942
+ total: entries.length,
943
+ reveal,
944
+ entries,
945
+ });
946
+ });
807
947
  app.get("/api/app-connect-code", requireAuth, (req, res) => {
808
948
  const dbPassword = storage.getPassword();
809
949
  const effectivePassword = dbPassword ?? config.password;
@@ -994,9 +1134,11 @@ export async function startServer(config, configPath) {
994
1134
  }
995
1135
  });
996
1136
  // ── File browsing ──
1137
+ const DIRECTORY_MAX_ITEMS = 200;
997
1138
  app.get("/api/directory", async (req, res) => {
998
1139
  const q = typeof req.query.q === "string" ? req.query.q : "";
999
1140
  const includeGitStatus = req.query.gitStatus === "true";
1141
+ const showHidden = req.query.showHidden === "true";
1000
1142
  const targetPath = path.resolve(q || config.defaultCwd);
1001
1143
  if (isBlockedFolderPath(targetPath)) {
1002
1144
  res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
@@ -1004,30 +1146,49 @@ export async function startServer(config, configPath) {
1004
1146
  }
1005
1147
  try {
1006
1148
  const entries = await readdir(targetPath, { withFileTypes: true });
1007
- let items = entries
1008
- .sort((a, b) => {
1149
+ const visible = showHidden ? entries : entries.filter((e) => !isHiddenEntry(e.name));
1150
+ const sorted = visible.sort((a, b) => {
1009
1151
  if (a.isDirectory() && !b.isDirectory())
1010
1152
  return -1;
1011
1153
  if (!a.isDirectory() && b.isDirectory())
1012
1154
  return 1;
1013
1155
  return a.name.localeCompare(b.name);
1014
- })
1015
- .slice(0, 100)
1016
- .map((entry) => ({
1017
- path: path.join(targetPath, entry.name),
1018
- name: entry.name,
1019
- type: entry.isDirectory() ? "dir" : "file",
1156
+ });
1157
+ const total = sorted.length;
1158
+ const truncated = total > DIRECTORY_MAX_ITEMS;
1159
+ const sliced = sorted.slice(0, DIRECTORY_MAX_ITEMS);
1160
+ // Fetch size/mtime in parallel; tolerate per-entry failures.
1161
+ let items = await Promise.all(sliced.map(async (entry) => {
1162
+ const fullPath = path.join(targetPath, entry.name);
1163
+ const isDir = entry.isDirectory();
1164
+ const base = {
1165
+ path: fullPath,
1166
+ name: entry.name,
1167
+ type: isDir ? "dir" : "file",
1168
+ };
1169
+ if (isDir)
1170
+ return base;
1171
+ try {
1172
+ const st = await lstat(fullPath);
1173
+ base.size = st.size;
1174
+ base.mtime = st.mtime.toISOString();
1175
+ }
1176
+ catch {
1177
+ // Permission errors etc — leave size/mtime undefined.
1178
+ }
1179
+ return base;
1020
1180
  }));
1021
1181
  if (includeGitStatus) {
1022
1182
  items = await enrichWithGitStatus(items, targetPath);
1023
1183
  }
1024
- res.json(items);
1184
+ const payload = { items, truncated, total };
1185
+ res.json(payload);
1025
1186
  }
1026
1187
  catch (error) {
1027
1188
  res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
1028
1189
  }
1029
1190
  });
1030
- const MAX_FILE_SIZE = 512 * 1024;
1191
+ const MAX_TEXT_PREVIEW_SIZE = 512 * 1024;
1031
1192
  app.get("/api/file-preview", async (req, res) => {
1032
1193
  const filePath = typeof req.query.path === "string" ? req.query.path : "";
1033
1194
  if (!filePath) {
@@ -1045,32 +1206,134 @@ export async function startServer(config, configPath) {
1045
1206
  res.status(400).json({ error: "Cannot preview a directory" });
1046
1207
  return;
1047
1208
  }
1048
- if (fileStat.size > MAX_FILE_SIZE) {
1049
- res.status(413).json({ error: "File too large", truncated: true, size: fileStat.size, maxSize: MAX_FILE_SIZE });
1209
+ const ext = path.extname(filePath).toLowerCase();
1210
+ const baseName = path.basename(filePath);
1211
+ const kind = classifyFile(ext, baseName);
1212
+ const mime = mimeForExt(ext);
1213
+ // Non-text kinds: respond with metadata so the client can pick a renderer.
1214
+ if (kind !== "text") {
1215
+ const payload = {
1216
+ kind,
1217
+ path: resolvedPath,
1218
+ name: baseName,
1219
+ ext,
1220
+ size: fileStat.size,
1221
+ mime,
1222
+ };
1223
+ res.json(payload);
1050
1224
  return;
1051
1225
  }
1052
- const ext = path.extname(filePath).toLowerCase();
1053
- const previewableExts = [
1054
- ".md", ".markdown", ".mdown", ".mkd", ".mkdn",
1055
- ".ts", ".tsx", ".js", ".jsx", ".json", ".html", ".css", ".scss", ".less",
1056
- ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
1057
- ".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh",
1058
- ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
1059
- ".xml", ".sql", ".graphql", ".proto",
1060
- ".dockerfile", ".gitignore", ".env", ".editorconfig",
1061
- ".mdx", ".vue", ".svelte",
1062
- ".txt", ".log", ".diff", ".patch",
1063
- ];
1064
- const isText = previewableExts.includes(ext) ||
1065
- ext === "" ||
1066
- [".gitignore", "dockerfile", ".env.local", ".env.development"].some((e) => filePath.toLowerCase().endsWith(e));
1067
- if (!isText) {
1068
- res.status(415).json({ error: "Unsupported file type", ext });
1226
+ // Text/code preview path — still subject to the 512 KB cap.
1227
+ if (fileStat.size > MAX_TEXT_PREVIEW_SIZE) {
1228
+ res.status(413).json({
1229
+ error: "文件太大,无法在线预览(限 512 KB)。",
1230
+ truncated: true,
1231
+ size: fileStat.size,
1232
+ maxSize: MAX_TEXT_PREVIEW_SIZE,
1233
+ });
1069
1234
  return;
1070
1235
  }
1071
1236
  const content = await readFile(resolvedPath, "utf-8");
1072
1237
  const lang = getLanguageFromExt(ext, filePath);
1073
- res.json({ path: resolvedPath, name: path.basename(filePath), ext, lang, content, size: fileStat.size });
1238
+ const payload = {
1239
+ kind: "text",
1240
+ path: resolvedPath,
1241
+ name: baseName,
1242
+ ext,
1243
+ size: fileStat.size,
1244
+ mime,
1245
+ lang,
1246
+ content,
1247
+ };
1248
+ res.json(payload);
1249
+ }
1250
+ catch (error) {
1251
+ res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
1252
+ }
1253
+ });
1254
+ // Streams the raw bytes of a file for inline media previews (image/PDF/video/audio)
1255
+ // and downloads. Honors HTTP Range so video/audio scrubbing works.
1256
+ const RAW_MAX_BYTES_BY_KIND = {
1257
+ text: 5 * 1024 * 1024,
1258
+ image: 50 * 1024 * 1024,
1259
+ pdf: 50 * 1024 * 1024,
1260
+ video: 200 * 1024 * 1024,
1261
+ audio: 200 * 1024 * 1024,
1262
+ binary: 50 * 1024 * 1024,
1263
+ };
1264
+ app.get("/api/file-raw", async (req, res) => {
1265
+ const filePath = typeof req.query.path === "string" ? req.query.path : "";
1266
+ const asDownload = req.query.download === "1" || req.query.download === "true";
1267
+ if (!filePath) {
1268
+ res.status(400).json({ error: "Missing path parameter" });
1269
+ return;
1270
+ }
1271
+ const resolvedPath = path.resolve(filePath);
1272
+ if (isBlockedFolderPath(resolvedPath)) {
1273
+ res.status(403).json({ error: "Access denied" });
1274
+ return;
1275
+ }
1276
+ try {
1277
+ const fileStat = await stat(resolvedPath);
1278
+ if (!fileStat.isFile()) {
1279
+ res.status(400).json({ error: "Not a regular file" });
1280
+ return;
1281
+ }
1282
+ const ext = path.extname(filePath).toLowerCase();
1283
+ const baseName = path.basename(filePath);
1284
+ const kind = classifyFile(ext, baseName);
1285
+ const cap = RAW_MAX_BYTES_BY_KIND[kind] ?? RAW_MAX_BYTES_BY_KIND.binary;
1286
+ if (fileStat.size > cap) {
1287
+ res.status(413).json({
1288
+ error: `文件超出可在线预览的上限(${Math.round(cap / 1024 / 1024)} MB)。`,
1289
+ size: fileStat.size,
1290
+ maxSize: cap,
1291
+ });
1292
+ return;
1293
+ }
1294
+ const mime = mimeForExt(ext);
1295
+ // SVG can be served with its proper type; binary fallback uses octet-stream.
1296
+ const contentType = kind === "binary" ? "application/octet-stream" : mime;
1297
+ // Encode the filename for Content-Disposition (RFC 5987).
1298
+ const encodedName = encodeURIComponent(baseName);
1299
+ const disposition = asDownload
1300
+ ? `attachment; filename*=UTF-8''${encodedName}`
1301
+ : `inline; filename*=UTF-8''${encodedName}`;
1302
+ const total = fileStat.size;
1303
+ const range = req.headers.range;
1304
+ // Safe to cache raw bytes briefly inside the user's browser.
1305
+ res.setHeader("Cache-Control", "private, max-age=60");
1306
+ res.setHeader("Content-Type", contentType);
1307
+ res.setHeader("Content-Disposition", disposition);
1308
+ res.setHeader("Accept-Ranges", "bytes");
1309
+ res.setHeader("X-Content-Type-Options", "nosniff");
1310
+ if (range && /^bytes=/.test(range)) {
1311
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range);
1312
+ if (!match) {
1313
+ res.status(416).setHeader("Content-Range", `bytes */${total}`).end();
1314
+ return;
1315
+ }
1316
+ const startStr = match[1];
1317
+ const endStr = match[2];
1318
+ let start = startStr === "" ? 0 : parseInt(startStr, 10);
1319
+ let end = endStr === "" ? total - 1 : parseInt(endStr, 10);
1320
+ if (Number.isNaN(start) || Number.isNaN(end) || start > end || start < 0 || end >= total) {
1321
+ res.status(416).setHeader("Content-Range", `bytes */${total}`).end();
1322
+ return;
1323
+ }
1324
+ const chunkSize = end - start + 1;
1325
+ res.status(206);
1326
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
1327
+ res.setHeader("Content-Length", String(chunkSize));
1328
+ const stream = createReadStream(resolvedPath, { start, end });
1329
+ stream.on("error", () => res.destroy());
1330
+ stream.pipe(res);
1331
+ return;
1332
+ }
1333
+ res.setHeader("Content-Length", String(total));
1334
+ const stream = createReadStream(resolvedPath);
1335
+ stream.on("error", () => res.destroy());
1336
+ stream.pipe(res);
1074
1337
  }
1075
1338
  catch (error) {
1076
1339
  res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
@@ -18,6 +18,12 @@ export declare class StructuredSessionManager {
18
18
  private readonly sessions;
19
19
  private readonly pendingChildren;
20
20
  private readonly pendingSdkAbort;
21
+ /**
22
+ * Active SDK Query handle per session, kept around so we can call
23
+ * `query.interrupt()` for a graceful stop instead of aborting via signal.
24
+ * Only populated while an SDK call is in flight.
25
+ */
26
+ private readonly pendingSdkQueries;
21
27
  private readonly interruptedWith;
22
28
  /** Last wall-clock time (ms) we did a full saveSession for a streaming session. */
23
29
  private readonly lastStreamSaveAt;
@@ -70,7 +76,6 @@ export declare class StructuredSessionManager {
70
76
  private emit;
71
77
  private resolvePermission;
72
78
  private incrementApprovalStats;
73
- private buildPermissionArgs;
74
79
  private buildCodexArgs;
75
80
  private runCodexStreaming;
76
81
  /**
@@ -96,7 +101,6 @@ export declare class StructuredSessionManager {
96
101
  * SDKAssistantMessage with the authoritative complete content.
97
102
  */
98
103
  private runClaudeSdkStreaming;
99
- private _runClaudeSdkStreamingAsync;
100
104
  private extractAssistantMessage;
101
105
  private compactContentBlocks;
102
106
  private normalizeToolInput;