@co0ontty/wand 4.60.0 → 4.61.0-beta.g14d0be2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "c0ff8ccee9e2b9591db65e5f14d45626e7a07fa9",
3
- "builtAt": "2026-09-12T12:31:51.181Z",
4
- "version": "4.60.0",
5
- "channel": "stable"
2
+ "commit": "14d0be2916773382d9b9c692d21ec271479d1fe4",
3
+ "builtAt": "2026-09-14T06:27:55.916Z",
4
+ "version": "4.61.0-beta.g14d0be2",
5
+ "channel": "beta"
6
6
  }
@@ -152,14 +152,17 @@ export class ClaudePtyBridge extends EventEmitter {
152
152
  return;
153
153
  }
154
154
  const cleanInput = input.replace(/[\r\n]+$/, "").trim();
155
+ const now = new Date().toISOString();
155
156
  // Add user message
156
157
  this.messages.push({
157
158
  role: "user",
159
+ createdAt: now,
158
160
  content: [{ type: "text", text: cleanInput }],
159
161
  });
160
162
  // Add assistant placeholder for streaming
161
163
  this.messages.push({
162
164
  role: "assistant",
165
+ createdAt: now,
163
166
  content: [],
164
167
  });
165
168
  // Initialize chat state
@@ -794,6 +797,7 @@ export class ClaudePtyBridge extends EventEmitter {
794
797
  // Emit turn completed
795
798
  const lastTurn = this.messages[this.messages.length - 1];
796
799
  if (lastTurn?.role === "assistant" && lastTurn.content.length > 0) {
800
+ lastTurn.completedAt = new Date().toISOString();
797
801
  this.emitEvent({
798
802
  type: "chat.turn",
799
803
  sessionId: this.sessionId,
@@ -16,6 +16,8 @@ export interface Mission {
16
16
  status: MissionStatus;
17
17
  /** 可选关联的任务(workspace task):派发的 attempt 会话绑定该任务。 */
18
18
  taskId?: string | null;
19
+ /** 可选里程碑:与任务共用同一份全局里程碑列表。 */
20
+ milestoneId?: string | null;
19
21
  worktree: MissionWorktreeOptions;
20
22
  createdAt: string;
21
23
  updatedAt: string;
@@ -82,6 +84,8 @@ export interface CreateMissionInput {
82
84
  providers: SessionProvider[];
83
85
  /** 关联到指定 workspace task;派发会话将绑定 workspaceTaskId。 */
84
86
  taskId?: string;
87
+ /** 里程碑 id;null / 缺省表示不归入里程碑。 */
88
+ milestoneId?: string | null;
85
89
  baseRef?: string;
86
90
  sharedDirectories?: string[];
87
91
  copyPaths?: string[];
package/dist/missions.js CHANGED
@@ -147,6 +147,9 @@ export class Missions {
147
147
  throw new Error(`请选择 1-${MAX_ATTEMPTS} 个有效 provider。`);
148
148
  }
149
149
  const createdAt = nowIso();
150
+ const milestoneId = input.milestoneId?.trim() || null;
151
+ if (milestoneId && !this.storage.getWandMilestone(milestoneId))
152
+ throw new Error("未找到该里程碑。");
150
153
  const mission = {
151
154
  id: randomUUID(),
152
155
  title: input.title?.trim().slice(0, 120) || firstPromptLine(prompt),
@@ -154,6 +157,7 @@ export class Missions {
154
157
  cwd,
155
158
  status: "dispatching",
156
159
  taskId: input.taskId?.trim() || null,
160
+ milestoneId,
157
161
  worktree: {
158
162
  baseRef: input.baseRef?.trim() || undefined,
159
163
  sharedDirectories: normalizeStringList(input.sharedDirectories, "sharedDirectories"),
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 公开 origin 推导、App 连接码与结构化聊天人设。
3
+ *
4
+ * 这些逻辑原来内联在 `server.ts` 里,占了组合根很大一块,而且和路由注册混在
5
+ * 一起。它们只依赖请求头 / 配置 / 文件系统,跟 Express 组合根无关,因此单独
6
+ * 成模块;`server.ts` 只负责把结果接到 `/api/login`、`/api/config` 等路由上。
7
+ */
8
+ import type { Request } from "express";
9
+ import type { StructuredChatPersonaConfig, WandConfig } from "./types.js";
10
+ export declare function generateAppToken(password: string, secret: string): string;
11
+ export declare function verifyAppToken(token: string, password: string, secret: string): boolean;
12
+ export declare function encodeConnectCode(url: string, token: string): string;
13
+ export declare function isBrowserExtensionOrigin(value: string | undefined): boolean;
14
+ /**
15
+ * App 连接码用于另一台设备。设置页若从 localhost 打开,直接编码浏览器 origin
16
+ * 会让客户端连接它自己;监听 0.0.0.0 时自动换成本机优先 LAN IPv4。
17
+ */
18
+ export declare function resolveAppConnectOrigin(origin: string, config: WandConfig): string;
19
+ /**
20
+ * 反代场景:配置了 publicOrigin 就优先用它。TLS 在 L4 反代终止时 node 只看到
21
+ * 明文 HTTP,既没有 X-Forwarded-Proto 也没有正确的 scheme,猜不出来。
22
+ */
23
+ export declare function resolveRequestServerUrl(req: Request, config: WandConfig, useHttps: boolean): string;
24
+ /** 设置页里的连接码:优先显式 origin,其次配置 publicOrigin,最后回落到请求头。 */
25
+ export declare function resolveAppConnectCode(req: Request, config: WandConfig, useHttps: boolean, password: string): {
26
+ code: string;
27
+ url: string;
28
+ };
29
+ export declare function appTokenLoginPayload(req: Request, config: WandConfig, useHttps: boolean, password: string): {
30
+ appToken: string;
31
+ serverUrl: string;
32
+ };
33
+ export declare function resolveStructuredChatPersona(config: WandConfig): StructuredChatPersonaConfig | undefined;
34
+ /** 本地头像文件的绝对路径;外链和未配置返回 null。 */
35
+ export declare function resolveStructuredChatAvatarPath(configPath: string, config: WandConfig, role: "user" | "assistant"): string | null;
36
+ /**
37
+ * `/api/config` 用的人设 payload。本地头像存在时替换成 `/api/structured-chat-avatar/:role`,
38
+ * 不把磁盘路径暴露给前端;文件不存在则整条 avatar 字段省略。
39
+ */
40
+ export declare function buildStructuredChatPersonaPayload(configPath: string, config: WandConfig): Promise<StructuredChatPersonaConfig | undefined>;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * 公开 origin 推导、App 连接码与结构化聊天人设。
3
+ *
4
+ * 这些逻辑原来内联在 `server.ts` 里,占了组合根很大一块,而且和路由注册混在
5
+ * 一起。它们只依赖请求头 / 配置 / 文件系统,跟 Express 组合根无关,因此单独
6
+ * 成模块;`server.ts` 只负责把结果接到 `/api/login`、`/api/config` 等路由上。
7
+ */
8
+ import crypto from "node:crypto";
9
+ import { stat } from "node:fs/promises";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { resolveConfigDir } from "./config.js";
13
+ import { normalizePublicOrigin } from "./ios-ota.js";
14
+ import { firstQueryStringValue, firstHeaderValue, firstHeaderListValue } from "./server-request.js";
15
+ // ── App 连接码 ──
16
+ //
17
+ // 连接码里的 token 是密码派生的(HMAC(password, appSecret)),兼容既有连接码;
18
+ // 因此它等价于密码,绝不能写进日志或回显给非登录方。
19
+ export function generateAppToken(password, secret) {
20
+ return crypto.createHmac("sha256", secret).update(password).digest("hex");
21
+ }
22
+ export function verifyAppToken(token, password, secret) {
23
+ const expected = generateAppToken(password, secret);
24
+ return crypto.timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex"));
25
+ }
26
+ export function encodeConnectCode(url, token) {
27
+ return Buffer.from(`${url}#${token}`).toString("base64");
28
+ }
29
+ // ── 转发头解析 ──
30
+ function unquoteHeaderValue(value) {
31
+ const trimmed = value.trim();
32
+ if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
33
+ return trimmed.slice(1, -1);
34
+ }
35
+ return trimmed;
36
+ }
37
+ function getForwardedParam(req, key) {
38
+ const forwarded = firstHeaderListValue(req.headers.forwarded);
39
+ if (!forwarded)
40
+ return undefined;
41
+ const targetKey = key.toLowerCase();
42
+ for (const part of forwarded.split(";")) {
43
+ const eqIdx = part.indexOf("=");
44
+ if (eqIdx < 1)
45
+ continue;
46
+ const partKey = part.slice(0, eqIdx).trim().toLowerCase();
47
+ if (partKey !== targetKey)
48
+ continue;
49
+ return unquoteHeaderValue(part.slice(eqIdx + 1));
50
+ }
51
+ return undefined;
52
+ }
53
+ function normalizePublicProtocol(value) {
54
+ const proto = value?.trim().toLowerCase();
55
+ if (proto === "http" || proto === "https")
56
+ return proto;
57
+ return undefined;
58
+ }
59
+ function getPublicRequestProtocol(req, fallback) {
60
+ return (normalizePublicProtocol(firstHeaderListValue(req.headers["x-forwarded-proto"]))
61
+ ?? normalizePublicProtocol(getForwardedParam(req, "proto"))
62
+ ?? (firstHeaderListValue(req.headers["x-forwarded-ssl"])?.toLowerCase() === "on" ? "https" : undefined)
63
+ ?? (firstHeaderListValue(req.headers["x-forwarded-scheme"])?.toLowerCase() === "https" ? "https" : undefined)
64
+ ?? fallback);
65
+ }
66
+ function getPublicRequestHost(req, config) {
67
+ return (firstHeaderListValue(req.headers["x-forwarded-host"])
68
+ ?? getForwardedParam(req, "host")
69
+ ?? req.headers.host
70
+ ?? `${config.host}:${config.port}`);
71
+ }
72
+ // ── 浏览器扩展 origin ──
73
+ export function isBrowserExtensionOrigin(value) {
74
+ if (!value)
75
+ return false;
76
+ return /^chrome-extension:\/\/[a-z]{32}$/i.test(value)
77
+ || /^moz-extension:\/\/[0-9a-f-]+$/i.test(value)
78
+ || /^safari-web-extension:\/\//i.test(value);
79
+ }
80
+ // ── LAN 地址选择 ──
81
+ function isPrivateIpv4(address) {
82
+ if (address.startsWith("10.") || address.startsWith("192.168."))
83
+ return true;
84
+ const match = address.match(/^172\.(\d+)\./);
85
+ return match ? Number(match[1]) >= 16 && Number(match[1]) <= 31 : false;
86
+ }
87
+ function preferredLanIpv4() {
88
+ const candidates = [];
89
+ for (const [name, entries] of Object.entries(os.networkInterfaces())) {
90
+ for (const entry of entries ?? []) {
91
+ const family = entry.family;
92
+ if (entry.internal || (family !== "IPv4" && family !== 4))
93
+ continue;
94
+ let score = isPrivateIpv4(entry.address) ? 10 : 0;
95
+ if (/^(en|eth|wlan)\d+$/i.test(name))
96
+ score += 10;
97
+ candidates.push({ address: entry.address, score });
98
+ }
99
+ }
100
+ candidates.sort((a, b) => b.score - a.score || a.address.localeCompare(b.address));
101
+ return candidates[0]?.address;
102
+ }
103
+ /**
104
+ * App 连接码用于另一台设备。设置页若从 localhost 打开,直接编码浏览器 origin
105
+ * 会让客户端连接它自己;监听 0.0.0.0 时自动换成本机优先 LAN IPv4。
106
+ */
107
+ export function resolveAppConnectOrigin(origin, config) {
108
+ if (config.host !== "0.0.0.0")
109
+ return origin;
110
+ try {
111
+ const parsed = new URL(origin);
112
+ const hostname = parsed.hostname.toLowerCase();
113
+ const isLocalOnly = hostname === "localhost"
114
+ || hostname === "127.0.0.1"
115
+ || hostname === "[::1]"
116
+ || hostname === "::1"
117
+ || hostname === "0.0.0.0"
118
+ || hostname === "[::]";
119
+ if (!isLocalOnly)
120
+ return parsed.origin;
121
+ const lanIp = preferredLanIpv4();
122
+ if (!lanIp)
123
+ return parsed.origin;
124
+ parsed.hostname = lanIp;
125
+ return parsed.origin;
126
+ }
127
+ catch {
128
+ return origin;
129
+ }
130
+ }
131
+ /**
132
+ * 反代场景:配置了 publicOrigin 就优先用它。TLS 在 L4 反代终止时 node 只看到
133
+ * 明文 HTTP,既没有 X-Forwarded-Proto 也没有正确的 scheme,猜不出来。
134
+ */
135
+ export function resolveRequestServerUrl(req, config, useHttps) {
136
+ const configuredOrigin = normalizePublicOrigin(config.publicOrigin);
137
+ if (configuredOrigin)
138
+ return resolveAppConnectOrigin(configuredOrigin, config);
139
+ const requestProtocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
140
+ const requestHost = getPublicRequestHost(req, config);
141
+ const originHeader = firstHeaderValue(req.headers.origin);
142
+ const browserOrigin = isBrowserExtensionOrigin(originHeader)
143
+ ? undefined
144
+ : normalizePublicOrigin(originHeader) ?? undefined;
145
+ return resolveAppConnectOrigin(browserOrigin ?? `${requestProtocol}://${requestHost}`, config);
146
+ }
147
+ /** 设置页里的连接码:优先显式 origin,其次配置 publicOrigin,最后回落到请求头。 */
148
+ export function resolveAppConnectCode(req, config, useHttps, password) {
149
+ const requestProtocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
150
+ const requestHost = getPublicRequestHost(req, config);
151
+ const browserOrigin = normalizePublicOrigin(firstQueryStringValue(req.query.origin));
152
+ const serverUrl = resolveAppConnectOrigin(normalizePublicOrigin(config.publicOrigin) ?? browserOrigin ?? `${requestProtocol}://${requestHost}`, config);
153
+ const token = generateAppToken(password, config.appSecret ?? "");
154
+ return { code: encodeConnectCode(serverUrl, token), url: serverUrl };
155
+ }
156
+ export function appTokenLoginPayload(req, config, useHttps, password) {
157
+ return {
158
+ appToken: generateAppToken(password, config.appSecret ?? ""),
159
+ serverUrl: resolveRequestServerUrl(req, config, useHttps),
160
+ };
161
+ }
162
+ // ── 结构化聊天人设 ──
163
+ function isExternalAvatarSource(value) {
164
+ return /^(https?:|data:)/i.test(value);
165
+ }
166
+ function normalizePersonaName(value) {
167
+ if (typeof value !== "string")
168
+ return undefined;
169
+ const trimmed = value.trim();
170
+ return trimmed || undefined;
171
+ }
172
+ function normalizePersonaAvatar(value) {
173
+ if (typeof value !== "string")
174
+ return undefined;
175
+ const trimmed = value.trim();
176
+ return trimmed || undefined;
177
+ }
178
+ export function resolveStructuredChatPersona(config) {
179
+ const persona = config.structuredChatPersona;
180
+ if (!persona)
181
+ return undefined;
182
+ const userName = normalizePersonaName(persona.user?.name);
183
+ const userAvatar = normalizePersonaAvatar(persona.user?.avatar);
184
+ const assistantName = normalizePersonaName(persona.assistant?.name);
185
+ const assistantAvatar = normalizePersonaAvatar(persona.assistant?.avatar);
186
+ if (!userName && !userAvatar && !assistantName && !assistantAvatar) {
187
+ return undefined;
188
+ }
189
+ return {
190
+ user: userName || userAvatar ? { name: userName, avatar: userAvatar } : undefined,
191
+ assistant: assistantName || assistantAvatar ? { name: assistantName, avatar: assistantAvatar } : undefined,
192
+ };
193
+ }
194
+ /** 本地头像文件的绝对路径;外链和未配置返回 null。 */
195
+ export function resolveStructuredChatAvatarPath(configPath, config, role) {
196
+ const avatar = role === "user"
197
+ ? config.structuredChatPersona?.user?.avatar
198
+ : config.structuredChatPersona?.assistant?.avatar;
199
+ if (!avatar || isExternalAvatarSource(avatar)) {
200
+ return null;
201
+ }
202
+ const configDir = resolveConfigDir(configPath);
203
+ return path.isAbsolute(avatar) ? avatar : path.resolve(configDir, avatar);
204
+ }
205
+ /**
206
+ * `/api/config` 用的人设 payload。本地头像存在时替换成 `/api/structured-chat-avatar/:role`,
207
+ * 不把磁盘路径暴露给前端;文件不存在则整条 avatar 字段省略。
208
+ */
209
+ export async function buildStructuredChatPersonaPayload(configPath, config) {
210
+ const persona = resolveStructuredChatPersona(config);
211
+ if (!persona)
212
+ return undefined;
213
+ const buildRole = async (role) => {
214
+ const roleConfig = role === "user" ? persona.user : persona.assistant;
215
+ if (!roleConfig)
216
+ return undefined;
217
+ let avatar = roleConfig.avatar;
218
+ if (avatar && !isExternalAvatarSource(avatar)) {
219
+ const resolvedPath = resolveStructuredChatAvatarPath(configPath, config, role);
220
+ if (!resolvedPath) {
221
+ avatar = undefined;
222
+ }
223
+ else {
224
+ try {
225
+ const fileStat = await stat(resolvedPath);
226
+ avatar = fileStat.isFile() ? `/api/structured-chat-avatar/${role}` : undefined;
227
+ }
228
+ catch {
229
+ avatar = undefined;
230
+ }
231
+ }
232
+ }
233
+ if (!roleConfig.name && !avatar)
234
+ return undefined;
235
+ return { name: roleConfig.name, avatar };
236
+ };
237
+ const [user, assistant] = await Promise.all([buildRole("user"), buildRole("assistant")]);
238
+ if (!user && !assistant)
239
+ return undefined;
240
+ return { user, assistant };
241
+ }
@@ -7,6 +7,7 @@ import process from "node:process";
7
7
  import { promisify } from "node:util";
8
8
  import { getErrorMessage } from "./error-utils.js";
9
9
  import { asyncRoute } from "./express-async.js";
10
+ import { sendRouteError } from "./server-request.js";
10
11
  import { isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
11
12
  import { parseBoundedInteger } from "./request-limits.js";
12
13
  const execAsync = promisify(exec);
@@ -48,7 +49,7 @@ export function registerFileRoutes(app, deps) {
48
49
  res.json(await listPathSuggestions(query, defaultCwd));
49
50
  }
50
51
  catch (error) {
51
- res.status(400).json({ error: getErrorMessage(error, "无法加载路径建议。") });
52
+ sendRouteError(res, error, "无法加载路径建议。");
52
53
  }
53
54
  }));
54
55
  app.get("/api/directory", asyncRoute(async (req, res) => {
@@ -96,7 +97,7 @@ export function registerFileRoutes(app, deps) {
96
97
  res.json(payload);
97
98
  }
98
99
  catch (error) {
99
- res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
100
+ sendRouteError(res, error, "无法读取目录。可能原因:路径不存在或权限不足。");
100
101
  }
101
102
  }));
102
103
  app.get("/api/file-preview", asyncRoute(async (req, res) => {
@@ -155,7 +156,7 @@ export function registerFileRoutes(app, deps) {
155
156
  res.json({ ...payload, mtime: fileStat.mtime.toISOString() });
156
157
  }
157
158
  catch (error) {
158
- res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
159
+ sendRouteError(res, error, "Failed to read file");
159
160
  }
160
161
  }));
161
162
  app.post("/api/file-write", asyncRoute(async (req, res) => {
@@ -227,7 +228,7 @@ export function registerFileRoutes(app, deps) {
227
228
  });
228
229
  }
229
230
  catch (error) {
230
- res.status(400).json({ error: getErrorMessage(error, "保存文件失败。") });
231
+ sendRouteError(res, error, "保存文件失败。");
231
232
  }
232
233
  }));
233
234
  app.post("/api/file-create", asyncRoute(async (req, res) => {
@@ -253,7 +254,7 @@ export function registerFileRoutes(app, deps) {
253
254
  res.status(409).json({ error: "文件已存在。", path: resolvedPath });
254
255
  return;
255
256
  }
256
- res.status(400).json({ error: getErrorMessage(error, "创建文件失败。") });
257
+ sendRouteError(res, error, "创建文件失败。");
257
258
  }
258
259
  }));
259
260
  app.post("/api/dir-create", asyncRoute(async (req, res) => {
@@ -274,7 +275,7 @@ export function registerFileRoutes(app, deps) {
274
275
  res.json({ ok: true, path: resolvedPath, mtime: fileStat.mtime.toISOString() });
275
276
  }
276
277
  catch (error) {
277
- res.status(400).json({ error: getErrorMessage(error, "创建文件夹失败。") });
278
+ sendRouteError(res, error, "创建文件夹失败。");
278
279
  }
279
280
  }));
280
281
  app.post("/api/file-rename", asyncRoute(async (req, res) => {
@@ -319,7 +320,7 @@ export function registerFileRoutes(app, deps) {
319
320
  res.status(404).json({ error: "源路径不存在。" });
320
321
  return;
321
322
  }
322
- res.status(400).json({ error: getErrorMessage(error, "重命名/移动失败。") });
323
+ sendRouteError(res, error, "重命名/移动失败。");
323
324
  }
324
325
  }));
325
326
  app.post("/api/file-delete", asyncRoute(async (req, res) => {
@@ -345,7 +346,7 @@ export function registerFileRoutes(app, deps) {
345
346
  res.status(404).json({ error: "路径不存在。" });
346
347
  return;
347
348
  }
348
- res.status(400).json({ error: getErrorMessage(error, "删除失败。") });
349
+ sendRouteError(res, error, "删除失败。");
349
350
  }
350
351
  }));
351
352
  app.get("/api/file-raw", asyncRoute(async (req, res) => {
@@ -396,7 +397,7 @@ export function registerFileRoutes(app, deps) {
396
397
  });
397
398
  }
398
399
  catch (error) {
399
- res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
400
+ sendRouteError(res, error, "Failed to read file");
400
401
  }
401
402
  }));
402
403
  app.get("/api/folders", asyncRoute(async (req, res) => {
@@ -559,7 +560,7 @@ export function registerFileRoutes(app, deps) {
559
560
  res.json({ results: results.slice(0, maxResults), query, cwd: resolvedCwd });
560
561
  }
561
562
  catch (error) {
562
- res.status(400).json({ error: getErrorMessage(error, "搜索失败。可能原因:路径不存在或权限不足。") });
563
+ sendRouteError(res, error, "搜索失败。可能原因:路径不存在或权限不足。");
563
564
  }
564
565
  }));
565
566
  }
@@ -1,13 +1,6 @@
1
1
  import { asyncRoute } from "./express-async.js";
2
2
  import { connectGithub, getGithubConnectorStatus, githubRequest, } from "./github-connector.js";
3
- import { getErrorMessage } from "./error-utils.js";
4
- function stringQuery(value, fallback = "") {
5
- return typeof value === "string" ? value.trim() : fallback;
6
- }
7
- function integerQuery(value, fallback, min, max) {
8
- const parsed = Number(stringQuery(value));
9
- return Number.isInteger(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
10
- }
3
+ import { bodyObject, integerQuery, requiredString, sendRouteError, stringQuery } from "./server-request.js";
11
4
  function githubListQuery(reqQuery, defaults = {}) {
12
5
  const query = new URLSearchParams();
13
6
  if (defaults.state)
@@ -38,27 +31,16 @@ function issueNumber(req) {
38
31
  throw new Error("Issue 或 PR 编号无效。");
39
32
  return number;
40
33
  }
41
- function validateObjectBody(body) {
42
- if (!body || typeof body !== "object" || Array.isArray(body))
43
- throw new Error("请求体必须是对象。");
44
- return body;
45
- }
46
- function requiredString(body, key) {
47
- const value = typeof body[key] === "string" ? body[key].trim() : "";
48
- if (!value)
49
- throw new Error(`${key} 不能为空。`);
50
- return value;
51
- }
52
- function publicError(error) {
34
+ /** 把 connector 抛出的 HTTP 错误映射成对外的状态码;非法状态一律降为 500。 */
35
+ function githubErrorStatus(error) {
53
36
  const status = error instanceof Error && "status" in error && typeof error.status === "number"
54
37
  ? error.status
55
38
  : 400;
56
- return { status: status >= 400 && status < 600 ? status : 500, message: getErrorMessage(error, "GitHub 操作失败。") };
39
+ return status >= 400 && status < 600 ? status : 500;
57
40
  }
58
41
  function sendGithubError(res, error, remapAuth = false) {
59
- const result = publicError(error);
60
- const status = remapAuth && (result.status === 401 || result.status === 403) ? 400 : result.status;
61
- res.status(status).json({ error: result.message });
42
+ const status = githubErrorStatus(error);
43
+ sendRouteError(res, error, "GitHub 操作失败。", remapAuth && (status === 401 || status === 403) ? 400 : status);
62
44
  }
63
45
  async function githubJson(storage, pathname, init = {}) {
64
46
  return (await githubRequest(storage, pathname, init)).data;
@@ -71,7 +53,7 @@ export function registerGithubRoutes(app, deps) {
71
53
  });
72
54
  app.post("/api/connectors/github", requireAdmin, asyncRoute(async (req, res) => {
73
55
  try {
74
- const body = validateObjectBody(req.body);
56
+ const body = bodyObject(req.body);
75
57
  const token = requiredString(body, "token");
76
58
  const apiUrl = typeof body.apiUrl === "string" ? body.apiUrl : undefined;
77
59
  res.set("Cache-Control", "no-store");
@@ -98,7 +80,7 @@ export function registerGithubRoutes(app, deps) {
98
80
  app.post("/api/github/repos/:owner/:repo/issues/:number/bindings", requireAdmin, (req, res) => {
99
81
  try {
100
82
  const number = issueNumber(req);
101
- const body = validateObjectBody(req.body);
83
+ const body = bodyObject(req.body);
102
84
  const sessionId = requiredString(body, "sessionId");
103
85
  if (sessions && !sessions.get(sessionId))
104
86
  throw new Error("未找到该会话。");
@@ -168,7 +150,7 @@ export function registerGithubRoutes(app, deps) {
168
150
  }));
169
151
  app.post("/api/github/repos/:owner/:repo/issues", requireAdmin, asyncRoute(async (req, res) => {
170
152
  try {
171
- const body = validateObjectBody(req.body);
153
+ const body = bodyObject(req.body);
172
154
  const payload = { title: requiredString(body, "title") };
173
155
  for (const key of ["body", "labels", "assignees", "milestone"]) {
174
156
  if (body[key] !== undefined)
@@ -182,7 +164,7 @@ export function registerGithubRoutes(app, deps) {
182
164
  }));
183
165
  app.patch("/api/github/repos/:owner/:repo/issues/:number", requireAdmin, asyncRoute(async (req, res) => {
184
166
  try {
185
- const body = validateObjectBody(req.body);
167
+ const body = bodyObject(req.body);
186
168
  const payload = {};
187
169
  for (const key of ["title", "body", "state", "state_reason", "labels", "assignees", "milestone"]) {
188
170
  if (body[key] !== undefined)
@@ -198,7 +180,7 @@ export function registerGithubRoutes(app, deps) {
198
180
  }));
199
181
  app.post("/api/github/repos/:owner/:repo/issues/:number/comments", requireAdmin, asyncRoute(async (req, res) => {
200
182
  try {
201
- const body = validateObjectBody(req.body);
183
+ const body = bodyObject(req.body);
202
184
  const payload = { body: requiredString(body, "body") };
203
185
  res.status(201).json(await githubJson(storage, `${repoPath(req)}/issues/${issueNumber(req)}/comments`, { method: "POST", body: JSON.stringify(payload) }));
204
186
  }
@@ -217,7 +199,7 @@ export function registerGithubRoutes(app, deps) {
217
199
  }));
218
200
  app.post("/api/github/repos/:owner/:repo/pulls", requireAdmin, asyncRoute(async (req, res) => {
219
201
  try {
220
- const body = validateObjectBody(req.body);
202
+ const body = bodyObject(req.body);
221
203
  const payload = {
222
204
  title: requiredString(body, "title"),
223
205
  head: requiredString(body, "head"),
@@ -1,9 +1,11 @@
1
1
  import { asyncRoute } from "./express-async.js";
2
2
  import { getErrorMessage } from "./error-utils.js";
3
+ import { sendRouteError } from "./server-request.js";
4
+ // Missions 的错误文案在 service 层就已确定,这里只把“不存在”类错误提升为 404。
5
+ const MISSION_NOT_FOUND = /^(任务不存在|任务 attempt 不存在)|没有关联会话|当前不可用/;
3
6
  function sendMissionError(res, error) {
4
7
  const message = getErrorMessage(error, "任务操作失败。");
5
- const missing = /^(任务不存在|任务 attempt 不存在)|没有关联会话|当前不可用/.test(message);
6
- res.status(missing ? 404 : 400).json({ error: message });
8
+ sendRouteError(res, error, "任务操作失败。", MISSION_NOT_FOUND.test(message) ? 404 : 400);
7
9
  }
8
10
  function commentIdsFrom(body) {
9
11
  if (!body || typeof body !== "object" || !Array.isArray(body.commentIds))
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 路由层共享的请求解析小工具。
3
+ *
4
+ * 这些函数原来在 `server-task-routes.ts`、`server-github-routes.ts`、
5
+ * `server.ts` 里各写了一份;集中到一处后,行为(尤其是“空字符串视为缺省”)
6
+ * 只有一个定义,路由文件只保留业务语义。
7
+ */
8
+ /** 取字符串并 trim;非字符串一律当缺省处理。 */
9
+ export declare function text(value: unknown, fallback?: string): string;
10
+ /** Express query 值可能是数组,取第一个字符串。 */
11
+ export declare function firstQueryStringValue(value: unknown): string | undefined;
12
+ /** 请求头可能是数组(重复头),取第一个值。 */
13
+ export declare function firstHeaderValue(value: string | string[] | undefined): string | undefined;
14
+ /** 逗号分隔的请求头(如 Forwarded / X-Forwarded-*)取第一段。 */
15
+ export declare function firstHeaderListValue(value: string | string[] | undefined): string | undefined;
16
+ /** 查询参数版 `text()`:显式传 fallback 时返回 fallback 而不是空串。 */
17
+ export declare function stringQuery(value: unknown, fallback?: string): string;
18
+ /** 解析范围内整数,越界或非整数回落到 fallback。 */
19
+ export declare function integerQuery(value: unknown, fallback: number, min: number, max: number): number;
20
+ /** 要求请求体是普通对象;数组和 null 都拒绝。 */
21
+ export declare function bodyObject(value: unknown): Record<string, unknown>;
22
+ /** 从对象里取必填的非空字符串字段。 */
23
+ export declare function requiredString(body: Record<string, unknown>, key: string): string;
24
+ /**
25
+ * 路由 catch 块的标准回包:业务异常用 fallback 文案,默认 400。
26
+ *
27
+ * `getErrorMessage` 会把 Error.message 原样透出,因此抛出的错误必须是面向用户的;
28
+ * 内部错误请用 getErrorMessage(error, fallback) 已包好的 fallback。
29
+ */
30
+ export declare function sendRouteError(res: {
31
+ status(code: number): {
32
+ json(body: unknown): unknown;
33
+ };
34
+ }, error: unknown, fallback: string, status?: number): void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 路由层共享的请求解析小工具。
3
+ *
4
+ * 这些函数原来在 `server-task-routes.ts`、`server-github-routes.ts`、
5
+ * `server.ts` 里各写了一份;集中到一处后,行为(尤其是“空字符串视为缺省”)
6
+ * 只有一个定义,路由文件只保留业务语义。
7
+ */
8
+ import { getErrorMessage } from "./error-utils.js";
9
+ /** 取字符串并 trim;非字符串一律当缺省处理。 */
10
+ export function text(value, fallback = "") {
11
+ return typeof value === "string" ? value.trim() : fallback;
12
+ }
13
+ /** Express query 值可能是数组,取第一个字符串。 */
14
+ export function firstQueryStringValue(value) {
15
+ if (typeof value === "string")
16
+ return value;
17
+ if (Array.isArray(value))
18
+ return firstQueryStringValue(value[0]);
19
+ return undefined;
20
+ }
21
+ /** 请求头可能是数组(重复头),取第一个值。 */
22
+ export function firstHeaderValue(value) {
23
+ if (Array.isArray(value))
24
+ return value[0];
25
+ return value;
26
+ }
27
+ /** 逗号分隔的请求头(如 Forwarded / X-Forwarded-*)取第一段。 */
28
+ export function firstHeaderListValue(value) {
29
+ return firstHeaderValue(value)?.split(",")[0]?.trim();
30
+ }
31
+ /** 查询参数版 `text()`:显式传 fallback 时返回 fallback 而不是空串。 */
32
+ export function stringQuery(value, fallback = "") {
33
+ return typeof value === "string" ? value.trim() : fallback;
34
+ }
35
+ /** 解析范围内整数,越界或非整数回落到 fallback。 */
36
+ export function integerQuery(value, fallback, min, max) {
37
+ const parsed = Number(stringQuery(value));
38
+ return Number.isInteger(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
39
+ }
40
+ /** 要求请求体是普通对象;数组和 null 都拒绝。 */
41
+ export function bodyObject(value) {
42
+ if (!value || typeof value !== "object" || Array.isArray(value))
43
+ throw new Error("请求体必须是对象。");
44
+ return value;
45
+ }
46
+ /** 从对象里取必填的非空字符串字段。 */
47
+ export function requiredString(body, key) {
48
+ const value = text(body[key]);
49
+ if (!value)
50
+ throw new Error(`${key} 不能为空。`);
51
+ return value;
52
+ }
53
+ /**
54
+ * 路由 catch 块的标准回包:业务异常用 fallback 文案,默认 400。
55
+ *
56
+ * `getErrorMessage` 会把 Error.message 原样透出,因此抛出的错误必须是面向用户的;
57
+ * 内部错误请用 getErrorMessage(error, fallback) 已包好的 fallback。
58
+ */
59
+ export function sendRouteError(res, error, fallback, status = 400) {
60
+ res.status(status).json({ error: getErrorMessage(error, fallback) });
61
+ }