c0de-agent 1.13.1 → 1.14.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 (155) hide show
  1. package/dist/cli/commands/serve.js +5 -1
  2. package/dist/cli/deps.d.ts +2 -4
  3. package/dist/cli/deps.js +22 -36
  4. package/dist/cli/modes/print.js +27 -17
  5. package/dist/core/agent.js +59 -36
  6. package/dist/core/agents/parallel.js +4 -2
  7. package/dist/core/config-path.d.ts +3 -1
  8. package/dist/core/config-path.js +33 -3
  9. package/dist/core/config.js +81 -0
  10. package/dist/core/context.js +6 -3
  11. package/dist/core/loop/persist.js +4 -1
  12. package/dist/core/loop/subagent.d.ts +19 -0
  13. package/dist/core/loop/subagent.js +73 -7
  14. package/dist/core/loop.d.ts +4 -1
  15. package/dist/core/loop.js +22 -6
  16. package/dist/{server/registry-config.d.ts → core/provider-registry.d.ts} +10 -2
  17. package/dist/core/provider-registry.js +66 -0
  18. package/dist/core/redact.d.ts +2 -1
  19. package/dist/core/redact.js +4 -2
  20. package/dist/core/secret.d.ts +5 -0
  21. package/dist/core/secret.js +14 -0
  22. package/dist/core/skills.d.ts +2 -0
  23. package/dist/core/skills.js +61 -0
  24. package/dist/core/slash.js +31 -18
  25. package/dist/core/title.js +4 -2
  26. package/dist/core/todo-tags.d.ts +4 -3
  27. package/dist/core/todo-tags.js +20 -6
  28. package/dist/core/workflow.d.ts +4 -13
  29. package/dist/core/workflow.js +6 -6
  30. package/dist/core/workflows/context.js +31 -59
  31. package/dist/core/workflows/runtime.js +10 -8
  32. package/dist/dap/index.d.ts +1 -0
  33. package/dist/dap/index.js +1 -0
  34. package/dist/dap/protocol.d.ts +8 -3
  35. package/dist/dap/protocol.js +54 -18
  36. package/dist/dap/session.js +71 -12
  37. package/dist/dap/spawn.d.ts +11 -0
  38. package/dist/dap/spawn.js +48 -0
  39. package/dist/dap/transport.js +18 -0
  40. package/dist/kanban/index.d.ts +1 -1
  41. package/dist/kanban/index.js +1 -1
  42. package/dist/kanban/store.d.ts +8 -1
  43. package/dist/kanban/store.js +61 -13
  44. package/dist/llm/protocols/openai-compat.d.ts +7 -0
  45. package/dist/llm/protocols/openai-compat.js +19 -2
  46. package/dist/llm/protocols/utils/tool-stream.d.ts +12 -3
  47. package/dist/llm/protocols/utils/tool-stream.js +47 -23
  48. package/dist/llm/provider.js +18 -3
  49. package/dist/llm/retry.d.ts +1 -0
  50. package/dist/llm/retry.js +20 -7
  51. package/dist/llm/routing.js +14 -2
  52. package/dist/llm/schema/messages.d.ts +9 -2
  53. package/dist/llm/token.d.ts +9 -0
  54. package/dist/llm/token.js +10 -6
  55. package/dist/llm/transport.js +7 -2
  56. package/dist/mcp/client.d.ts +11 -0
  57. package/dist/mcp/client.js +70 -0
  58. package/dist/mcp/index.d.ts +46 -0
  59. package/dist/mcp/index.js +106 -0
  60. package/dist/mcp/tool-adapter.d.ts +14 -0
  61. package/dist/mcp/tool-adapter.js +65 -0
  62. package/dist/mcp/transport.d.ts +35 -0
  63. package/dist/mcp/transport.js +243 -0
  64. package/dist/mcp/types.d.ts +55 -0
  65. package/dist/mcp/types.js +3 -0
  66. package/dist/plugins/init.d.ts +2 -0
  67. package/dist/plugins/init.js +10 -1
  68. package/dist/project/project.js +50 -3
  69. package/dist/project/resolve.d.ts +7 -0
  70. package/dist/project/resolve.js +52 -5
  71. package/dist/server/permission/interactive.js +9 -4
  72. package/dist/server/permission/store.js +8 -2
  73. package/dist/server/routes/auth.js +28 -13
  74. package/dist/server/routes/chat.js +92 -26
  75. package/dist/server/routes/config.js +38 -10
  76. package/dist/server/routes/files.js +75 -16
  77. package/dist/server/routes/filesystem.js +9 -2
  78. package/dist/server/routes/kanban.js +146 -9
  79. package/dist/server/routes/permissions.js +32 -0
  80. package/dist/server/routes/project.js +15 -6
  81. package/dist/server/routes/provider.js +10 -5
  82. package/dist/server/routes/session.js +72 -26
  83. package/dist/server/routes/terminal.js +10 -2
  84. package/dist/server/routes/todo.js +18 -0
  85. package/dist/server/routes/tool.js +13 -2
  86. package/dist/server/routes/update.js +5 -1
  87. package/dist/server/routes/workflows.js +17 -6
  88. package/dist/server/server.d.ts +1 -1
  89. package/dist/server/server.js +31 -17
  90. package/dist/server/terminal/pty-manager.d.ts +3 -0
  91. package/dist/server/terminal/pty-manager.js +8 -3
  92. package/dist/server/types.d.ts +2 -0
  93. package/dist/server/util/json-body.d.ts +31 -0
  94. package/dist/server/util/json-body.js +38 -0
  95. package/dist/session/archive.js +4 -4
  96. package/dist/session/branch.d.ts +10 -1
  97. package/dist/session/branch.js +54 -9
  98. package/dist/session/compaction.d.ts +5 -0
  99. package/dist/session/compaction.js +15 -10
  100. package/dist/session/context.d.ts +7 -3
  101. package/dist/session/context.js +35 -9
  102. package/dist/session/import.js +53 -6
  103. package/dist/session/index.d.ts +2 -2
  104. package/dist/session/index.js +2 -2
  105. package/dist/session/message.d.ts +12 -3
  106. package/dist/session/message.js +44 -4
  107. package/dist/session/session.d.ts +11 -5
  108. package/dist/session/session.js +99 -88
  109. package/dist/session/shake.js +35 -25
  110. package/dist/session/squash.js +3 -4
  111. package/dist/session/token.d.ts +7 -8
  112. package/dist/session/token.js +19 -16
  113. package/dist/session/usage.js +4 -2
  114. package/dist/shared/index.d.ts +1 -0
  115. package/dist/shared/index.js +1 -0
  116. package/dist/shared/round.d.ts +12 -0
  117. package/dist/shared/round.js +18 -0
  118. package/dist/shared/timer.d.ts +23 -0
  119. package/dist/shared/timer.js +27 -0
  120. package/dist/shared/types/tool.d.ts +4 -0
  121. package/dist/shared/utils/markdown-code.d.ts +15 -0
  122. package/dist/shared/utils/markdown-code.js +44 -0
  123. package/dist/shared/utils/path.d.ts +4 -1
  124. package/dist/shared/utils/path.js +5 -2
  125. package/dist/shared/utils/string.d.ts +5 -0
  126. package/dist/shared/utils/string.js +52 -0
  127. package/dist/tools/builtin/bash.js +79 -10
  128. package/dist/tools/builtin/edit.js +67 -8
  129. package/dist/tools/builtin/glob.js +150 -14
  130. package/dist/tools/builtin/grep.js +53 -11
  131. package/dist/tools/builtin/kanban.d.ts +14 -0
  132. package/dist/tools/builtin/kanban.js +4 -1
  133. package/dist/tools/builtin/read.js +15 -2
  134. package/dist/tools/builtin/resolvers.d.ts +9 -2
  135. package/dist/tools/builtin/resolvers.js +20 -6
  136. package/dist/tools/builtin/task.js +107 -25
  137. package/dist/tools/builtin/todo.js +84 -9
  138. package/dist/tools/builtin/write.js +7 -1
  139. package/dist/tools/builtin/yield.js +9 -1
  140. package/dist/tools/executor.js +53 -1
  141. package/dist/tools/hashline/patch.js +114 -38
  142. package/dist/tools/truncate.js +45 -16
  143. package/dist/tools/validate.js +66 -20
  144. package/dist/tools/websearch/index.js +8 -4
  145. package/dist/update/index.d.ts +1 -1
  146. package/dist/update/index.js +1 -1
  147. package/dist/update/scheduler.js +9 -2
  148. package/dist/update/snapshot.d.ts +14 -4
  149. package/dist/update/snapshot.js +22 -22
  150. package/dist/update/updater.d.ts +16 -2
  151. package/dist/update/updater.js +39 -8
  152. package/dist/update/version.d.ts +3 -1
  153. package/dist/update/version.js +63 -9
  154. package/package.json +1 -1
  155. package/dist/server/registry-config.js +0 -44
@@ -1,14 +1,17 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
3
  import { dirname, join, relative } from 'node:path';
3
4
  import { Hono } from 'hono';
4
5
  import trash from 'trash';
5
6
  import { createSummarizer } from '../../core/compact.js';
6
7
  import { loadConfigScopes, mergeConfig } from '../../core/config.js';
8
+ import { buildRegistryFromConfig } from '../../core/provider-registry.js';
7
9
  import { buildFallbackChain } from '../../llm/routing.js';
8
10
  import { getProject } from '../../project/project.js';
9
- import { appendToGitignore, checkIgnored, checkoutGitBranch, createGitBranch, getGitBranch, getGitDiffSummary, getGitLastCommit, getGitStatus, listGitBranches, performGitCommit, } from '../../project/resolve.js';
11
+ import { appendToGitignore, checkIgnored, checkoutGitBranch, createGitBranch, getGitBranch, getGitDiffSummary, getGitLastCommit, getGitStatus, isValidBranchName, listGitBranches, performGitCommit, } from '../../project/resolve.js';
12
+ import { headChars } from '../../shared/utils/string.js';
10
13
  import { apiError } from '../middleware/error.js';
11
- import { buildRegistryFromConfig } from '../registry-config.js';
14
+ import { readJsonObject } from '../util/json-body.js';
12
15
  import { safeResolve } from '../util/safe-path.js';
13
16
  /** 递归搜索时跳过的目录(体积大/为元数据噪音,避免递归进入)。
14
17
  * P3:补齐常见构建/缓存产物目录——此前只跳 .git/node_modules,
@@ -34,7 +37,11 @@ const SEARCH_MAX_RESULTS = 5000;
34
37
  /** 递归收集文件列表(用于搜索)。P3:深度上限 5 → 8,深层文件此前搜不到。
35
38
  * P3-14:达结果上限即停止遍历,防大仓库全量 walk。 */
36
39
  async function collectFiles(dir, basePath, maxDepth = 8, budget = SEARCH_MAX_RESULTS) {
37
- if (maxDepth < 0 || budget <= 0)
40
+ // maxDepth 语义:能列举条目的调用层级。`maxDepth < 0` 会放 maxDepth=0 的
41
+ // 调用进来多收集一层(上限 8 时第 9 层的文件/目录仍被搜到)——守卫与
42
+ // 「深度上限」契约差一层。maxDepth <= 0 使收集层数恰为声明的上限:
43
+ // 首次调用 maxDepth=D 列举第 1 层,递归递减,第 D 层之后不再列举。
44
+ if (maxDepth <= 0 || budget <= 0)
38
45
  return [];
39
46
  const results = [];
40
47
  let entries;
@@ -135,6 +142,29 @@ function rejectCommitMessage(c, reason, mode) {
135
142
  }
136
143
  function createFilesRoute(ctx) {
137
144
  const app = new Hono();
145
+ /**
146
+ * 从请求 URL 提取工作区相对文件路径(逐段 decodeURIComponent)。
147
+ *
148
+ * 不能直接用 Hono 的 `c.req.path`:它经 `decodeURI`,只解码非 reserved 转义,
149
+ * `? # : @ & = + $ , ;` 的转义被原样保留。客户端 encodeURIComponent 后
150
+ * `a%3Fb.txt` 在服务端永远对不上磁盘上的 `a?b.txt`——读取 404,写入/删除更会
151
+ * 落到另一个字面名为 `a%3Fb.txt` 的幽灵文件(界面报成功、用户改动丢失)。
152
+ * 基于原始 URL 逐段解码(而非整串解码)保证 `%2F` 不会拆出新的路径层级。
153
+ * 非法转义(`%zz` 等)返回 null,由调用方转 400。
154
+ */
155
+ const extractFilePath = (c) => {
156
+ try {
157
+ return new URL(c.req.url).pathname
158
+ .replace(/^\/api\/files\/?/, '')
159
+ .replace(/^\/+/, '')
160
+ .split('/')
161
+ .map((segment) => decodeURIComponent(segment))
162
+ .join('/');
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ };
138
168
  // git 状态:返回 path → 状态分类 的映射(非 git 返回空对象)
139
169
  app.get('/git-status', async (c) => {
140
170
  const projectId = c.req.query('projectId');
@@ -190,9 +220,10 @@ function createFilesRoute(ctx) {
190
220
  return apiError(c, 400, 'NO_CHANGES', 'No changes to commit');
191
221
  }
192
222
  // 可选 body:mode / message / suggestions
193
- const body = await c.req
194
- .json()
195
- .catch(() => ({}));
223
+ const parsedBody = await readJsonObject(c);
224
+ if (!parsedBody.ok)
225
+ return parsedBody.response;
226
+ const body = parsedBody.body;
196
227
  // --- mode: force — 跳过检查,用传入 message 直接提交 ---
197
228
  if (body.mode === 'force') {
198
229
  const check = checkCommitMessage(body.message);
@@ -252,7 +283,7 @@ Reply as JSON ONLY:
252
283
 
253
284
  If no files need ignoring, return an empty array for ignoreSuggestions.
254
285
 
255
- ${summary.diff.slice(0, 8000)}`;
286
+ ${headChars(summary.diff, 8000)}`;
256
287
  let raw;
257
288
  try {
258
289
  // P1-1:项目级 provider 注册表(项目配置的 provider 才能路由)。
@@ -333,10 +364,20 @@ ${summary.diff.slice(0, 8000)}`;
333
364
  }
334
365
  root = project.worktree;
335
366
  }
336
- const body = await c.req.json().catch(() => ({}));
337
- const branch = body.branch;
367
+ const parsed = await readJsonObject(c);
368
+ if (!parsed.ok)
369
+ return parsed.response;
370
+ // branch 必须是非空字符串:数字此前经 as 断言进 spawnSync 参数抛
371
+ // ERR_INVALID_ARG_TYPE → 500。
372
+ const branch = typeof parsed.body.branch === 'string' ? parsed.body.branch : '';
338
373
  if (!branch)
339
374
  return apiError(c, 400, 'BAD_REQUEST', 'branch is required');
375
+ // 分支名合法性在入口显式 400:以 `-` 开头的名字会被 git 当作**选项**解释
376
+ //(`git checkout -B<name>` 静默重置分支指针、`--detach` 让 HEAD 脱离分支),
377
+ // 且这类输入属客户端错误而非服务端故障(resolve 层同样兜底拒绝)。
378
+ if (!isValidBranchName(branch)) {
379
+ return apiError(c, 400, 'INVALID_BRANCH', `非法分支名:${branch}`);
380
+ }
340
381
  const result = checkoutGitBranch(root, branch);
341
382
  if ('error' in result) {
342
383
  return apiError(c, 500, 'CHECKOUT_FAILED', result.error);
@@ -354,10 +395,17 @@ ${summary.diff.slice(0, 8000)}`;
354
395
  }
355
396
  root = project.worktree;
356
397
  }
357
- const body = await c.req.json().catch(() => ({}));
358
- const name = body.name;
398
+ const parsed = await readJsonObject(c);
399
+ if (!parsed.ok)
400
+ return parsed.response;
401
+ const name = typeof parsed.body.name === 'string' ? parsed.body.name : '';
359
402
  if (!name)
360
403
  return apiError(c, 400, 'BAD_REQUEST', 'name is required');
404
+ // 与 /git-checkout 同口径:非法分支名(含以 `-` 开头)显式 400,
405
+ // 不透出 git 的 stderr(那是输入非法而非 git 故障)。
406
+ if (!isValidBranchName(name)) {
407
+ return apiError(c, 400, 'INVALID_BRANCH', `非法分支名:${name}`);
408
+ }
361
409
  const result = createGitBranch(root, name);
362
410
  if ('error' in result) {
363
411
  return apiError(c, 500, 'BRANCH_CREATE_FAILED', result.error);
@@ -431,9 +479,9 @@ ${summary.diff.slice(0, 8000)}`;
431
479
  // 读取文件
432
480
  // projectId 指定时按对应项目 worktree 解析,否则回退 ctx.cwd(向后兼容)。
433
481
  app.get('/*', async (c) => {
434
- const path = c.req.path.replace(/^\/api\/files\//, '').replace(/^\//, '');
435
- const raw = path.endsWith('/raw');
436
- const filePath = raw ? path.slice(0, -'/raw'.length) : path;
482
+ const path = extractFilePath(c);
483
+ if (path === null)
484
+ return apiError(c, 400, 'BAD_REQUEST', '非法的 URL 转义序列');
437
485
  const projectId = c.req.query('projectId');
438
486
  let root = ctx.cwd;
439
487
  if (projectId) {
@@ -443,6 +491,13 @@ ${summary.diff.slice(0, 8000)}`;
443
491
  }
444
492
  root = project.worktree;
445
493
  }
494
+ // /raw 后缀是媒体预览的协议标记,但仅当「完整路径不存在」时才按 raw 模式
495
+ // 解释——真实存在的 .../raw 文件按普通文件字面读取。此前纯后缀判定优先,
496
+ // 名为 raw 的文件(data/raw 等)恒被误判为 raw 模式,去后缀父路径必为目录
497
+ // (readFile 抛 EISDIR → 404),该文件永远读不到。
498
+ const fullResolved = safeResolve(root, path);
499
+ const raw = path.endsWith('/raw') && !(fullResolved !== null && existsSync(fullResolved));
500
+ const filePath = raw ? path.slice(0, -'/raw'.length) : path;
446
501
  const resolved = safeResolve(root, filePath);
447
502
  if (!resolved) {
448
503
  return apiError(c, 403, 'FORBIDDEN', 'Path outside workspace');
@@ -476,7 +531,9 @@ ${summary.diff.slice(0, 8000)}`;
476
531
  // 写入文件
477
532
  // projectId 指定时按对应项目 worktree 解析,否则回退 ctx.cwd(向后兼容)。
478
533
  app.put('/*', async (c) => {
479
- const path = c.req.path.replace(/^\/api\/files\//, '').replace(/^\//, '');
534
+ const path = extractFilePath(c);
535
+ if (path === null)
536
+ return apiError(c, 400, 'BAD_REQUEST', '非法的 URL 转义序列');
480
537
  const projectId = c.req.query('projectId');
481
538
  let root = ctx.cwd;
482
539
  if (projectId) {
@@ -512,7 +569,9 @@ ${summary.diff.slice(0, 8000)}`;
512
569
  // 删除文件/目录(移入系统回收站)
513
570
  // projectId 指定时按对应项目 worktree 解析,否则回退 ctx.cwd(向后兼容)。
514
571
  app.delete('/*', async (c) => {
515
- const path = c.req.path.replace(/^\/api\/files\//, '').replace(/^\//, '');
572
+ const path = extractFilePath(c);
573
+ if (path === null)
574
+ return apiError(c, 400, 'BAD_REQUEST', '非法的 URL 转义序列');
516
575
  const projectId = c.req.query('projectId');
517
576
  let root = ctx.cwd;
518
577
  if (projectId) {
@@ -42,7 +42,11 @@ async function searchDirectories(directory, query, limit = 50, maxDepth = 5) {
42
42
  const lowerQuery = query.toLowerCase();
43
43
  const results = [];
44
44
  async function walk(dir, depth) {
45
- if (results.length >= limit || depth > maxDepth)
45
+ // depth 语义:walk(base, 0) 处理的是第 1 层条目。守卫必须挡住
46
+ // depth === maxDepth 的调用——`depth > maxDepth` 会放它进来多收集一层
47
+ // (maxDepth=1 实测返回 ['a','a/b'])。depth >= maxDepth 使收集层数恰为
48
+ // maxDepth:walk 只在 depth ∈ [0, maxDepth) 时推入条目。
49
+ if (results.length >= limit || depth >= maxDepth)
46
50
  return;
47
51
  let entries;
48
52
  try {
@@ -93,7 +97,10 @@ function createFilesystemRoute(ctx) {
93
97
  app.get('/search', async (c) => {
94
98
  const rawDir = c.req.query('directory') ?? '';
95
99
  const q = c.req.query('q') ?? '';
96
- const limitRaw = Number.parseInt(c.req.query('limit') ?? '', 10);
100
+ // limit 仅接受十进制正整数整体:parseInt('2abc') 会折成 2 静默截断结果集。
101
+ // 非法/缺失回落默认 50(与 read/grep 的数值参数显式校验同口径)。
102
+ const limitParam = c.req.query('limit') ?? '';
103
+ const limitRaw = /^\d+$/.test(limitParam) ? Number(limitParam) : Number.NaN;
97
104
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 200) : 50;
98
105
  const directory = expandPath(rawDir);
99
106
  const items = await searchDirectories(directory, q, limit);
@@ -3,11 +3,50 @@
3
3
  // P2-5:/deleted* 为看板回收站端点(必须注册在 /:projectId 之前避免被参数路由吞掉)。
4
4
  import { existsSync } from 'node:fs';
5
5
  import { Hono } from 'hono';
6
- import { createKanbanStore, getDeletedKanbanBoard, KanbanCardNotFoundError, KanbanColumnInUseError, KanbanColumnNotFoundError, KanbanInvalidPriorityError, listDeletedKanbanBoards, mergeKanbanBoard, permanentlyDeleteKanbanBoard, restoreKanbanBoard, } from '../../kanban/index.js';
6
+ import { createKanbanStore, getDeletedKanbanBoard, KanbanCardNotFoundError, KanbanColumnInUseError, KanbanColumnNotFoundError, KanbanInvalidPositionError, KanbanInvalidPriorityError, listDeletedKanbanBoards, mergeKanbanBoard, permanentlyDeleteKanbanBoard, restoreKanbanBoard, } from '../../kanban/index.js';
7
7
  import { fromDirectory, getProject } from '../../project/index.js';
8
+ import { isUuid } from '../../shared/utils/string.js';
8
9
  import { apiError } from '../middleware/error.js';
10
+ import { readJsonObject } from '../util/json-body.js';
11
+ // ── body 字段类型守卫 ──────────────────────────────────────────────
12
+ // 畸形 JSON 此前直接 as 断言后进 store/DB:数字 title → .trim TypeError 500、
13
+ // columns/labels 数组含 null/缺字段 → 读 null.id TypeError 500、labels 字符串
14
+ // → 持久化毒化卡片(后续每轮前端 .map 崩溃)。
15
+ const VALID_PRIORITIES = new Set(['high', 'medium', 'low']);
16
+ function isValidColumnDef(v) {
17
+ return (v !== null &&
18
+ typeof v === 'object' &&
19
+ typeof v.id === 'string' &&
20
+ typeof v.name === 'string');
21
+ }
22
+ function isValidLabelDef(v) {
23
+ return (v !== null &&
24
+ typeof v === 'object' &&
25
+ typeof v.id === 'string' &&
26
+ typeof v.name === 'string' &&
27
+ typeof v.color === 'string');
28
+ }
29
+ function isValidStringArray(v) {
30
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
31
+ }
32
+ /** 列/标签配置含重复 id:拖拽目标歧义、前端按 id 索引互踩——导入/更新前拒绝。 */
33
+ function hasDuplicateIds(items) {
34
+ return new Set(items.map((x) => x.id)).size !== items.length;
35
+ }
9
36
  function createKanbanRoute(ctx) {
10
37
  const app = new Hono();
38
+ // 看板 id 是 uuid 列:非 UUID 直接 404(同 guardProject 的理由——此前非 UUID
39
+ // 经 drizzle 查询在 PG 侧抛 22P02,Hono 兜成 500 且回显 SQL 错误细节)。
40
+ // 注册在 /deleted* 路由之前:Hono 按注册序分派,守卫必须先于具体处理器。
41
+ // '/deleted'(单段)不匹配本守卫模式,回收站列表/清空不受影响。
42
+ const guardBoardId = async (c, next) => {
43
+ if (!isUuid(c.req.param('boardId') ?? '')) {
44
+ return apiError(c, 404, 'BOARD_NOT_FOUND', '看板不存在或不在回收站');
45
+ }
46
+ await next();
47
+ };
48
+ app.use('/deleted/:boardId', guardBoardId);
49
+ app.use('/deleted/:boardId/*', guardBoardId);
11
50
  // GET /deleted — 回收站看板列表(项目删除软删除的看板,60 天保留期)。
12
51
  app.get('/deleted', async (c) => {
13
52
  return c.json({ boards: await listDeletedKanbanBoards(ctx.db) });
@@ -17,7 +56,10 @@ function createKanbanRoute(ctx) {
17
56
  // (目标无板时等价普通恢复)。此前 409 指引「删除目标看板」但产品无此能力,
18
57
  // 恢复是死胡同。
19
58
  app.post('/deleted/:boardId/restore', async (c) => {
20
- const body = (await c.req.json().catch(() => ({})));
59
+ const parsed = await readJsonObject(c);
60
+ if (!parsed.ok)
61
+ return parsed.response;
62
+ const body = parsed.body;
21
63
  const boardId = c.req.param('boardId');
22
64
  const merge = body.merge === true;
23
65
  // 重建原项目:目录仍存在时重建项目记录并恢复到该目录(与会话存储同语义)。
@@ -92,6 +134,13 @@ function createKanbanRoute(ctx) {
92
134
  };
93
135
  app.use('/:projectId', guardProject);
94
136
  app.use('/:projectId/*', guardProject);
137
+ // 卡片 id 同为用户可控路径参数(uuid 列):非 UUID → 404 而非 PG 22P02 → 500。
138
+ app.use('/:projectId/cards/:cardId', async (c, next) => {
139
+ if (!isUuid(c.req.param('cardId') ?? '')) {
140
+ return apiError(c, 404, 'CARD_NOT_FOUND', '卡片不存在');
141
+ }
142
+ await next();
143
+ });
95
144
  // GET /:projectId — full board with cards
96
145
  app.get('/:projectId', async (c) => {
97
146
  const projectId = c.req.param('projectId');
@@ -125,6 +174,18 @@ function createKanbanRoute(ctx) {
125
174
  if (body?.version !== 1 || !Array.isArray(body.columns) || !Array.isArray(body.cards)) {
126
175
  return apiError(c, 400, 'INVALID_EXPORT', '无效的看板导出 JSON:需要 version/columns/cards 字段');
127
176
  }
177
+ // 条目形状校验:columns 数组含 null/缺 name 此前直接读 col.id 击穿 500,
178
+ // 缺字段列被持久化后前端渲染/拖拽静默出错。
179
+ if (!body.columns.every(isValidColumnDef)) {
180
+ return apiError(c, 400, 'INVALID_EXPORT', 'columns 条目必须是 { id, name } 对象');
181
+ }
182
+ if (Array.isArray(body.labels) && !body.labels.every(isValidLabelDef)) {
183
+ return apiError(c, 400, 'INVALID_EXPORT', 'labels 条目必须是 { id, name, color } 对象');
184
+ }
185
+ const columns = body.columns;
186
+ if (hasDuplicateIds(columns)) {
187
+ return apiError(c, 400, 'INVALID_EXPORT', 'columns 包含重复的 id');
188
+ }
128
189
  // P3:导入体积上限——与会话导入(messages/archives 上限)同口径。
129
190
  // 无上限的数组会让 replaceBoard 的单事务逐条重建 OOM/长事务拖垮 PGLite。
130
191
  const MAX_IMPORT_COLUMNS = 100;
@@ -139,7 +200,6 @@ function createKanbanRoute(ctx) {
139
200
  if (body.cards.length > MAX_IMPORT_CARDS) {
140
201
  return apiError(c, 400, 'IMPORT_TOO_LARGE', `导入卡片数 ${body.cards.length} 超过上限 ${MAX_IMPORT_CARDS}`);
141
202
  }
142
- const columns = body.columns;
143
203
  const labels = Array.isArray(body.labels) ? body.labels : [];
144
204
  // 宽松校验卡片:只保留结构完整的条目(导入宁少勿坏,与会话导入一致)。
145
205
  const cards = body.cards
@@ -156,7 +216,7 @@ function createKanbanRoute(ctx) {
156
216
  priority: (card.priority === 'high' || card.priority === 'medium' || card.priority === 'low'
157
217
  ? card.priority
158
218
  : 'medium'),
159
- position: typeof card.position === 'number' ? card.position : 0,
219
+ position: typeof card.position === 'number' && Number.isFinite(card.position) ? card.position : 0,
160
220
  labels: Array.isArray(card.labels)
161
221
  ? card.labels.filter((l) => typeof l === 'string')
162
222
  : [],
@@ -174,7 +234,28 @@ function createKanbanRoute(ctx) {
174
234
  // PATCH /:projectId — update board columns/labels config
175
235
  app.patch('/:projectId', async (c) => {
176
236
  const projectId = c.req.param('projectId');
177
- const body = await c.req.json().catch(() => ({}));
237
+ const parsed = await readJsonObject(c);
238
+ if (!parsed.ok)
239
+ return parsed.response;
240
+ const body = parsed.body;
241
+ // 形状校验:columns 非数组/条目含 null 此前在 store .map 处击穿 500;
242
+ // 缺 name 的列/标签被持久化后前端渲染静默出错。
243
+ if (body.columns !== undefined) {
244
+ if (!Array.isArray(body.columns) || !body.columns.every(isValidColumnDef)) {
245
+ return apiError(c, 400, 'INVALID_COLUMNS', 'columns 必须是 { id, name } 对象数组');
246
+ }
247
+ if (hasDuplicateIds(body.columns)) {
248
+ return apiError(c, 400, 'INVALID_COLUMNS', 'columns 包含重复的 id');
249
+ }
250
+ }
251
+ if (body.labels !== undefined) {
252
+ if (!Array.isArray(body.labels) || !body.labels.every(isValidLabelDef)) {
253
+ return apiError(c, 400, 'INVALID_LABELS', 'labels 必须是 { id, name, color } 对象数组');
254
+ }
255
+ if (hasDuplicateIds(body.labels)) {
256
+ return apiError(c, 400, 'INVALID_LABELS', 'labels 包含重复的 id');
257
+ }
258
+ }
178
259
  const store = createKanbanStore(ctx.db, projectId);
179
260
  try {
180
261
  const board = await store.updateBoard({
@@ -193,10 +274,30 @@ function createKanbanRoute(ctx) {
193
274
  // POST /:projectId/cards — add a card
194
275
  app.post('/:projectId/cards', async (c) => {
195
276
  const projectId = c.req.param('projectId');
196
- const body = await c.req.json().catch(() => ({}));
197
- const title = body.title?.trim();
277
+ const parsed = await readJsonObject(c);
278
+ if (!parsed.ok)
279
+ return parsed.response;
280
+ const body = parsed.body;
281
+ // 类型校验:数字 title 此前经 (as string).trim 击穿 500;labels 字符串/
282
+ // description 数字会被持久化成毒化卡片(jsonb 存非数组,前端 .map 崩溃)。
283
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
198
284
  if (!title)
199
285
  return apiError(c, 400, 'INVALID_INPUT', 'title is required');
286
+ if (body.description !== undefined &&
287
+ body.description !== null &&
288
+ typeof body.description !== 'string') {
289
+ return apiError(c, 400, 'INVALID_INPUT', 'description must be a string or null');
290
+ }
291
+ if (body.columnId !== undefined && typeof body.columnId !== 'string') {
292
+ return apiError(c, 400, 'INVALID_INPUT', 'columnId must be a string');
293
+ }
294
+ if (body.priority !== undefined &&
295
+ (typeof body.priority !== 'string' || !VALID_PRIORITIES.has(body.priority))) {
296
+ return apiError(c, 400, 'INVALID_PRIORITY', 'priority must be high/medium/low');
297
+ }
298
+ if (body.labels !== undefined && !isValidStringArray(body.labels)) {
299
+ return apiError(c, 400, 'INVALID_LABELS', 'labels must be an array of strings');
300
+ }
200
301
  const store = createKanbanStore(ctx.db, projectId);
201
302
  try {
202
303
  const card = await store.addCard({
@@ -222,7 +323,30 @@ function createKanbanRoute(ctx) {
222
323
  app.patch('/:projectId/cards/:cardId', async (c) => {
223
324
  const projectId = c.req.param('projectId');
224
325
  const cardId = c.req.param('cardId');
225
- const body = await c.req.json().catch(() => ({}));
326
+ const parsed = await readJsonObject(c);
327
+ if (!parsed.ok)
328
+ return parsed.response;
329
+ const body = parsed.body;
330
+ // 类型校验:数字 title/description/labels 字符串此前被 as 断言后直接
331
+ // update 进 DB(text/jsonb 类型失配 500 或持久化毒化字段)。
332
+ if (body.title !== undefined && (typeof body.title !== 'string' || body.title.trim() === '')) {
333
+ return apiError(c, 400, 'INVALID_INPUT', 'title must be a non-empty string');
334
+ }
335
+ if (body.description !== undefined &&
336
+ body.description !== null &&
337
+ typeof body.description !== 'string') {
338
+ return apiError(c, 400, 'INVALID_INPUT', 'description must be a string or null');
339
+ }
340
+ if (body.priority !== undefined &&
341
+ (typeof body.priority !== 'string' || !VALID_PRIORITIES.has(body.priority))) {
342
+ return apiError(c, 400, 'INVALID_PRIORITY', 'priority must be high/medium/low');
343
+ }
344
+ if (body.labels !== undefined && !isValidStringArray(body.labels)) {
345
+ return apiError(c, 400, 'INVALID_LABELS', 'labels must be an array of strings');
346
+ }
347
+ if (body.columnId !== undefined && typeof body.columnId !== 'string') {
348
+ return apiError(c, 400, 'INVALID_INPUT', 'columnId must be a string');
349
+ }
226
350
  const store = createKanbanStore(ctx.db, projectId);
227
351
  try {
228
352
  // columnId + position → moveCard; otherwise field update.
@@ -250,6 +374,9 @@ function createKanbanRoute(ctx) {
250
374
  if (err instanceof KanbanInvalidPriorityError) {
251
375
  return apiError(c, 400, 'INVALID_PRIORITY', err.message);
252
376
  }
377
+ if (err instanceof KanbanInvalidPositionError) {
378
+ return apiError(c, 400, 'INVALID_POSITION', err.message);
379
+ }
253
380
  throw err;
254
381
  }
255
382
  });
@@ -258,7 +385,17 @@ function createKanbanRoute(ctx) {
258
385
  const projectId = c.req.param('projectId');
259
386
  const cardId = c.req.param('cardId');
260
387
  const store = createKanbanStore(ctx.db, projectId);
261
- await store.deleteCard(cardId);
388
+ try {
389
+ // 作用域内删除:卡片不属于本项目板(他板 id / 已不存在)→ 404,
390
+ // 不再对任意 id 回 ok:true(删除失败静默无反馈)。
391
+ await store.deleteCard(cardId);
392
+ }
393
+ catch (err) {
394
+ if (err instanceof KanbanCardNotFoundError) {
395
+ return apiError(c, 404, 'CARD_NOT_FOUND', err.message);
396
+ }
397
+ throw err;
398
+ }
262
399
  return c.json({ ok: true });
263
400
  });
264
401
  return app;
@@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm';
2
2
  import { Hono } from 'hono';
3
3
  import { sessions } from '../../db/schema.js';
4
4
  import { getSession } from '../../session/session.js';
5
+ import { isUuid } from '../../shared/utils/string.js';
5
6
  import { apiError } from '../middleware/error.js';
6
7
  /** GET/PUT /api/permissions — 授权模式(default 逐个确认 / auto 自动放行 ask 工具)。
7
8
  * - 根路径:全局默认模式(启动时取 config.permission.defaultMode;
@@ -66,6 +67,21 @@ async function persistSessionMetadata(ctx, sessionId, mutate) {
66
67
  }
67
68
  function createPermissionsRoute(ctx) {
68
69
  const app = new Hono();
70
+ // 路径参数 sessionId 是 sessions.id(uuid 列):非 UUID 直接 404。
71
+ // 此前未守卫:PUT /:sessionId 的 getSession 把非 UUID 直接送进 drizzle
72
+ // 参数化查询,PG 侧抛 22P02(invalid input syntax for type uuid),Hono 兜成
73
+ // 500 并把 SQL 错误细节回给客户端——与 session/kanban/todo 路由已修的
74
+ // 同型缺陷。「格式非法」与「会话不存在」同属资源不存在,统一 404。
75
+ // /:sessionId/pending 只读内存 permissionStore(不触 DB),不受守卫约束。
76
+ const guardSessionId = async (c, next) => {
77
+ if (!isUuid(c.req.param('sessionId') ?? '')) {
78
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
79
+ }
80
+ await next();
81
+ };
82
+ app.use('/:sessionId', guardSessionId);
83
+ app.use('/:sessionId/always-allow', guardSessionId);
84
+ app.use('/:sessionId/always-allow/*', guardSessionId);
69
85
  // GET / — 默认授权模式
70
86
  app.get('/', (c) => {
71
87
  return c.json({ mode: ctx.permissionMode });
@@ -95,6 +111,12 @@ function createPermissionsRoute(ctx) {
95
111
  // GET /:sessionId — 会话实际生效模式(覆盖优先,回退默认)+ 始终允许白名单
96
112
  app.get('/:sessionId', async (c) => {
97
113
  const sessionId = c.req.param('sessionId');
114
+ // 与 PUT /:sessionId 同口径:不存在/回收站会话 404——返回全局默认模式
115
+ // 会冒充「该会话的模式」,调用方无法区分「无覆盖」与「会话不存在」。
116
+ const session = await getSession(ctx.db, sessionId);
117
+ if (!session || session.deletedAt) {
118
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
119
+ }
98
120
  await ensureSessionModeLoaded(ctx, sessionId);
99
121
  await ensureSessionAllowLoaded(ctx, sessionId);
100
122
  return c.json({
@@ -144,6 +166,12 @@ function createPermissionsRoute(ctx) {
144
166
  // POST /:sessionId/always-allow — 追加工具到会话白名单(幂等)
145
167
  app.post('/:sessionId/always-allow', async (c) => {
146
168
  const sessionId = c.req.param('sessionId');
169
+ // P3-11 同口径(此前只修了 PUT /:sessionId):不存在的会话此前写入返回
170
+ // 200 且污染内存 Map——用户以为白名单已生效,实际会话已删除。
171
+ const session = await getSession(ctx.db, sessionId);
172
+ if (!session || session.deletedAt) {
173
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
174
+ }
147
175
  const body = (await c.req.json().catch(() => null));
148
176
  const tool = body?.tool;
149
177
  if (typeof tool !== 'string' || tool.length === 0) {
@@ -162,6 +190,10 @@ function createPermissionsRoute(ctx) {
162
190
  // DELETE /:sessionId/always-allow/:tool — 从会话白名单移除
163
191
  app.delete('/:sessionId/always-allow/:tool', async (c) => {
164
192
  const sessionId = c.req.param('sessionId');
193
+ const session = await getSession(ctx.db, sessionId);
194
+ if (!session || session.deletedAt) {
195
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
196
+ }
165
197
  const tool = c.req.param('tool');
166
198
  await ensureSessionAllowLoaded(ctx, sessionId);
167
199
  const list = (ctx.sessionAlwaysAllow.get(sessionId) ?? []).filter((t) => t !== tool);
@@ -9,6 +9,7 @@ import { fromDirectory, getByDirectory, getProject, listProjects, relocateProjec
9
9
  import { clearTrashMarks } from '../../session/session.js';
10
10
  import { generateId } from '../../shared/index.js';
11
11
  import { apiError } from '../middleware/error.js';
12
+ import { readJsonObject } from '../util/json-body.js';
12
13
  import { expandPath } from './filesystem.js';
13
14
  /** realpath 失败(目录不存在等)时返回 null,调用方回退原路径。 */
14
15
  function realpathSafe(p) {
@@ -28,8 +29,12 @@ function createProjectRoute(ctx) {
28
29
  const app = new Hono();
29
30
  // 解析目录并创建/更新项目记录
30
31
  app.post('/from-directory', async (c) => {
31
- const body = await c.req.json().catch(() => ({}));
32
- const directory = body.directory;
32
+ const parsed = await readJsonObject(c);
33
+ if (!parsed.ok)
34
+ return parsed.response;
35
+ // directory 必须是非空字符串:数字/对象此前经 as 断言进 expandPath 的
36
+ // startsWith 抛 TypeError → 500。
37
+ const directory = typeof parsed.body.directory === 'string' ? parsed.body.directory : '';
33
38
  if (!directory)
34
39
  return apiError(c, 400, 'BAD_REQUEST', 'directory is required');
35
40
  // 展开 ~ 前缀为 home 绝对路径(前端可能传 ~/... 形式)
@@ -68,8 +73,10 @@ function createProjectRoute(ctx) {
68
73
  });
69
74
  // 更新项目名
70
75
  app.patch('/:id', async (c) => {
71
- const body = await c.req.json().catch(() => ({}));
72
- const name = body.name;
76
+ const parsed = await readJsonObject(c);
77
+ if (!parsed.ok)
78
+ return parsed.response;
79
+ const name = typeof parsed.body.name === 'string' ? parsed.body.name : '';
73
80
  if (!name)
74
81
  return apiError(c, 400, 'BAD_REQUEST', 'name is required');
75
82
  const project = await updateProjectName(ctx.db, c.req.param('id'), name);
@@ -90,8 +97,10 @@ function createProjectRoute(ctx) {
90
97
  // 目标目录已注册为另一项目 → 409;有活跃 run → 拒绝(agent 工作目录悬空)。
91
98
  app.post('/:id/relocate', async (c) => {
92
99
  const id = c.req.param('id');
93
- const body = (await c.req.json().catch(() => ({})));
94
- const directory = typeof body.directory === 'string' ? body.directory.trim() : '';
100
+ const parsed = await readJsonObject(c);
101
+ if (!parsed.ok)
102
+ return parsed.response;
103
+ const directory = typeof parsed.body.directory === 'string' ? parsed.body.directory.trim() : '';
95
104
  if (!directory)
96
105
  return apiError(c, 400, 'DIRECTORY_REQUIRED', 'directory is required');
97
106
  if (!existsSync(directory)) {
@@ -1,10 +1,12 @@
1
1
  import { Hono } from 'hono';
2
2
  import { loadConfigScopes, mergeConfig } from '../../core/config.js';
3
+ import { buildRegistryFromConfig } from '../../core/provider-registry.js';
3
4
  import { decryptSecret } from '../../core/secret.js';
4
5
  import { resolveRoute } from '../../llm/registry.js';
5
6
  import { getProject } from '../../project/project.js';
7
+ import { headChars } from '../../shared/utils/string.js';
6
8
  import { apiError } from '../middleware/error.js';
7
- import { buildRegistryFromConfig } from '../registry-config.js';
9
+ import { readJsonObject } from '../util/json-body.js';
8
10
  /**
9
11
  * 用给定 baseURL/apiKey 探测 OpenAI 兼容的 /models 端点。
10
12
  * baseURL 应自行包含版本路径(如 https://api.openai.com/v1)。
@@ -25,7 +27,7 @@ async function probeModels(baseURL, apiKey) {
25
27
  });
26
28
  if (!res.ok) {
27
29
  const text = await res.text().catch(() => '');
28
- return { ok: false, error: `HTTP ${res.status} ${text.slice(0, 200) || res.statusText}` };
30
+ return { ok: false, error: `HTTP ${res.status} ${headChars(text, 200) || res.statusText}` };
29
31
  }
30
32
  const json = (await res.json());
31
33
  const models = Array.isArray(json.data)
@@ -96,10 +98,13 @@ function createProviderRoute(ctx) {
96
98
  });
97
99
  // 连接测试:用请求体里的凭据探测 /models,不污染 registry
98
100
  app.post('/test', async (c) => {
99
- const body = await c.req.json().catch(() => ({}));
100
- const { baseURL, apiKey } = body;
101
- if (!baseURL)
101
+ const parsed = await readJsonObject(c);
102
+ if (!parsed.ok)
103
+ return parsed.response;
104
+ const { baseURL, apiKey } = parsed.body;
105
+ if (typeof baseURL !== 'string' || !baseURL) {
102
106
  return apiError(c, 400, 'BAD_REQUEST', 'baseURL is required');
107
+ }
103
108
  // P3:仅允许 http/https——服务端按调用方提供的 URL 发起请求,非 http 协议
104
109
  // 不应由「测试连接」端点代理(本机 localhost/Ollama 等 http 服务不受影响)。
105
110
  const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(baseURL)?.[1]?.toLowerCase();