c0de-agent 1.12.0 → 1.13.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 (142) hide show
  1. package/README.md +51 -23
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +70 -6
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +46 -0
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +51 -4
  8. package/dist/cli/commands/trust.d.ts +11 -0
  9. package/dist/cli/commands/trust.js +31 -0
  10. package/dist/cli/commands/update.js +4 -0
  11. package/dist/cli/deps.d.ts +9 -6
  12. package/dist/cli/deps.js +66 -19
  13. package/dist/cli/index.d.ts +4 -1
  14. package/dist/cli/index.js +92 -12
  15. package/dist/cli/modes/acp.js +39 -8
  16. package/dist/cli/modes/print.d.ts +3 -0
  17. package/dist/cli/modes/print.js +71 -8
  18. package/dist/core/agent.d.ts +1 -1
  19. package/dist/core/agent.js +9 -4
  20. package/dist/core/agents/builtin.js +2 -2
  21. package/dist/core/agents/index.d.ts +0 -1
  22. package/dist/core/agents/index.js +0 -1
  23. package/dist/core/config.d.ts +43 -1
  24. package/dist/core/config.js +163 -14
  25. package/dist/core/loop/segment.d.ts +3 -1
  26. package/dist/core/loop/segment.js +24 -1
  27. package/dist/core/loop/subagent.js +65 -32
  28. package/dist/core/loop.d.ts +7 -0
  29. package/dist/core/loop.js +90 -6
  30. package/dist/core/slash.d.ts +10 -0
  31. package/dist/core/slash.js +150 -20
  32. package/dist/core/types.d.ts +20 -1
  33. package/dist/core/workflows/context.js +44 -4
  34. package/dist/core/workflows/discovery.d.ts +2 -2
  35. package/dist/core/workflows/discovery.js +5 -7
  36. package/dist/core/workflows/index.d.ts +2 -1
  37. package/dist/core/workflows/index.js +2 -1
  38. package/dist/core/workflows/registry.d.ts +13 -3
  39. package/dist/core/workflows/registry.js +14 -9
  40. package/dist/core/workflows/resolve.d.ts +22 -0
  41. package/dist/core/workflows/resolve.js +23 -0
  42. package/dist/core/workflows/runtime.d.ts +6 -1
  43. package/dist/core/workflows/runtime.js +19 -2
  44. package/dist/db/schema.d.ts +356 -1
  45. package/dist/db/schema.js +57 -3
  46. package/dist/kanban/index.d.ts +2 -1
  47. package/dist/kanban/index.js +1 -1
  48. package/dist/kanban/store.d.ts +84 -2
  49. package/dist/kanban/store.js +358 -25
  50. package/dist/llm/registry.d.ts +7 -1
  51. package/dist/llm/registry.js +7 -1
  52. package/dist/plugins/init.d.ts +7 -0
  53. package/dist/plugins/init.js +22 -1
  54. package/dist/plugins/loader.d.ts +7 -1
  55. package/dist/plugins/loader.js +12 -4
  56. package/dist/project/index.d.ts +1 -1
  57. package/dist/project/index.js +1 -1
  58. package/dist/project/project.d.ts +23 -0
  59. package/dist/project/project.js +65 -0
  60. package/dist/project/trust.d.ts +65 -0
  61. package/dist/project/trust.js +328 -0
  62. package/dist/server/agent-manager.d.ts +6 -2
  63. package/dist/server/agent-manager.js +4 -2
  64. package/dist/server/app.js +45 -30
  65. package/dist/server/auth-manager.d.ts +15 -4
  66. package/dist/server/auth-manager.js +41 -24
  67. package/dist/server/context.js +11 -1
  68. package/dist/server/index.d.ts +1 -1
  69. package/dist/server/index.js +1 -1
  70. package/dist/server/middleware/auth.d.ts +11 -1
  71. package/dist/server/middleware/auth.js +25 -3
  72. package/dist/server/permission/interactive.d.ts +5 -0
  73. package/dist/server/permission/interactive.js +4 -1
  74. package/dist/server/permission/store.d.ts +2 -0
  75. package/dist/server/permission/store.js +8 -0
  76. package/dist/server/routes/auth.js +35 -9
  77. package/dist/server/routes/chat.d.ts +16 -7
  78. package/dist/server/routes/chat.js +483 -28
  79. package/dist/server/routes/commands.d.ts +2 -2
  80. package/dist/server/routes/commands.js +4 -6
  81. package/dist/server/routes/config.js +81 -4
  82. package/dist/server/routes/files.js +120 -24
  83. package/dist/server/routes/kanban.js +152 -22
  84. package/dist/server/routes/permissions.js +7 -0
  85. package/dist/server/routes/project.js +76 -22
  86. package/dist/server/routes/provider.js +6 -0
  87. package/dist/server/routes/session.js +177 -13
  88. package/dist/server/routes/tool.js +24 -5
  89. package/dist/server/routes/update.js +58 -2
  90. package/dist/server/routes/usage.d.ts +4 -0
  91. package/dist/server/routes/usage.js +133 -0
  92. package/dist/server/routes/workflows.js +476 -87
  93. package/dist/server/server.d.ts +12 -1
  94. package/dist/server/server.js +194 -33
  95. package/dist/server/terminal/pty-manager.d.ts +12 -0
  96. package/dist/server/terminal/pty-manager.js +63 -7
  97. package/dist/server/types.d.ts +9 -0
  98. package/dist/session/branch.d.ts +2 -1
  99. package/dist/session/branch.js +75 -10
  100. package/dist/session/context.js +28 -2
  101. package/dist/session/index.d.ts +1 -1
  102. package/dist/session/index.js +1 -1
  103. package/dist/session/session.d.ts +67 -10
  104. package/dist/session/session.js +180 -67
  105. package/dist/session/types.d.ts +2 -0
  106. package/dist/session/usage.d.ts +62 -0
  107. package/dist/session/usage.js +194 -0
  108. package/dist/shared/types/agent.d.ts +19 -3
  109. package/dist/shared/types/config.d.ts +59 -4
  110. package/dist/shared/types/kanban.d.ts +4 -2
  111. package/dist/shared/types/message.d.ts +14 -0
  112. package/dist/tools/builtin/kanban.js +13 -2
  113. package/dist/tools/index.d.ts +14 -3
  114. package/dist/tools/index.js +23 -5
  115. package/dist/tools/websearch/index.d.ts +5 -1
  116. package/dist/tools/websearch/index.js +40 -4
  117. package/dist/tools/websearch/providers/brave.js +2 -2
  118. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  119. package/dist/tools/websearch/providers/tavily.js +2 -2
  120. package/dist/tools/websearch/types.d.ts +5 -1
  121. package/dist/tools/websearch/types.js +7 -1
  122. package/dist/update/scheduler.js +5 -2
  123. package/dist/update/snapshot.d.ts +17 -3
  124. package/dist/update/snapshot.js +3 -2
  125. package/dist/update/updater.d.ts +2 -0
  126. package/dist/update/updater.js +2 -1
  127. package/dist/update/version.d.ts +3 -0
  128. package/dist/update/version.js +9 -3
  129. package/drizzle/0010_usage_events_ledger.sql +17 -0
  130. package/drizzle/0011_slow_slapstick.sql +12 -0
  131. package/drizzle/0012_chief_timeslip.sql +1 -0
  132. package/drizzle/0013_swift_zeigeist.sql +1 -0
  133. package/drizzle/0014_flimsy_legion.sql +1 -0
  134. package/drizzle/meta/0010_snapshot.json +1010 -0
  135. package/drizzle/meta/0011_snapshot.json +1055 -0
  136. package/drizzle/meta/0012_snapshot.json +1062 -0
  137. package/drizzle/meta/0013_snapshot.json +1068 -0
  138. package/drizzle/meta/0014_snapshot.json +1075 -0
  139. package/drizzle/meta/_journal.json +35 -0
  140. package/package.json +24 -23
  141. package/dist/core/agents/discovery.d.ts +0 -6
  142. package/dist/core/agents/discovery.js +0 -96
@@ -1,7 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import type { ServerContext } from '../types.js';
3
3
  /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。
4
- * 按 config.slashCommands.enabled 过滤:enabled 为空 = 全部启用;
5
- * 否则只返回启用项,保证弹窗发现性 = 可执行性(避免展示必被拒的命令)。 */
4
+ * 按 config.slashCommands.enabled 过滤:enabled 为空 = 全部禁用(fail-closed);
5
+ * 含 ['*'] = 全部启用;否则只返回启用项,保证弹窗发现性 = 可执行性(避免展示必被拒的命令)。 */
6
6
  declare function createCommandsRoute(ctx: ServerContext): Hono;
7
7
  export { createCommandsRoute };
@@ -1,17 +1,15 @@
1
1
  import { Hono } from 'hono';
2
- import { createSlashRegistry } from '../../core/slash.js';
2
+ import { createSlashRegistry, isSlashCommandEnabled } from '../../core/slash.js';
3
3
  /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。
4
- * 按 config.slashCommands.enabled 过滤:enabled 为空 = 全部启用;
5
- * 否则只返回启用项,保证弹窗发现性 = 可执行性(避免展示必被拒的命令)。 */
4
+ * 按 config.slashCommands.enabled 过滤:enabled 为空 = 全部禁用(fail-closed);
5
+ * 含 ['*'] = 全部启用;否则只返回启用项,保证弹窗发现性 = 可执行性(避免展示必被拒的命令)。 */
6
6
  function createCommandsRoute(ctx) {
7
7
  const app = new Hono();
8
8
  app.get('/', (c) => {
9
9
  const registry = createSlashRegistry();
10
- const enabledList = ctx.config.slashCommands?.enabled ?? [];
11
- const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
12
10
  const commands = registry
13
11
  .list()
14
- .filter((cmd) => enabledSet.size === 0 || enabledSet.has(cmd.name))
12
+ .filter((cmd) => isSlashCommandEnabled(ctx.config.slashCommands?.enabled, cmd.name))
15
13
  .map((cmd) => ({
16
14
  name: cmd.name,
17
15
  description: cmd.description,
@@ -1,10 +1,10 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { Hono } from 'hono';
4
- import { applyScopedPatch, collectUnknownConfigKeys, KNOWN_CONFIG_KEYS, loadConfigScopes, mergeConfig, saveConfigScoped, } from '../../core/config.js';
4
+ import { applyScopedPatch, collectConfigMigrationWarnings, collectUnknownConfigKeys, GLOBAL_ONLY_USAGE_KEYS, KNOWN_CONFIG_KEYS, loadConfigScopes, loadProjectRawScope, mergeConfig, projectGlobalOnlyUsageKeys, projectSecurityKeys, saveConfigScoped, } from '../../core/config.js';
5
5
  import { containsSecrets } from '../../core/redact.js';
6
6
  import { decryptSecret, encryptSecret, isEncryptedSecret } from '../../core/secret.js';
7
- import { getProject } from '../../project/project.js';
7
+ import { getByDirectory, getProject, trustProject } from '../../project/index.js';
8
8
  import { apiError } from '../middleware/error.js';
9
9
  import { syncRegistryFromConfig } from '../server.js';
10
10
  /** 检查每个 provider 的 apiKey 能否在本机解密(机器绑定密钥换机/容器重建后会失败)。 */
@@ -69,14 +69,62 @@ function createConfigRoute(ctx) {
69
69
  ...collectUnknownConfigKeys(scopes.global).map((k) => `全局配置含未知键 "${k}",不会生效`),
70
70
  ...collectUnknownConfigKeys(scopes.project).map((k) => `项目配置含未知键 "${k}",不会生效`),
71
71
  ];
72
+ // 作用域收敛:项目配置里遗留的全局口径预算键加载时已被剥离(不再生效),在此明示。
73
+ const strippedGlobalKeys = projectGlobalOnlyUsageKeys(target.dir);
74
+ const globalBudgetWarnings = strippedGlobalKeys.map((k) => `项目配置含全局口径预算键 "${k}",已忽略(该键仅在全局作用域生效,请改在全局配置设置)`);
75
+ // P:security 收敛为 global-only(此前克隆仓库可静默改写服务端鉴权/CORS 参数)。
76
+ const strippedSecurityKeys = projectSecurityKeys(target.dir);
77
+ const securityWarnings = strippedSecurityKeys.length > 0
78
+ ? [
79
+ `项目配置含 security 键(${strippedSecurityKeys.join('、')},服务端全局参数),已忽略——` +
80
+ '请在设置页切换到「全局」作用域修改安全设置。',
81
+ ]
82
+ : [];
83
+ // P:未归属调用(CLI 临时会话/未绑定项目的续接会话)只受全局口径预算兜底。
84
+ // 仅设项目预算、未设全局预算时这些调用零护栏——在设置页明示而非静默裸奔。
85
+ const budgetScopeWarnings = [];
86
+ const hasProjectBudget = (config.usage.monthlyBudgetUsd ?? 0) > 0 || (config.usage.monthlyTokenBudget ?? 0) > 0;
87
+ const hasGlobalBudget = (config.usage.globalMonthlyBudgetUsd ?? 0) > 0 ||
88
+ (config.usage.globalMonthlyTokenBudget ?? 0) > 0;
89
+ if (hasProjectBudget && !hasGlobalBudget) {
90
+ budgetScopeWarnings.push('已设置项目预算但未设置全局预算:CLI(c0de chat)与未归属项目的调用不受任何预算护栏约束。' +
91
+ '建议在设置页切换到「全局」作用域补充全局月度预算。');
92
+ }
93
+ // P0-1:语义翻转/易混键(tools.enabled/slashCommands.enabled 空数组)在 Web 可见。
94
+ const migrationWarnings = [
95
+ ...collectConfigMigrationWarnings('global', scopes.global),
96
+ ...collectConfigMigrationWarnings('project', scopes.project),
97
+ ];
98
+ // P2-1 / P2-2:无可用 provider 与项目 providers 空数组覆盖本机全局 providers。
99
+ const providerWarnings = [];
100
+ if (config.providers.length === 0) {
101
+ providerWarnings.push('尚未配置任何 AI 服务(providers 为空)。请到「设置 → Provider」添加并测试连接,否则无法发起对话。');
102
+ }
103
+ const projProviders = scopes.project?.providers;
104
+ const globalProviders = scopes.global?.providers;
105
+ if (Array.isArray(projProviders) &&
106
+ projProviders.length === 0 &&
107
+ Array.isArray(globalProviders) &&
108
+ globalProviders.length > 0) {
109
+ providerWarnings.push(`项目配置的 providers 为空数组,会整体替换本机全局 providers(${globalProviders.length} 项),` +
110
+ '导致无可用服务。请删除项目配置中的 providers 键,或在项目配置中显式填写服务。');
111
+ }
72
112
  return c.json({
73
113
  config,
74
114
  scopes: {
75
115
  global: scopes.global ?? null,
76
116
  project: scopes.project ?? null,
77
117
  },
78
- warnings: [...providerApiKeyWarnings(config.providers), ...unknownWarnings],
79
- gitWarning: projectConfigGitWarning(scopes.project, target.dir),
118
+ warnings: [
119
+ ...providerApiKeyWarnings(config.providers),
120
+ ...globalBudgetWarnings,
121
+ ...securityWarnings,
122
+ ...unknownWarnings,
123
+ ...migrationWarnings,
124
+ ...providerWarnings,
125
+ ...budgetScopeWarnings,
126
+ ],
127
+ gitWarning: projectConfigGitWarning(loadProjectRawScope(target.dir), target.dir),
80
128
  projectDir: target.isProjectScoped ? target.dir : undefined,
81
129
  });
82
130
  });
@@ -93,6 +141,22 @@ function createConfigRoute(ctx) {
93
141
  if (unknown.length > 0) {
94
142
  return apiError(c, 400, 'UNKNOWN_CONFIG_KEYS', `未知配置键:${unknown.join(', ')}。有效顶层键:${[...KNOWN_CONFIG_KEYS].join(', ')}`);
95
143
  }
144
+ // 作用域收敛:security 是服务端全局参数,仅 global 作用域生效(项目作用域读取时被剥离)。
145
+ // 项目作用域写入会被静默忽略,与其落盘后再丢弃,不如直接拒绝并给出正解路径。
146
+ if (scope === 'project' && patch.security !== undefined) {
147
+ return apiError(c, 400, 'SECURITY_IN_PROJECT_SCOPE', 'security 是服务端全局参数,仅在全局作用域生效。请在设置页切换到「全局」作用域后再修改。');
148
+ }
149
+ // 作用域收敛:usage 的全局口径预算键仅 global 作用域生效(项目作用域读取时被剥离)。
150
+ // 项目作用域写入这些键会被静默忽略,与其落盘后再丢弃,不如直接拒绝并给出正解路径。
151
+ if (scope === 'project' &&
152
+ typeof patch.usage === 'object' &&
153
+ patch.usage !== null &&
154
+ !Array.isArray(patch.usage)) {
155
+ const offending = GLOBAL_ONLY_USAGE_KEYS.filter((k) => k in patch.usage);
156
+ if (offending.length > 0) {
157
+ return apiError(c, 400, 'GLOBAL_BUDGET_IN_PROJECT_SCOPE', `「${offending.join(' / ')}」是全局口径预算,仅在全局作用域生效。请在设置页切换到「全局」作用域后再修改。`);
158
+ }
159
+ }
96
160
  // spec §24.2:provider apiKey 落盘前加密,明文不持久化。
97
161
  // 已加密(enc: 前缀)或无 apiKey 的透传。
98
162
  if (Array.isArray(patch.providers)) {
@@ -116,6 +180,19 @@ function createConfigRoute(ctx) {
116
180
  catch (err) {
117
181
  return apiError(c, 500, 'CONFIG_SAVE_FAILED', `配置保存失败:${err instanceof Error ? err.message : String(err)}`);
118
182
  }
183
+ // P1:项目作用域写入是用户经产品入口的显式操作——已信任项目刷新风险指纹
184
+ //(与工作流 create/delete 同口径)。不刷新会让用户自己的设置修改触发
185
+ // trust-drift 复检,「信任 → 改设置 → 再信任」自锁循环。
186
+ if (scope === 'project') {
187
+ try {
188
+ const p = await getByDirectory(ctx.db, target.dir);
189
+ if (p?.trustedAt != null)
190
+ await trustProject(ctx.db, p.id);
191
+ }
192
+ catch {
193
+ // 指纹刷新失败不阻塞配置保存(最坏回到 fail-closed 复检路径)
194
+ }
195
+ }
119
196
  const isServerCwdProject = target.dir === ctx.cwd;
120
197
  if (isServerCwdProject || scope === 'global') {
121
198
  // global 作用域写入影响所有项目(含服务级 provider 注册表),
@@ -10,11 +10,31 @@ import { appendToGitignore, checkIgnored, checkoutGitBranch, createGitBranch, ge
10
10
  import { apiError } from '../middleware/error.js';
11
11
  import { buildRegistryFromConfig } from '../registry-config.js';
12
12
  import { safeResolve } from '../util/safe-path.js';
13
- /** 递归搜索时跳过的目录(体积大/为元数据噪音,避免递归进入)。 */
14
- const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules']);
15
- /** 递归收集文件列表(用于搜索)。 */
16
- async function collectFiles(dir, basePath, maxDepth = 5) {
17
- if (maxDepth < 0)
13
+ /** 递归搜索时跳过的目录(体积大/为元数据噪音,避免递归进入)。
14
+ * P3:补齐常见构建/缓存产物目录——此前只跳 .git/node_modules,
15
+ * 大仓库每击键全量 walk 时 dist/.cache 等目录拖垮延迟。 */
16
+ const SEARCH_SKIP_DIRS = new Set([
17
+ '.git',
18
+ 'node_modules',
19
+ 'dist',
20
+ 'build',
21
+ '.cache',
22
+ 'coverage',
23
+ '.next',
24
+ '.nuxt',
25
+ '.turbo',
26
+ '.parcel-cache',
27
+ '.vite',
28
+ 'target',
29
+ 'vendor',
30
+ '__pycache__',
31
+ ]);
32
+ /** P3-14:递归收集的结果上限——大仓库无上限返回会拖垮前端渲染。 */
33
+ const SEARCH_MAX_RESULTS = 5000;
34
+ /** 递归收集文件列表(用于搜索)。P3:深度上限 5 → 8,深层文件此前搜不到。
35
+ * P3-14:达结果上限即停止遍历,防大仓库全量 walk。 */
36
+ async function collectFiles(dir, basePath, maxDepth = 8, budget = SEARCH_MAX_RESULTS) {
37
+ if (maxDepth < 0 || budget <= 0)
18
38
  return [];
19
39
  const results = [];
20
40
  let entries;
@@ -25,13 +45,16 @@ async function collectFiles(dir, basePath, maxDepth = 5) {
25
45
  return [];
26
46
  }
27
47
  for (const entry of entries) {
48
+ if (results.length >= budget)
49
+ break;
28
50
  if (entry.isDirectory() && SEARCH_SKIP_DIRS.has(entry.name))
29
51
  continue;
30
52
  const fullPath = join(dir, entry.name);
31
53
  const relPath = relative(basePath, fullPath);
32
54
  if (entry.isDirectory()) {
33
55
  results.push({ path: relPath, type: 'directory' });
34
- results.push(...(await collectFiles(fullPath, basePath, maxDepth - 1)));
56
+ const rest = await collectFiles(fullPath, basePath, maxDepth - 1, budget - results.length);
57
+ results.push(...rest);
35
58
  }
36
59
  else {
37
60
  results.push({ path: relPath, type: 'file' });
@@ -58,7 +81,6 @@ function contentTypeFor(name) {
58
81
  webm: 'video/webm',
59
82
  mov: 'video/quicktime',
60
83
  json: 'application/json; charset=utf-8',
61
- html: 'text/html; charset=utf-8',
62
84
  md: 'text/markdown; charset=utf-8',
63
85
  txt: 'text/plain; charset=utf-8',
64
86
  ts: 'text/plain; charset=utf-8',
@@ -66,6 +88,51 @@ function contentTypeFor(name) {
66
88
  };
67
89
  return map[ext] ?? 'application/octet-stream';
68
90
  }
91
+ /** P3:.gitignore 追加条目的白名单式校验。suggestions 源自 LLM 输出并经前端
92
+ * 「批准」回传——服务端兜底拒绝换行注入(一条变多条)、全局通配(`*` 直接
93
+ * 忽略整个仓库)与超长/超量条目。合法返回规范化的 trim 后数组,非法返回 null。 */
94
+ function sanitizeIgnoreSuggestions(raw) {
95
+ if (!Array.isArray(raw))
96
+ return null;
97
+ if (raw.length === 0 || raw.length > 100)
98
+ return null;
99
+ const out = [];
100
+ for (const item of raw) {
101
+ if (typeof item !== 'string')
102
+ return null;
103
+ const p = item.trim();
104
+ if (p.length === 0 || p.length > 200)
105
+ return null;
106
+ if (p.includes('\n') || p.includes('\r'))
107
+ return null;
108
+ if (p === '*' || p === '/*' || p === '/')
109
+ return null;
110
+ out.push(p);
111
+ }
112
+ return out.length > 0 ? out : null;
113
+ }
114
+ function checkCommitMessage(raw) {
115
+ if (typeof raw !== 'string')
116
+ return { ok: false, reason: 'EMPTY' };
117
+ const message = raw.trim();
118
+ if (!message)
119
+ return { ok: false, reason: 'EMPTY' };
120
+ if (message.length > 500)
121
+ return { ok: false, reason: 'TOO_LONG' };
122
+ if (/[\r\n]/.test(message))
123
+ return { ok: false, reason: 'MULTILINE' };
124
+ return { ok: true, message };
125
+ }
126
+ /** force/append-ignore 模式的 message 校验错误码映射(保持既有 API 契约)。 */
127
+ function rejectCommitMessage(c, reason, mode) {
128
+ if (reason === 'EMPTY') {
129
+ return apiError(c, 400, 'MISSING_MESSAGE', `mode=${mode} requires a message`);
130
+ }
131
+ if (reason === 'TOO_LONG') {
132
+ return apiError(c, 400, 'MESSAGE_TOO_LONG', 'commit message must be at most 500 characters');
133
+ }
134
+ return apiError(c, 400, 'BAD_REQUEST', 'commit message must be a single line');
135
+ }
69
136
  function createFilesRoute(ctx) {
70
137
  const app = new Hono();
71
138
  // git 状态:返回 path → 状态分类 的映射(非 git 返回空对象)
@@ -128,36 +195,39 @@ function createFilesRoute(ctx) {
128
195
  .catch(() => ({}));
129
196
  // --- mode: force — 跳过检查,用传入 message 直接提交 ---
130
197
  if (body.mode === 'force') {
131
- if (!body.message) {
132
- return apiError(c, 400, 'MISSING_MESSAGE', 'mode=force requires a message');
133
- }
134
- const result = performGitCommit(root, body.message);
198
+ const check = checkCommitMessage(body.message);
199
+ if (!check.ok)
200
+ return rejectCommitMessage(c, check.reason, 'force');
201
+ const message = check.message;
202
+ const result = performGitCommit(root, message);
135
203
  if ('error' in result) {
136
204
  return apiError(c, 500, 'COMMIT_FAILED', result.error);
137
205
  }
138
206
  return c.json({
139
207
  committed: true,
140
- message: body.message,
208
+ message,
141
209
  hash: result.hash,
142
210
  fileCount: summary.fileCount,
143
211
  });
144
212
  }
145
213
  // --- mode: append-ignore — 追加 .gitignore 后提交 ---
146
214
  if (body.mode === 'append-ignore') {
147
- if (!body.message) {
148
- return apiError(c, 400, 'MISSING_MESSAGE', 'mode=append-ignore requires a message');
149
- }
150
- if (!body.suggestions || body.suggestions.length === 0) {
151
- return apiError(c, 400, 'MISSING_SUGGESTIONS', 'mode=append-ignore requires suggestions');
215
+ const check = checkCommitMessage(body.message);
216
+ if (!check.ok)
217
+ return rejectCommitMessage(c, check.reason, 'append-ignore');
218
+ // P3:suggestions 服务端白名单校验(换行注入/全局通配/超量直接 400)。
219
+ const suggestions = sanitizeIgnoreSuggestions(body.suggestions);
220
+ if (!suggestions) {
221
+ return apiError(c, 400, 'INVALID_SUGGESTIONS', 'mode=append-ignore requires valid suggestions (non-empty paths, no newlines, no global wildcards)');
152
222
  }
153
- appendToGitignore(root, body.suggestions);
154
- const result = performGitCommit(root, body.message);
223
+ appendToGitignore(root, suggestions);
224
+ const result = performGitCommit(root, check.message);
155
225
  if ('error' in result) {
156
226
  return apiError(c, 500, 'COMMIT_FAILED', result.error);
157
227
  }
158
228
  return c.json({
159
229
  committed: true,
160
- message: body.message,
230
+ message: check.message,
161
231
  hash: result.hash,
162
232
  fileCount: summary.fileCount,
163
233
  });
@@ -216,13 +286,19 @@ ${summary.diff.slice(0, 8000)}`;
216
286
  if (!message) {
217
287
  return apiError(c, 502, 'EMPTY_MESSAGE', 'LLM returned empty commit message');
218
288
  }
289
+ // P2 修复:与 force 模式同口径校验(长度/单行)——LLM 生成物同样不得
290
+ // 把多行/超长内容写入仓库历史。
291
+ const check = checkCommitMessage(message);
292
+ if (!check.ok) {
293
+ return apiError(c, 502, 'INVALID_LLM_MESSAGE', `LLM returned invalid commit message: ${check.reason === 'TOO_LONG' ? '超过 500 字符' : '包含换行'}`);
294
+ }
219
295
  const suggestions = Array.isArray(parsed.ignoreSuggestions) ? parsed.ignoreSuggestions : [];
220
296
  // LLM 检测到可疑文件 → 阻断提交,返回供前端审查
221
297
  if (suggestions.length > 0) {
222
- return c.json({ needsReview: true, message, suggestions });
298
+ return c.json({ needsReview: true, message: check.message, suggestions });
223
299
  }
224
300
  // 无可疑文件 → 直接提交
225
- const result = performGitCommit(root, message);
301
+ const result = performGitCommit(root, check.message);
226
302
  if ('error' in result) {
227
303
  return apiError(c, 500, 'COMMIT_FAILED', result.error);
228
304
  }
@@ -374,9 +450,20 @@ ${summary.diff.slice(0, 8000)}`;
374
450
  try {
375
451
  if (raw) {
376
452
  const buf = await readFile(resolved);
453
+ // P2 加固:raw 同源直出,nosniff 防「工作区内 html/svg 被浏览器当
454
+ // 活动文档执行」的同源 XSS 面;html 已改 octet-stream(见 contentTypeFor),
455
+ // svg 保留 image/svg+xml 供 <img> 预览(img 上下文中不执行脚本),
456
+ // 仅对 svg 附加沙箱 CSP 兜底「直接导航打开」场景。
457
+ const contentType = contentTypeFor(filePath);
377
458
  return c.body(buf, 200, {
378
- 'Content-Type': contentTypeFor(filePath),
459
+ 'Content-Type': contentType,
379
460
  'Cache-Control': 'no-store',
461
+ 'X-Content-Type-Options': 'nosniff',
462
+ ...(contentType === 'image/svg+xml'
463
+ ? {
464
+ 'Content-Security-Policy': "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'",
465
+ }
466
+ : {}),
380
467
  });
381
468
  }
382
469
  const content = await readFile(resolved, 'utf-8');
@@ -403,7 +490,16 @@ ${summary.diff.slice(0, 8000)}`;
403
490
  if (!resolved) {
404
491
  return apiError(c, 403, 'FORBIDDEN', 'Path outside workspace');
405
492
  }
406
- const body = await c.req.json();
493
+ // P3-14:content 必须是字符串(此前 undefined/非字符串 → writeFile 抛
494
+ // 类型错误 → 500);空串合法(清空文件);超大写入给出明确错误而非 OOM。
495
+ const body = (await c.req.json().catch(() => null));
496
+ if (typeof body?.content !== 'string') {
497
+ return apiError(c, 400, 'BAD_REQUEST', 'content must be a string');
498
+ }
499
+ const MAX_WRITE_BYTES = 20 * 1024 * 1024;
500
+ if (Buffer.byteLength(body.content, 'utf8') > MAX_WRITE_BYTES) {
501
+ return apiError(c, 400, 'FILE_TOO_LARGE', '文件超过 20MB 上限,请用本地工具处理');
502
+ }
407
503
  try {
408
504
  await mkdir(dirname(resolved), { recursive: true });
409
505
  await writeFile(resolved, body.content, 'utf-8');
@@ -1,10 +1,97 @@
1
1
  // REST routes for the kanban board — frontend UI uses these for drag-and-drop
2
2
  // card operations, board config, and initial load.
3
+ // P2-5:/deleted* 为看板回收站端点(必须注册在 /:projectId 之前避免被参数路由吞掉)。
4
+ import { existsSync } from 'node:fs';
3
5
  import { Hono } from 'hono';
4
- import { createKanbanStore, KanbanColumnInUseError } from '../../kanban/index.js';
6
+ import { createKanbanStore, getDeletedKanbanBoard, KanbanCardNotFoundError, KanbanColumnInUseError, KanbanColumnNotFoundError, KanbanInvalidPriorityError, listDeletedKanbanBoards, mergeKanbanBoard, permanentlyDeleteKanbanBoard, restoreKanbanBoard, } from '../../kanban/index.js';
7
+ import { fromDirectory, getProject } from '../../project/index.js';
5
8
  import { apiError } from '../middleware/error.js';
6
9
  function createKanbanRoute(ctx) {
7
10
  const app = new Hono();
11
+ // GET /deleted — 回收站看板列表(项目删除软删除的看板,60 天保留期)。
12
+ app.get('/deleted', async (c) => {
13
+ return c.json({ boards: await listDeletedKanbanBoards(ctx.db) });
14
+ });
15
+ // POST /deleted/:boardId/restore — 恢复到指定项目,或「重建原项目」恢复。
16
+ // P1 修复:merge=true 时目标已有看板不再 409——缺失列与卡片并入现有看板
17
+ // (目标无板时等价普通恢复)。此前 409 指引「删除目标看板」但产品无此能力,
18
+ // 恢复是死胡同。
19
+ app.post('/deleted/:boardId/restore', async (c) => {
20
+ const body = (await c.req.json().catch(() => ({})));
21
+ const boardId = c.req.param('boardId');
22
+ const merge = body.merge === true;
23
+ // 重建原项目:目录仍存在时重建项目记录并恢复到该目录(与会话存储同语义)。
24
+ if (body.rebuild === true) {
25
+ const board = await getDeletedKanbanBoard(ctx.db, boardId);
26
+ if (!board)
27
+ return apiError(c, 404, 'BOARD_NOT_FOUND', '看板不存在或不在回收站');
28
+ if (!board.deletedProjectWorktree) {
29
+ return apiError(c, 400, 'NO_ORIGINAL_WORKTREE', '该看板未记录原项目目录,无法重建原项目;请改为恢复到现有项目');
30
+ }
31
+ if (!existsSync(board.deletedProjectWorktree)) {
32
+ return apiError(c, 409, 'ORIGINAL_DIR_MISSING', '原项目目录已不存在,无法重建;请改为恢复到现有项目');
33
+ }
34
+ const project = await fromDirectory(ctx.db, board.deletedProjectWorktree);
35
+ if (merge) {
36
+ const result = await mergeKanbanBoard(ctx.db, boardId, project.id);
37
+ if (result.ok) {
38
+ return c.json({
39
+ ok: true,
40
+ merged: result,
41
+ recreatedProject: { id: project.id, name: project.name },
42
+ });
43
+ }
44
+ return apiError(c, 404, 'BOARD_NOT_FOUND', '看板不存在或不在回收站');
45
+ }
46
+ const result = await restoreKanbanBoard(ctx.db, boardId, project.id);
47
+ if (result.ok) {
48
+ return c.json({ ok: true, recreatedProject: { id: project.id, name: project.name } });
49
+ }
50
+ if (result.reason === 'TARGET_HAS_BOARD') {
51
+ return apiError(c, 409, 'TARGET_HAS_BOARD', '原项目已存在看板,无法覆盖');
52
+ }
53
+ return apiError(c, 404, 'BOARD_NOT_FOUND', '看板不存在或不在回收站');
54
+ }
55
+ const projectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : '';
56
+ if (!projectId)
57
+ return apiError(c, 400, 'PROJECT_REQUIRED', '恢复目标项目(projectId)必填');
58
+ // P2 修复:目标项目必须存在(与会话恢复口径一致)——此前不校验,
59
+ // 可恢复出挂在不存在项目下、任何视图都不可见的看板。
60
+ const target = await getProject(ctx.db, projectId);
61
+ if (!target)
62
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '恢复目标项目不存在');
63
+ if (merge) {
64
+ const result = await mergeKanbanBoard(ctx.db, boardId, projectId);
65
+ if (result.ok)
66
+ return c.json({ ok: true, merged: result });
67
+ return apiError(c, 404, 'BOARD_NOT_FOUND', '看板不存在或不在回收站');
68
+ }
69
+ const result = await restoreKanbanBoard(ctx.db, boardId, projectId);
70
+ if (result.ok)
71
+ return c.json({ ok: true });
72
+ if (result.reason === 'TARGET_HAS_BOARD') {
73
+ return apiError(c, 409, 'TARGET_HAS_BOARD', '目标项目已有看板,请先导出/删除目标看板后再恢复');
74
+ }
75
+ return apiError(c, 404, 'BOARD_NOT_FOUND', '看板不存在或不在回收站');
76
+ });
77
+ // DELETE /deleted/:boardId — 彻底删除回收站看板(不可恢复)。
78
+ app.delete('/deleted/:boardId', async (c) => {
79
+ const count = await permanentlyDeleteKanbanBoard(ctx.db, c.req.param('boardId'));
80
+ if (count === 0)
81
+ return apiError(c, 404, 'BOARD_NOT_FOUND', '看板不存在或不在回收站');
82
+ return c.json({ ok: true });
83
+ });
84
+ // P1 修复:所有 /:projectId* 端点的项目存在守卫(注册在 /deleted* 之后)。
85
+ // 此前不存在的项目会穿透到 getOrCreateBoardId 的 insert,FK violation 抛 500
86
+ // 且错误信息回显 SQL——应为 404。
87
+ const guardProject = async (c, next) => {
88
+ const project = await getProject(ctx.db, c.req.param('projectId') ?? '');
89
+ if (!project)
90
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
91
+ await next();
92
+ };
93
+ app.use('/:projectId', guardProject);
94
+ app.use('/:projectId/*', guardProject);
8
95
  // GET /:projectId — full board with cards
9
96
  app.get('/:projectId', async (c) => {
10
97
  const projectId = c.req.param('projectId');
@@ -14,10 +101,14 @@ function createKanbanRoute(ctx) {
14
101
  });
15
102
  // GET /:projectId/export — 看板 JSON 导出(项目删除会永久级联删除看板,
16
103
  // 会话有 60 天回收站而看板没有——导出是唯一的备份途径)。
104
+ // peek 语义:纯读不懒建板——导出从未初始化看板的项目此前会凭空产生空板行。
17
105
  app.get('/:projectId/export', async (c) => {
18
106
  const projectId = c.req.param('projectId');
19
107
  const store = createKanbanStore(ctx.db, projectId);
20
- const board = await store.getBoard();
108
+ const board = await store.peekBoard();
109
+ if (!board) {
110
+ return apiError(c, 404, 'BOARD_NOT_FOUND', '该项目尚未初始化看板');
111
+ }
21
112
  return c.json({
22
113
  version: 1,
23
114
  exportedAt: new Date().toISOString(),
@@ -34,6 +125,20 @@ function createKanbanRoute(ctx) {
34
125
  if (body?.version !== 1 || !Array.isArray(body.columns) || !Array.isArray(body.cards)) {
35
126
  return apiError(c, 400, 'INVALID_EXPORT', '无效的看板导出 JSON:需要 version/columns/cards 字段');
36
127
  }
128
+ // P3:导入体积上限——与会话导入(messages/archives 上限)同口径。
129
+ // 无上限的数组会让 replaceBoard 的单事务逐条重建 OOM/长事务拖垮 PGLite。
130
+ const MAX_IMPORT_COLUMNS = 100;
131
+ const MAX_IMPORT_LABELS = 200;
132
+ const MAX_IMPORT_CARDS = 20000;
133
+ if (body.columns.length > MAX_IMPORT_COLUMNS) {
134
+ return apiError(c, 400, 'IMPORT_TOO_LARGE', `导入列数 ${body.columns.length} 超过上限 ${MAX_IMPORT_COLUMNS}`);
135
+ }
136
+ if (Array.isArray(body.labels) && body.labels.length > MAX_IMPORT_LABELS) {
137
+ return apiError(c, 400, 'IMPORT_TOO_LARGE', `导入标签数 ${body.labels.length} 超过上限 ${MAX_IMPORT_LABELS}`);
138
+ }
139
+ if (body.cards.length > MAX_IMPORT_CARDS) {
140
+ return apiError(c, 400, 'IMPORT_TOO_LARGE', `导入卡片数 ${body.cards.length} 超过上限 ${MAX_IMPORT_CARDS}`);
141
+ }
37
142
  const columns = body.columns;
38
143
  const labels = Array.isArray(body.labels) ? body.labels : [];
39
144
  // 宽松校验卡片:只保留结构完整的条目(导入宁少勿坏,与会话导入一致)。
@@ -93,14 +198,25 @@ function createKanbanRoute(ctx) {
93
198
  if (!title)
94
199
  return apiError(c, 400, 'INVALID_INPUT', 'title is required');
95
200
  const store = createKanbanStore(ctx.db, projectId);
96
- const card = await store.addCard({
97
- title,
98
- description: body.description ?? null,
99
- columnId: body.columnId,
100
- priority: body.priority,
101
- labels: body.labels,
102
- });
103
- return c.json(card, 201);
201
+ try {
202
+ const card = await store.addCard({
203
+ title,
204
+ description: body.description ?? null,
205
+ columnId: body.columnId,
206
+ priority: body.priority,
207
+ labels: body.labels,
208
+ });
209
+ return c.json(card, 201);
210
+ }
211
+ catch (err) {
212
+ if (err instanceof KanbanColumnNotFoundError) {
213
+ return apiError(c, 400, 'KANBAN_COLUMN_NOT_FOUND', err.message);
214
+ }
215
+ if (err instanceof KanbanInvalidPriorityError) {
216
+ return apiError(c, 400, 'INVALID_PRIORITY', err.message);
217
+ }
218
+ throw err;
219
+ }
104
220
  });
105
221
  // PATCH /:projectId/cards/:cardId — update card fields or move
106
222
  app.patch('/:projectId/cards/:cardId', async (c) => {
@@ -108,20 +224,34 @@ function createKanbanRoute(ctx) {
108
224
  const cardId = c.req.param('cardId');
109
225
  const body = await c.req.json().catch(() => ({}));
110
226
  const store = createKanbanStore(ctx.db, projectId);
111
- // columnId + position → moveCard; otherwise field update.
112
- if (body.columnId !== undefined) {
113
- const card = await store.moveCard(cardId, body.columnId, body.position);
227
+ try {
228
+ // columnId + position → moveCard; otherwise field update.
229
+ if (body.columnId !== undefined) {
230
+ const card = await store.moveCard(cardId, body.columnId, body.position);
231
+ return c.json(card);
232
+ }
233
+ const card = await store.updateCard(cardId, {
234
+ ...(body.title !== undefined && { title: body.title }),
235
+ ...(body.description !== undefined && {
236
+ description: body.description,
237
+ }),
238
+ ...(body.priority !== undefined && { priority: body.priority }),
239
+ ...(body.labels !== undefined && { labels: body.labels }),
240
+ });
114
241
  return c.json(card);
115
242
  }
116
- const card = await store.updateCard(cardId, {
117
- ...(body.title !== undefined && { title: body.title }),
118
- ...(body.description !== undefined && {
119
- description: body.description,
120
- }),
121
- ...(body.priority !== undefined && { priority: body.priority }),
122
- ...(body.labels !== undefined && { labels: body.labels }),
123
- });
124
- return c.json(card);
243
+ catch (err) {
244
+ if (err instanceof KanbanCardNotFoundError) {
245
+ return apiError(c, 404, 'CARD_NOT_FOUND', err.message);
246
+ }
247
+ if (err instanceof KanbanColumnNotFoundError) {
248
+ return apiError(c, 400, 'KANBAN_COLUMN_NOT_FOUND', err.message);
249
+ }
250
+ if (err instanceof KanbanInvalidPriorityError) {
251
+ return apiError(c, 400, 'INVALID_PRIORITY', err.message);
252
+ }
253
+ throw err;
254
+ }
125
255
  });
126
256
  // DELETE /:projectId/cards/:cardId — delete a card
127
257
  app.delete('/:projectId/cards/:cardId', async (c) => {
@@ -1,6 +1,7 @@
1
1
  import { eq } from 'drizzle-orm';
2
2
  import { Hono } from 'hono';
3
3
  import { sessions } from '../../db/schema.js';
4
+ import { getSession } from '../../session/session.js';
4
5
  import { apiError } from '../middleware/error.js';
5
6
  /** GET/PUT /api/permissions — 授权模式(default 逐个确认 / auto 自动放行 ask 工具)。
6
7
  * - 根路径:全局默认模式(启动时取 config.permission.defaultMode;
@@ -104,6 +105,12 @@ function createPermissionsRoute(ctx) {
104
105
  // PUT /:sessionId — 设置会话级覆盖;不传 mode 则清除覆盖。P2:落库持久化。
105
106
  app.put('/:sessionId', async (c) => {
106
107
  const sessionId = c.req.param('sessionId');
108
+ // P3-11 修复:校验会话存在——此前对不存在的 id 设置 auto 也返回成功,
109
+ // 用户以为已生效,重启后无任何痕迹。回收站会话同口径 404。
110
+ const session = await getSession(ctx.db, sessionId);
111
+ if (!session || session.deletedAt) {
112
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
113
+ }
107
114
  const body = (await c.req.json().catch(() => null));
108
115
  const mode = body?.mode;
109
116
  if (mode !== undefined && mode !== 'default' && mode !== 'auto') {