c0de-agent 1.13.0 → 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,5 +1,5 @@
1
1
  import { basename } from 'node:path';
2
- import { eq } from 'drizzle-orm';
2
+ import { and, eq, isNull } from 'drizzle-orm';
3
3
  import { loadConfigScopes } from '../core/config.js';
4
4
  import { kanbanBoards, projects, sessions } from '../db/schema.js';
5
5
  import { resolveProject } from './resolve.js';
@@ -57,8 +57,8 @@ export async function fromDirectory(handle, directory) {
57
57
  return result;
58
58
  }
59
59
  /**
60
- * 合并同 worktree 下 id ≠ canonicalId 的项目记录:迁移其会话到 canonicalId 后删除。
61
- * 清理因 id 漂移(remote 变更)产生的历史重复项目,使列表不再出现同名重复项,
60
+ * 合并同 worktree 下 id ≠ canonicalId 的项目记录:迁移其会话(与看板)到 canonicalId
61
+ * 后删除。清理因 id 漂移(remote 变更)产生的历史重复项目,使列表不再出现同名重复项,
62
62
  * 也让挂在漂移 id 上的会话重新归属规范项目。
63
63
  */
64
64
  async function mergeDuplicateProjects(handle, canonicalId, worktree) {
@@ -70,9 +70,56 @@ async function mergeDuplicateProjects(handle, canonicalId, worktree) {
70
70
  .update(sessions)
71
71
  .set({ projectId: canonicalId })
72
72
  .where(eq(sessions.projectId, dup.id));
73
+ await adoptKanbanBoard(handle, dup, canonicalId);
73
74
  await handle.db.delete(projects).where(eq(projects.id, dup.id));
74
75
  }
75
76
  }
77
+ /**
78
+ * 把漂移项目的活动看板随会话一起迁到规范项目。看板(及其卡片)必须显式处理——
79
+ * 删除项目行后 FK `kanban_boards.project_id` 是 set null:留着不管会得到
80
+ * 「projectId 为 null 且 deletedAt 也为 null」的孤儿看板,任何项目视图都查不到它
81
+ * (createKanbanStore 按 projectId 查、看板回收站只列已软删),卡片静默消失且
82
+ * 无任何恢复入口——同口径缺陷在 relocateProject 与 DELETE /api/projects/:id 早已修掉,
83
+ * 只有本路径漏了。
84
+ *
85
+ * 规范项目已有活动看板时(uq_kanban_boards_project 唯一约束不允许两块并存)不覆盖
86
+ * 用户现有看板:把漂移看板软删进回收站并记录原项目名/目录,用户可在看板回收站里
87
+ * 用「合并恢复」把卡片并回现有看板(与项目删除路径同口径的可恢复语义)。
88
+ */
89
+ async function adoptKanbanBoard(handle,
90
+ /** 漂移项目(原始 DB 行或 Project 均可,仅取 id/name/worktree)。 */
91
+ from, toProjectId) {
92
+ const [board] = await handle.db
93
+ .select({ id: kanbanBoards.id })
94
+ .from(kanbanBoards)
95
+ .where(and(eq(kanbanBoards.projectId, from.id), isNull(kanbanBoards.deletedAt)))
96
+ .limit(1);
97
+ if (!board)
98
+ return;
99
+ const [existing] = await handle.db
100
+ .select({ id: kanbanBoards.id })
101
+ .from(kanbanBoards)
102
+ .where(and(eq(kanbanBoards.projectId, toProjectId), isNull(kanbanBoards.deletedAt)))
103
+ .limit(1);
104
+ if (!existing) {
105
+ await handle.db
106
+ .update(kanbanBoards)
107
+ .set({ projectId: toProjectId, updatedAt: new Date() })
108
+ .where(eq(kanbanBoards.id, board.id));
109
+ return;
110
+ }
111
+ await handle.db
112
+ .update(kanbanBoards)
113
+ .set({
114
+ deletedAt: new Date(),
115
+ deletedProjectName: from.name ?? from.worktree,
116
+ deletedProjectWorktree: from.worktree,
117
+ // 恢复后重删须重新经历「到期 → 宽限」周期(与 softDeleteKanbanBoard 同口径)
118
+ purgePendingAt: null,
119
+ updatedAt: new Date(),
120
+ })
121
+ .where(eq(kanbanBoards.id, board.id));
122
+ }
76
123
  export async function listProjects(handle) {
77
124
  const rows = await handle.db.select().from(projects);
78
125
  return rows.map(rowToProject);
@@ -16,6 +16,12 @@ export declare function getGitStatus(cwd: string): Record<string, GitStatusCode>
16
16
  * 只检查当前展开目录的直接子项(通常 10-50 个),不递归进 node_modules 等。
17
17
  * 非 git 仓库或无忽略文件时返回空集。
18
18
  *
19
+ * 路径经 `--` 传入:它们是**位置参数**,以 `-` 开头的合法文件名(-v / --stdin
20
+ * 等)否则会被 git 当成 check-ignore 自己的选项——`-v` 变 verbose(输出
21
+ * `.gitignore:1:path` 形态的行,解析端无法匹配任何路径)、`--stdin` 改从标准
22
+ * 输入读路径(无输入 → 空集),该目录的忽略标记整体静默失效。与分支名注入
23
+ * (checkoutGitBranch 的 -B<name>)同类:用户可控值不得落进选项槽。
24
+ *
19
25
  * 注意:git check-ignore 在「无路径被忽略」或「非 git 仓库」时退出码为 1,
20
26
  * 这两种情况都返回空集。 */
21
27
  export declare function checkIgnored(cwd: string, paths: string[]): Set<string>;
@@ -64,6 +70,7 @@ export interface GitBranchInfo {
64
70
  * 列出本地分支及当前分支标记(非 git 仓库返回 null)。
65
71
  */
66
72
  export declare function listGitBranches(cwd: string): GitBranchInfo[] | null;
73
+ export declare function isValidBranchName(name: string): boolean;
67
74
  /**
68
75
  * 切换到指定分支(git checkout)。成功返回分支名,失败返回 error。
69
76
  */
@@ -64,10 +64,15 @@ export function getGitStatus(cwd) {
64
64
  const xy = token.slice(0, 2);
65
65
  const code = classifyStatus(xy);
66
66
  const isRename = xy[0] === 'R' || xy[0] === 'C';
67
- // 重命名/复制:"XY oldpath\0newpath",状态挂在 newpath
67
+ // 重命名/复制:porcelain v1 -z 输出 "XY newpath\0oldpath\0"——第一字段
68
+ // 携带新路径(XY 前缀后),第二字段为旧路径。状态必须挂在新路径上:
69
+ // 文件树按现行路径取状态,旧路径已不存在;此前把 tokens[i+1](旧路径)
70
+ // 当新路径挂状态并跳过首字段——git mv 后新文件无任何状态标记,且 map 里
71
+ // 残留指向不存在路径的幽灵条目。
68
72
  if (isRename && i + 1 < tokens.length) {
69
- const newPath = tokens[i + 1] ?? '';
70
- map[normalizePath(newPath)] = code;
73
+ const rest = token.slice(3); // 跳过 "XY ",得新路径
74
+ if (rest)
75
+ map[normalizePath(rest)] = code;
71
76
  i += 2;
72
77
  }
73
78
  else {
@@ -106,13 +111,19 @@ function git(args, cwd) {
106
111
  * 只检查当前展开目录的直接子项(通常 10-50 个),不递归进 node_modules 等。
107
112
  * 非 git 仓库或无忽略文件时返回空集。
108
113
  *
114
+ * 路径经 `--` 传入:它们是**位置参数**,以 `-` 开头的合法文件名(-v / --stdin
115
+ * 等)否则会被 git 当成 check-ignore 自己的选项——`-v` 变 verbose(输出
116
+ * `.gitignore:1:path` 形态的行,解析端无法匹配任何路径)、`--stdin` 改从标准
117
+ * 输入读路径(无输入 → 空集),该目录的忽略标记整体静默失效。与分支名注入
118
+ * (checkoutGitBranch 的 -B<name>)同类:用户可控值不得落进选项槽。
119
+ *
109
120
  * 注意:git check-ignore 在「无路径被忽略」或「非 git 仓库」时退出码为 1,
110
121
  * 这两种情况都返回空集。 */
111
122
  export function checkIgnored(cwd, paths) {
112
123
  if (paths.length === 0)
113
124
  return new Set();
114
125
  try {
115
- const result = spawnSync('git', ['check-ignore', ...paths], {
126
+ const result = spawnSync('git', ['check-ignore', '--', ...paths], {
116
127
  cwd,
117
128
  encoding: 'utf-8',
118
129
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -178,7 +189,10 @@ function firstRemoteUrl(cwd) {
178
189
  .filter(Boolean)[0];
179
190
  if (!first)
180
191
  return '';
181
- return git(['remote', 'get-url', first], cwd);
192
+ // remote 名来自仓库自身的 .git/config(克隆的仓库可携带 `[remote "-x"]` 这类
193
+ // 以 `-` 开头的名字):与 checkIgnored 同口径,位置参数先以 `--` 终止选项解析,
194
+ // 否则名字被当成 get-url 的选项(--push/--all)而取不到 URL。
195
+ return git(['remote', 'get-url', '--', first], cwd);
182
196
  }
183
197
  /**
184
198
  * 取当前分支名(非 git 仓库返回 null)。
@@ -271,10 +285,41 @@ export function listGitBranches(cwd) {
271
285
  })
272
286
  .filter((b) => b.name);
273
287
  }
288
+ /**
289
+ * git 分支名合法性:拒绝「以 `-` 开头」(位置参数会被 git 当作**选项**解释——
290
+ * `git checkout -B<name>` 静默把该分支指针重置到当前 HEAD、`--detach` 让 HEAD
291
+ * 脱离分支,接口还照常回成功)与 git ref 规则禁用的字符/形状。
292
+ * 规则取 git-check-ref-format(1) 的常见子集:本产品的分支名来自 UI 分支列表,
293
+ * 不接受 revision 语法(HEAD~1 / @{-1})。
294
+ */
295
+ const INVALID_BRANCH_CHARS = /[\s~^:?*[\]\\]/;
296
+ /** 控制字符(C0/C1):ref 名不允许,且会污染日志/UI(与 `\s` 不覆盖 NUL 等)。 */
297
+ const BRANCH_CONTROL_CHARS = /\p{Cc}/u;
298
+ export function isValidBranchName(name) {
299
+ if (!name || name.startsWith('-') || name === '@')
300
+ return false;
301
+ if (INVALID_BRANCH_CHARS.test(name) || BRANCH_CONTROL_CHARS.test(name))
302
+ return false;
303
+ if (name.includes('..') || name.includes('@{') || name.includes('//'))
304
+ return false;
305
+ if (name.startsWith('/') || name.endsWith('/'))
306
+ return false;
307
+ if (name.endsWith('.') || name.endsWith('.lock'))
308
+ return false;
309
+ return name.split('/').every((segment) => segment.length > 0 && !segment.startsWith('.'));
310
+ }
311
+ /** 分支名非法时的统一错误(不回显 git stderr:那是「用户输入非法」而非「git 失败」)。 */
312
+ function invalidBranchNameError(name) {
313
+ return {
314
+ error: `Invalid branch name ${JSON.stringify(name)}: must be a valid git ref (no leading "-", no whitespace or ~^:?*[\\, no "..", no leading/trailing "/")`,
315
+ };
316
+ }
274
317
  /**
275
318
  * 切换到指定分支(git checkout)。成功返回分支名,失败返回 error。
276
319
  */
277
320
  export function checkoutGitBranch(cwd, branch) {
321
+ if (!isValidBranchName(branch))
322
+ return invalidBranchNameError(branch);
278
323
  const result = spawnSync('git', ['checkout', branch], {
279
324
  cwd,
280
325
  encoding: 'utf-8',
@@ -291,6 +336,8 @@ export function checkoutGitBranch(cwd, branch) {
291
336
  * 创建并切换到新分支。成功返回分支名,失败返回 error。
292
337
  */
293
338
  export function createGitBranch(cwd, name) {
339
+ if (!isValidBranchName(name))
340
+ return invalidBranchNameError(name);
294
341
  const result = spawnSync('git', ['checkout', '-b', name], {
295
342
  cwd,
296
343
  encoding: 'utf-8',
@@ -55,15 +55,20 @@ function createInteractivePermissionChecker(store, opts = {}) {
55
55
  // agent abort 联动:abort 时主动 deny 并清理 store 条目,
56
56
  // 避免 agent 已中止后 promise 仍悬空等待超时。store.resolve 是幂等终结,
57
57
  // 与超时/confirm 互斥——谁先到谁生效。
58
+ // 监听器必须在终结时注销:{ once: true } 只在「真的触发 abort」时自动摘除,
59
+ // 而确认/超时终结的请求(绝大多数)从不触发 abort——同一次 run 里累积到第 11
60
+ // 个监听器即触发 Node 的 MaxListenersExceededWarning,每个闭包还随 run 级
61
+ // signal 活到 run 结束(executor/bash 的同型监听都已在 finally 摘除)。
62
+ const onAbort = () => {
63
+ store.resolve(toolCallId, false);
64
+ };
58
65
  if (ctx.abort.aborted) {
59
66
  store.resolve(toolCallId, false);
60
67
  }
61
68
  else {
62
- ctx.abort.addEventListener('abort', () => {
63
- store.resolve(toolCallId, false);
64
- }, { once: true });
69
+ ctx.abort.addEventListener('abort', onAbort, { once: true });
65
70
  }
66
- return promise;
71
+ return promise.finally(() => ctx.abort.removeEventListener('abort', onAbort));
67
72
  },
68
73
  confirm(toolCallId, approved) {
69
74
  return store.resolve(toolCallId, approved);
@@ -4,14 +4,20 @@
4
4
  // reply 按 toolCallId 直接寻址,不依赖 session/agent run 的注册状态。
5
5
  // 这样即使 agent run 被覆盖、注销或 abort,pending 仍在 store 里,
6
6
  // confirm 仍能找到并 resolve。
7
+ import { clampTimerDelay } from '../../shared/timer.js';
7
8
  /** 默认 pending 超时时长:5 分钟(300_000ms)。
8
9
  * 超过后自动 resolve 为 deny 并清理 store 条目,避免 promise 永久挂起与 pending Map 泄漏。 */
9
10
  const DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
10
11
  /** 首层超时(仅提示)后到兜底自动拒绝的宽限期:25 分钟。双层超时总窗口 30 分钟。 */
11
12
  const DEFAULT_PERMISSION_EXPIRE_GRACE_MS = 25 * 60 * 1000;
12
13
  function createPermissionStore(opts = {}) {
13
- const timeoutMs = opts.timeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS;
14
- const expireGraceMs = opts.expireGraceMs ?? DEFAULT_PERMISSION_EXPIRE_GRACE_MS;
14
+ // timeoutMs/expireGraceMs 来自用户 config(server.ts 直传,调用点只校验 > 0):
15
+ // 超 32 位上限的值会被 Node 钳到 1ms——用户设「30 天」
16
+ // (2_592_000_000ms > 2^31-1)时权限请求注册即被「超时自动拒绝」,
17
+ // 根本没机会确认;宽限期同理退化成「提示后立刻拒绝」。非法/超限一律
18
+ // 回落默认(超限钳到上限),与 bash timeout / 更新检查间隔同口径。
19
+ const timeoutMs = clampTimerDelay(opts.timeoutMs, DEFAULT_PERMISSION_TIMEOUT_MS, 1);
20
+ const expireGraceMs = clampTimerDelay(opts.expireGraceMs, DEFAULT_PERMISSION_EXPIRE_GRACE_MS, 1);
15
21
  const pending = new Map();
16
22
  // 统一终结路径:clearTimeout → 删除条目 → resolve。
17
23
  // 三处调用(confirm、超时回调、abort 联动)都汇聚于此:
@@ -14,6 +14,7 @@
14
14
  // DELETE /api/auth/devices/:id 撤销设备(立即生效)
15
15
  import { Hono } from 'hono';
16
16
  import { apiError } from '../middleware/error.js';
17
+ import { readJsonObject } from '../util/json-body.js';
17
18
  /** 尽力而为的请求来源:x-forwarded-for 首跳(反代场景),否则视为本地回环。仅展示/软限流。 */
18
19
  function requestSource(c) {
19
20
  const fwd = c.req.header('x-forwarded-for');
@@ -31,11 +32,15 @@ function createAuthRoute(ctx) {
31
32
  if (!ctx.authManager) {
32
33
  return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
33
34
  }
34
- const body = (await c.req.json().catch(() => ({})));
35
- if (!body.token) {
35
+ const parsed = await readJsonObject(c);
36
+ if (!parsed.ok)
37
+ return parsed.response;
38
+ const body = parsed.body;
39
+ if (typeof body.token !== 'string' || !body.token) {
36
40
  return apiError(c, 400, 'BAD_REQUEST', 'token is required');
37
41
  }
38
- const result = await ctx.authManager.registerFirstDevice(body.token, body.deviceName ?? '设备');
42
+ const deviceName = typeof body.deviceName === 'string' && body.deviceName ? body.deviceName : '设备';
43
+ const result = await ctx.authManager.registerFirstDevice(body.token, deviceName);
39
44
  if (!result.ok) {
40
45
  // 区分失败原因给正确的恢复指引:链接过期 → 重启 serve 换新链接;
41
46
  // 已有设备 → 走配对审批;两者补救动作完全不同,不得混用同一文案。
@@ -52,15 +57,18 @@ function createAuthRoute(ctx) {
52
57
  return apiError(c, 403, 'STATIC_TOKEN_MODE', '静态 token 模式不支持设备注册。');
53
58
  }
54
59
  // P1-3:回传注册成功的设备名,前端展示一次性确认(用户可核对注册的是否自己)。
55
- return c.json({ deviceToken: result.deviceToken, deviceName: body.deviceName ?? '设备' });
60
+ return c.json({ deviceToken: result.deviceToken, deviceName });
56
61
  });
57
62
  // 新设备发起配对请求(公开)。
58
63
  app.post('/pairing/request', async (c) => {
59
64
  if (!ctx.authManager) {
60
65
  return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
61
66
  }
62
- const body = (await c.req.json().catch(() => ({})));
63
- const result = ctx.authManager.requestPairing(body.deviceName ?? '新设备', requestSource(c));
67
+ const parsed = await readJsonObject(c);
68
+ if (!parsed.ok)
69
+ return parsed.response;
70
+ const deviceName = parsed.body.deviceName;
71
+ const result = ctx.authManager.requestPairing(typeof deviceName === 'string' && deviceName ? deviceName : '新设备', requestSource(c));
64
72
  if (!result) {
65
73
  return apiError(c, 429, 'PAIRING_LIMIT', '待审批的配对请求过多,请稍后再试');
66
74
  }
@@ -98,12 +106,16 @@ function createAuthRoute(ctx) {
98
106
  if (!ctx.authManager) {
99
107
  return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
100
108
  }
101
- const body = (await c.req.json().catch(() => ({})));
102
- if (!body.pairingId)
109
+ const parsed = await readJsonObject(c);
110
+ if (!parsed.ok)
111
+ return parsed.response;
112
+ const pairingId = typeof parsed.body.pairingId === 'string' ? parsed.body.pairingId : '';
113
+ const code = typeof parsed.body.code === 'string' ? parsed.body.code : '';
114
+ if (!pairingId)
103
115
  return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
104
- if (!body.code)
116
+ if (!code)
105
117
  return apiError(c, 400, 'BAD_REQUEST', 'code is required');
106
- const result = ctx.authManager.approvePairing(body.pairingId, body.code);
118
+ const result = ctx.authManager.approvePairing(pairingId, code);
107
119
  if (result === 'code_mismatch') {
108
120
  return apiError(c, 400, 'PAIRING_CODE_MISMATCH', '配对码不匹配:请核对新设备屏幕显示的 6 位配对码后重新输入');
109
121
  }
@@ -120,10 +132,13 @@ function createAuthRoute(ctx) {
120
132
  if (!ctx.authManager) {
121
133
  return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
122
134
  }
123
- const body = (await c.req.json().catch(() => ({})));
124
- if (!body.pairingId)
135
+ const parsed = await readJsonObject(c);
136
+ if (!parsed.ok)
137
+ return parsed.response;
138
+ const pairingId = typeof parsed.body.pairingId === 'string' ? parsed.body.pairingId : '';
139
+ if (!pairingId)
125
140
  return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
126
- const ok = ctx.authManager.denyPairing(body.pairingId);
141
+ const ok = ctx.authManager.denyPairing(pairingId);
127
142
  if (!ok)
128
143
  return apiError(c, 404, 'PAIRING_NOT_FOUND', '配对请求不存在或已过期');
129
144
  return c.json({ ok: true });
@@ -5,10 +5,13 @@ import { streamSSE } from 'hono/streaming';
5
5
  import { createAgent, runAgent } from '../../core/agent.js';
6
6
  import { loadConfigScopes, mergeConfig } from '../../core/config.js';
7
7
  import { compactContext } from '../../core/loop.js';
8
+ import { buildRegistryFromConfig } from '../../core/provider-registry.js';
9
+ import { discoverSkills } from '../../core/skills.js';
8
10
  import { createSlashRegistry, isSlashCommandEnabled, parseSlashInput } from '../../core/slash.js';
9
11
  import { injectSteering } from '../../core/steering.js';
10
12
  import { buildWorkflowNotice, containsWorkflow } from '../../core/workflow.js';
11
13
  import { createWorkflowRegistry, discoverWorkflows, executeWorkflow, resolveWorkflow, workflowSessionTitle, } from '../../core/workflows/index.js';
14
+ import { createDebugSpawn } from '../../dap/index.js';
12
15
  import { resolveRoute } from '../../llm/registry.js';
13
16
  import { getProject } from '../../project/project.js';
14
17
  import { enrichProjectRiskWithGlobal, projectTrustNeeded } from '../../project/trust.js';
@@ -19,7 +22,7 @@ import { PROJECT_BOUND_TOOLS, resolveEnabledToolNames } from '../../tools/index.
19
22
  import { autoAllowChecker } from '../../tools/permission.js';
20
23
  import { apiError } from '../middleware/error.js';
21
24
  import { createInteractivePermissionChecker } from '../permission/interactive.js';
22
- import { buildRegistryFromConfig } from '../registry-config.js';
25
+ import { readJsonObject } from '../util/json-body.js';
23
26
  import { safeResolve } from '../util/safe-path.js';
24
27
  /** 按 session.projectId / worktreePath 解析 agent 工作目录:
25
28
  * - 有 projectId → 项目 worktree(缺失/目录失效时抛错,由上层转 409)。
@@ -100,9 +103,17 @@ function createChatRoute(ctx) {
100
103
  const app = new Hono();
101
104
  // POST / — SSE 流式聊天
102
105
  app.post('/', async (c) => {
103
- const body = await c.req.json().catch(() => ({}));
104
- const sessionId = body.sessionId;
105
- const message = body.message;
106
+ // 畸形 JSON / null / 非对象 body → 400(此前 null body 在 body.sessionId 处
107
+ // TypeError 500;数字 sessionId 会一路进 PG 的 uuid 参数 → 22P02 500)。
108
+ const bodyParsed = await readJsonObject(c);
109
+ if (!bodyParsed.ok)
110
+ return bodyParsed.response;
111
+ const { body } = bodyParsed;
112
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined;
113
+ // body 字段形状校验(P3 加固):message 非字符串(number/object)会通过
114
+ // 「!message」检查后经 parseSlashInput 的 .trim() 击穿 TypeError 500——
115
+ // 按缺省处理落入下方空消息 400,而非 500。
116
+ const message = typeof body.message === 'string' ? body.message : undefined;
106
117
  const images = body.images;
107
118
  // P1-5:纯图片消息放行(Composer 允许只带图片不带文本),
108
119
  // 仅当文本与图片皆无时拒绝,错误信息说明可操作原因。
@@ -110,27 +121,41 @@ function createChatRoute(ctx) {
110
121
  return apiError(c, 400, 'BAD_REQUEST', '消息内容不能为空:请输入文字或附带图片后再发送');
111
122
  }
112
123
  // P0:图片大小/数量服务端限额(前端已限制,此为兜底——API 直调/旧客户端)。
113
- if (images) {
124
+ // P3 加固:条目形状(对象 + string mediaType/data + image/ 前缀)显式 400——
125
+ // images 非数组 → for-of TypeError;[null] → 读 null.data TypeError;缺字段
126
+ // 条目此前静默持久化成毒化图片消息(jsonb 丢弃 undefined 字段),后续每轮
127
+ // 上下文重建都携带破损图片,provider 恒 400,会话无法继续。
128
+ if (images !== undefined) {
129
+ if (!Array.isArray(images)) {
130
+ return apiError(c, 400, 'INVALID_IMAGES', 'images 必须是数组');
131
+ }
114
132
  const MAX_IMAGE_COUNT = 6;
115
133
  const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
116
134
  if (images.length > MAX_IMAGE_COUNT) {
117
135
  return apiError(c, 400, 'TOO_MANY_IMAGES', `最多 ${MAX_IMAGE_COUNT} 张图片(收到 ${images.length} 张)`);
118
136
  }
119
137
  for (const img of images) {
138
+ if (img === null ||
139
+ typeof img !== 'object' ||
140
+ Array.isArray(img) ||
141
+ typeof img.mediaType !== 'string' ||
142
+ !img.mediaType.startsWith('image/') ||
143
+ typeof img.data !== 'string' ||
144
+ img.data.length === 0) {
145
+ return apiError(c, 400, 'INVALID_IMAGE', 'images 每项必须是 { mediaType: "image/...", data: "<base64>" }');
146
+ }
120
147
  // P3:按解码字节校验(base64 字符串长度比真实字节约大 33%);
121
148
  // 无法解码(非法 base64/含 dataURL 前缀等)时按字符串长度保守拦截。
122
- if (typeof img.data === 'string') {
123
- let bytes = img.data.length;
124
- try {
125
- bytes = Buffer.from(img.data, 'base64').length;
126
- }
127
- catch {
128
- // 解码失败保留字符串长度兜底
129
- }
130
- if (bytes > MAX_IMAGE_BYTES) {
131
- const mb = (bytes / (1024 * 1024)).toFixed(1);
132
- return apiError(c, 400, 'IMAGE_TOO_LARGE', `图片大小 ${mb}MB 超过上限 ${MAX_IMAGE_BYTES / (1024 * 1024)}MB`);
133
- }
149
+ let bytes = img.data.length;
150
+ try {
151
+ bytes = Buffer.from(img.data, 'base64').length;
152
+ }
153
+ catch {
154
+ // 解码失败保留字符串长度兜底
155
+ }
156
+ if (bytes > MAX_IMAGE_BYTES) {
157
+ const mb = (bytes / (1024 * 1024)).toFixed(1);
158
+ return apiError(c, 400, 'IMAGE_TOO_LARGE', `图片大小 ${mb}MB 超过上限 ${MAX_IMAGE_BYTES / (1024 * 1024)}MB`);
134
159
  }
135
160
  }
136
161
  }
@@ -293,7 +318,7 @@ function createChatRoute(ctx) {
293
318
  try {
294
319
  workflowSession = await createSession(ctx.db, workflowSessionTitle(name), session.projectId ?? undefined, 'workflow', undefined, undefined, cwd,
295
320
  // 中断恢复指引需要工作流名(重启后会话内无 user 消息可重发)。
296
- { workflowName: name });
321
+ { workflowName: name }, ctx.hookRunner);
297
322
  }
298
323
  catch (e) {
299
324
  ctx.workflowBusyByScope.delete(scopeKey);
@@ -725,6 +750,10 @@ function createChatRoute(ctx) {
725
750
  const files = body.files;
726
751
  if (files?.length) {
727
752
  for (const p of files) {
753
+ // 非字符串条目(number/object)进 safeResolve 会让 node:path resolve
754
+ // 抛 TypeError 击穿 500——按「读取失败静默跳过」同口径忽略。
755
+ if (typeof p !== 'string' || p.length === 0)
756
+ continue;
728
757
  const resolved = safeResolve(cwd, p);
729
758
  if (!resolved)
730
759
  continue;
@@ -911,6 +940,10 @@ function createChatRoute(ctx) {
911
940
  agentRegistry: ctx.agentRegistry,
912
941
  registerChildRun: childRunBridge(ctx.agentManager),
913
942
  cwd,
943
+ // 技能发现(system prompt ## Loaded Skills 段数据源)。
944
+ skills: discoverSkills(cwd),
945
+ // DAP(spec §21):Web 宿主注入真实适配器 spawn(与 CLI 同口径)。
946
+ debugSpawn: createDebugSpawn(),
914
947
  // P3 成本护栏:会话项目配置任一轴动作为 'pause'/'abort' 时启用预算检查。
915
948
  // 具体 pause/abort 由 budgetOverageParts 按超支轴配置动作判定(abort >
916
949
  // pause),此处仅注入「启用」门禁——修 P1 双轴动作坍缩(此前 pause 分支
@@ -1059,7 +1092,18 @@ function createChatRoute(ctx) {
1059
1092
  ? apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试')
1060
1093
  : undefined;
1061
1094
  app.post('/abort', async (c) => {
1062
- const { sessionId } = await c.req.json();
1095
+ // 畸形 JSON / 非对象 body / 缺 sessionId → 400(与其他写端点同口径)。
1096
+ // 此前裸 destructure:畸形 JSON 击穿 Hono 500,null body 在解构处
1097
+ // TypeError 500,缺 sessionId 静默回 { aborted: false }。
1098
+ const body = (await c.req.json().catch(() => null));
1099
+ const sessionId = body !== null &&
1100
+ typeof body === 'object' &&
1101
+ !Array.isArray(body) &&
1102
+ typeof body.sessionId === 'string'
1103
+ ? body.sessionId
1104
+ : '';
1105
+ if (!sessionId)
1106
+ return apiError(c, 400, 'BAD_REQUEST', 'sessionId is required');
1063
1107
  // 工作流运行期间发起会话无主 run:中止请求路由到实际的工作流 run。
1064
1108
  const busyWorkflowId = ctx.workflowBusyBySession.get(sessionId);
1065
1109
  if (busyWorkflowId) {
@@ -1068,7 +1112,15 @@ function createChatRoute(ctx) {
1068
1112
  return runStarting(c, sessionId) ?? c.json({ aborted: ctx.agentManager.abort(sessionId) });
1069
1113
  });
1070
1114
  app.post('/pause', async (c) => {
1071
- const { sessionId } = await c.req.json();
1115
+ const body = (await c.req.json().catch(() => null));
1116
+ const sessionId = body !== null &&
1117
+ typeof body === 'object' &&
1118
+ !Array.isArray(body) &&
1119
+ typeof body.sessionId === 'string'
1120
+ ? body.sessionId
1121
+ : '';
1122
+ if (!sessionId)
1123
+ return apiError(c, 400, 'BAD_REQUEST', 'sessionId is required');
1072
1124
  // 级联子 run(P1:子 agent 现已注册,暂停主 run 必须一并暂停——
1073
1125
  // 否则后台子 agent 在用户认为「已暂停」时继续执行)。
1074
1126
  const busyWorkflowId = ctx.workflowBusyBySession.get(sessionId);
@@ -1082,7 +1134,15 @@ function createChatRoute(ctx) {
1082
1134
  return c.json({ paused });
1083
1135
  });
1084
1136
  app.post('/resume', async (c) => {
1085
- const { sessionId } = await c.req.json();
1137
+ const body = (await c.req.json().catch(() => null));
1138
+ const sessionId = body !== null &&
1139
+ typeof body === 'object' &&
1140
+ !Array.isArray(body) &&
1141
+ typeof body.sessionId === 'string'
1142
+ ? body.sessionId
1143
+ : '';
1144
+ if (!sessionId)
1145
+ return apiError(c, 400, 'BAD_REQUEST', 'sessionId is required');
1086
1146
  // 权限超时暂停工作流后,发起会话展示「恢复」按钮——路由到工作流 run 真正恢复。
1087
1147
  // 级联恢复子 run:暂停时子 run 一并暂停,仅恢复主 run 会让父的工具批次
1088
1148
  // 永远等待已暂停的子 agent(死锁)。
@@ -1097,8 +1157,14 @@ function createChatRoute(ctx) {
1097
1157
  return c.json({ resumed });
1098
1158
  });
1099
1159
  app.post('/steer', async (c) => {
1100
- const body = await c.req.json();
1101
- const starting = runStarting(c, body.sessionId);
1160
+ const body = (await c.req.json().catch(() => null));
1161
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
1162
+ return apiError(c, 400, 'BAD_REQUEST', '请求体必须是 JSON 对象');
1163
+ }
1164
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
1165
+ if (!sessionId)
1166
+ return apiError(c, 400, 'BAD_REQUEST', 'sessionId is required');
1167
+ const starting = runStarting(c, sessionId);
1102
1168
  if (starting)
1103
1169
  return starting;
1104
1170
  const message = typeof body.message === 'string' ? body.message.trim() : '';
@@ -1106,14 +1172,14 @@ function createChatRoute(ctx) {
1106
1172
  return c.json({ steered: false });
1107
1173
  // P1 工作流路由:工作流运行期间 steer 目标为实际的 workflow run
1108
1174
  //(此前 steer 恒 false、指令静默丢失,用户以为已生效)。
1109
- const busyWorkflowId = ctx.workflowBusyBySession.get(body.sessionId);
1110
- const steered = ctx.agentManager.steer(busyWorkflowId ?? body.sessionId, message);
1175
+ const busyWorkflowId = ctx.workflowBusyBySession.get(sessionId);
1176
+ const steered = ctx.agentManager.steer(busyWorkflowId ?? sessionId, message);
1111
1177
  // P0 闭环:steering 持久化为会话条目——用户运行中输入追加指令曾是内存瞬态,
1112
1178
  // 刷新后彻底丢失;现在与消息同样入史(context 重建时作为 system 消息注入,
1113
1179
  // 时间线经 /messages 渲染为 user steering 块)。
1114
1180
  if (steered) {
1115
1181
  await insertEntry(ctx.db, {
1116
- sessionId: body.sessionId,
1182
+ sessionId,
1117
1183
  tag: 'steering',
1118
1184
  content: { text: message },
1119
1185
  }).catch(() => {
@@ -10,14 +10,19 @@ import { syncRegistryFromConfig } from '../server.js';
10
10
  /** 检查每个 provider 的 apiKey 能否在本机解密(机器绑定密钥换机/容器重建后会失败)。 */
11
11
  function providerApiKeyWarnings(providers) {
12
12
  const warnings = [];
13
- for (const p of providers) {
14
- if (!p.apiKey || !isEncryptedSecret(p.apiKey))
13
+ for (const entry of providers) {
14
+ // 手改/旧版配置文件的畸形条目:跳过错报(registry 构建侧同口径跳过)。
15
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
16
+ continue;
17
+ const p = entry;
18
+ const apiKey = p.apiKey;
19
+ if (typeof apiKey !== 'string' || apiKey.length === 0 || !isEncryptedSecret(apiKey))
15
20
  continue;
16
21
  try {
17
- decryptSecret(p.apiKey);
22
+ decryptSecret(apiKey);
18
23
  }
19
24
  catch {
20
- warnings.push(`provider "${p.name || ''}" 的 apiKey 无法在本机解密(配置来自其他机器),请重新设置`);
25
+ warnings.push(`provider "${(typeof p.name === 'string' ? p.name : '') || ''}" 的 apiKey 无法在本机解密(配置来自其他机器),请重新设置`);
21
26
  }
22
27
  }
23
28
  return warnings;
@@ -133,7 +138,12 @@ function createConfigRoute(ctx) {
133
138
  // 仅当写入目标是服务启动目录项目时同步服务级内存配置与 LLM registry——
134
139
  // 其他项目的配置不污染服务级 provider 注册表(会话级按需解析)。
135
140
  app.patch('/', async (c) => {
136
- const body = (await c.req.json());
141
+ // 畸形 JSON / 非对象 body → 400(此前裸 await c.req.json():畸形 JSON
142
+ // 击穿 Hono 500,null body 在 body.scope 处 TypeError 500)。
143
+ const body = (await c.req.json().catch(() => null));
144
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
145
+ return apiError(c, 400, 'BAD_REQUEST', '请求体必须是 JSON 对象');
146
+ }
137
147
  const scope = body.scope === 'global' ? 'global' : 'project';
138
148
  const { scope: _omit, projectId, ...patch } = body;
139
149
  // 未知顶层键拒绝:拼写错误/过时键经此路径写入只会埋雷(永不生效且难发现)。
@@ -158,9 +168,18 @@ function createConfigRoute(ctx) {
158
168
  }
159
169
  }
160
170
  // spec §24.2:provider apiKey 落盘前加密,明文不持久化。
161
- // 已加密(enc: 前缀)或无 apiKey 的透传。
171
+ // 已加密(enc: 前缀)或无 apiKey 的透传。映射须对畸形条目空安全:
172
+ // [null] 此前在 p.apiKey 处 TypeError 击穿 500——畸形条目原样保留,
173
+ // 由下方 applyScopedPatch 的 providers 形状校验显式 400。
162
174
  if (Array.isArray(patch.providers)) {
163
- patch.providers = patch.providers.map((p) => p.apiKey && !isEncryptedSecret(p.apiKey) ? { ...p, apiKey: encryptSecret(p.apiKey) } : p);
175
+ patch.providers = patch.providers.map((entry) => {
176
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
177
+ return entry;
178
+ const p = entry;
179
+ return typeof p.apiKey === 'string' && p.apiKey.length > 0 && !isEncryptedSecret(p.apiKey)
180
+ ? { ...p, apiKey: encryptSecret(p.apiKey) }
181
+ : entry;
182
+ });
164
183
  }
165
184
  // 按作用域最小落盘:patch 只合并进指定作用域原始文件,
166
185
  // 不把合并结果(含默认值/另一作用域配置)整体序列化进文件。
@@ -169,9 +188,18 @@ function createConfigRoute(ctx) {
169
188
  if (!target)
170
189
  return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
171
190
  const scopes = loadConfigScopes(target.dir);
172
- const nextScoped = scope === 'global'
173
- ? applyScopedPatch(scopes.global ?? {}, patch)
174
- : applyScopedPatch(scopes.project ?? {}, patch);
191
+ // applyScopedPatch 拒绝非有限数值(JSON 1e999 → Infinity 落盘为 null、
192
+ // 键被静默删除)——转成 400 明示,而非 500 或不透因。
193
+ let nextScoped;
194
+ try {
195
+ nextScoped =
196
+ scope === 'global'
197
+ ? applyScopedPatch(scopes.global ?? {}, patch)
198
+ : applyScopedPatch(scopes.project ?? {}, patch);
199
+ }
200
+ catch (err) {
201
+ return apiError(c, 400, 'INVALID_CONFIG_VALUE', err instanceof Error ? err.message : String(err));
202
+ }
175
203
  // P2-3:落盘失败必须反馈给前端(此前静默吞掉,UI 显示已保存但重启后丢失)。
176
204
  // 先落盘、成功后才更新内存配置与 registry,保证「已保存」反馈与磁盘状态一致。
177
205
  try {