c0de-agent 1.11.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 (177) hide show
  1. package/README.md +63 -16
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +151 -1
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +63 -3
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +109 -3
  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 +110 -11
  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 +78 -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/compact.d.ts +2 -0
  24. package/dist/core/compact.js +1 -0
  25. package/dist/core/config.d.ts +53 -2
  26. package/dist/core/config.js +235 -19
  27. package/dist/core/loop/compaction.d.ts +2 -1
  28. package/dist/core/loop/compaction.js +19 -6
  29. package/dist/core/loop/segment.d.ts +3 -1
  30. package/dist/core/loop/segment.js +24 -1
  31. package/dist/core/loop/stream-collect.js +9 -1
  32. package/dist/core/loop/subagent.js +88 -37
  33. package/dist/core/loop.d.ts +7 -0
  34. package/dist/core/loop.js +90 -6
  35. package/dist/core/redact.d.ts +11 -0
  36. package/dist/core/redact.js +49 -0
  37. package/dist/core/slash.d.ts +10 -0
  38. package/dist/core/slash.js +237 -65
  39. package/dist/core/title.js +7 -1
  40. package/dist/core/types.d.ts +22 -1
  41. package/dist/core/workflows/context.js +44 -4
  42. package/dist/core/workflows/discovery.d.ts +2 -2
  43. package/dist/core/workflows/discovery.js +5 -7
  44. package/dist/core/workflows/index.d.ts +2 -1
  45. package/dist/core/workflows/index.js +2 -1
  46. package/dist/core/workflows/registry.d.ts +13 -3
  47. package/dist/core/workflows/registry.js +14 -9
  48. package/dist/core/workflows/resolve.d.ts +22 -0
  49. package/dist/core/workflows/resolve.js +23 -0
  50. package/dist/core/workflows/runtime.d.ts +6 -1
  51. package/dist/core/workflows/runtime.js +19 -2
  52. package/dist/db/schema.d.ts +373 -1
  53. package/dist/db/schema.js +60 -4
  54. package/dist/kanban/index.d.ts +2 -1
  55. package/dist/kanban/index.js +1 -1
  56. package/dist/kanban/store.d.ts +87 -1
  57. package/dist/kanban/store.js +429 -22
  58. package/dist/llm/index.d.ts +1 -1
  59. package/dist/llm/index.js +1 -1
  60. package/dist/llm/provider.js +1 -1
  61. package/dist/llm/registry.d.ts +7 -1
  62. package/dist/llm/registry.js +7 -1
  63. package/dist/llm/retry.d.ts +5 -2
  64. package/dist/llm/retry.js +6 -5
  65. package/dist/llm/routing.d.ts +24 -1
  66. package/dist/llm/routing.js +29 -1
  67. package/dist/plugins/init.d.ts +7 -0
  68. package/dist/plugins/init.js +22 -1
  69. package/dist/plugins/loader.d.ts +7 -1
  70. package/dist/plugins/loader.js +12 -4
  71. package/dist/project/index.d.ts +1 -1
  72. package/dist/project/index.js +1 -1
  73. package/dist/project/project.d.ts +37 -0
  74. package/dist/project/project.js +142 -1
  75. package/dist/project/trust.d.ts +65 -0
  76. package/dist/project/trust.js +328 -0
  77. package/dist/server/agent-manager.d.ts +15 -2
  78. package/dist/server/agent-manager.js +18 -3
  79. package/dist/server/app.js +45 -30
  80. package/dist/server/auth-manager.d.ts +26 -9
  81. package/dist/server/auth-manager.js +78 -42
  82. package/dist/server/context.js +14 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/index.js +1 -1
  85. package/dist/server/middleware/auth.d.ts +11 -1
  86. package/dist/server/middleware/auth.js +25 -3
  87. package/dist/server/permission/interactive.d.ts +11 -3
  88. package/dist/server/permission/interactive.js +23 -4
  89. package/dist/server/permission/store.d.ts +14 -2
  90. package/dist/server/permission/store.js +39 -3
  91. package/dist/server/registry-config.d.ts +14 -0
  92. package/dist/server/registry-config.js +44 -0
  93. package/dist/server/routes/auth.js +66 -10
  94. package/dist/server/routes/chat.d.ts +21 -0
  95. package/dist/server/routes/chat.js +685 -52
  96. package/dist/server/routes/commands.d.ts +4 -2
  97. package/dist/server/routes/commands.js +9 -4
  98. package/dist/server/routes/config.js +180 -23
  99. package/dist/server/routes/files.js +142 -28
  100. package/dist/server/routes/kanban.js +218 -26
  101. package/dist/server/routes/permissions.d.ts +0 -4
  102. package/dist/server/routes/permissions.js +140 -7
  103. package/dist/server/routes/project.js +132 -29
  104. package/dist/server/routes/provider.js +54 -3
  105. package/dist/server/routes/session.js +463 -23
  106. package/dist/server/routes/tool.js +24 -5
  107. package/dist/server/routes/update.d.ts +11 -5
  108. package/dist/server/routes/update.js +145 -29
  109. package/dist/server/routes/usage.d.ts +4 -0
  110. package/dist/server/routes/usage.js +133 -0
  111. package/dist/server/routes/workflows.js +476 -87
  112. package/dist/server/server.d.ts +16 -13
  113. package/dist/server/server.js +364 -107
  114. package/dist/server/terminal/pty-manager.d.ts +12 -0
  115. package/dist/server/terminal/pty-manager.js +63 -7
  116. package/dist/server/types.d.ts +16 -0
  117. package/dist/session/archive.d.ts +3 -1
  118. package/dist/session/archive.js +13 -1
  119. package/dist/session/branch.d.ts +2 -1
  120. package/dist/session/branch.js +92 -9
  121. package/dist/session/context.js +28 -2
  122. package/dist/session/import.d.ts +44 -0
  123. package/dist/session/import.js +83 -0
  124. package/dist/session/index.d.ts +1 -1
  125. package/dist/session/index.js +1 -1
  126. package/dist/session/jobs.d.ts +12 -0
  127. package/dist/session/jobs.js +53 -0
  128. package/dist/session/message.d.ts +7 -1
  129. package/dist/session/message.js +21 -1
  130. package/dist/session/session.d.ts +186 -12
  131. package/dist/session/session.js +577 -33
  132. package/dist/session/snapshot.d.ts +5 -2
  133. package/dist/session/snapshot.js +10 -2
  134. package/dist/session/types.d.ts +13 -1
  135. package/dist/session/usage.d.ts +62 -0
  136. package/dist/session/usage.js +194 -0
  137. package/dist/shared/types/agent.d.ts +29 -2
  138. package/dist/shared/types/config.d.ts +64 -5
  139. package/dist/shared/types/kanban.d.ts +17 -2
  140. package/dist/shared/types/message.d.ts +23 -0
  141. package/dist/tools/builtin/kanban.js +13 -2
  142. package/dist/tools/index.d.ts +14 -3
  143. package/dist/tools/index.js +23 -5
  144. package/dist/tools/websearch/index.d.ts +5 -1
  145. package/dist/tools/websearch/index.js +46 -7
  146. package/dist/tools/websearch/providers/brave.js +2 -2
  147. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  148. package/dist/tools/websearch/providers/tavily.js +2 -2
  149. package/dist/tools/websearch/types.d.ts +5 -1
  150. package/dist/tools/websearch/types.js +7 -1
  151. package/dist/update/index.d.ts +3 -3
  152. package/dist/update/index.js +2 -2
  153. package/dist/update/ipc.d.ts +13 -2
  154. package/dist/update/ipc.js +36 -2
  155. package/dist/update/scheduler.js +5 -2
  156. package/dist/update/snapshot.d.ts +18 -3
  157. package/dist/update/snapshot.js +5 -2
  158. package/dist/update/updater.d.ts +17 -1
  159. package/dist/update/updater.js +47 -18
  160. package/dist/update/version.d.ts +3 -0
  161. package/dist/update/version.js +9 -3
  162. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  163. package/drizzle/0010_usage_events_ledger.sql +17 -0
  164. package/drizzle/0011_slow_slapstick.sql +12 -0
  165. package/drizzle/0012_chief_timeslip.sql +1 -0
  166. package/drizzle/0013_swift_zeigeist.sql +1 -0
  167. package/drizzle/0014_flimsy_legion.sql +1 -0
  168. package/drizzle/meta/0008_snapshot.json +875 -0
  169. package/drizzle/meta/0010_snapshot.json +1010 -0
  170. package/drizzle/meta/0011_snapshot.json +1055 -0
  171. package/drizzle/meta/0012_snapshot.json +1062 -0
  172. package/drizzle/meta/0013_snapshot.json +1068 -0
  173. package/drizzle/meta/0014_snapshot.json +1075 -0
  174. package/drizzle/meta/_journal.json +42 -0
  175. package/package.json +42 -42
  176. package/dist/core/agents/discovery.d.ts +0 -6
  177. package/dist/core/agents/discovery.js +0 -96
@@ -1,5 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import type { ServerContext } from '../types.js';
3
- /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。 */
4
- declare function createCommandsRoute(_ctx: ServerContext): Hono;
3
+ /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。
4
+ * 按 config.slashCommands.enabled 过滤:enabled 为空 = 全部禁用(fail-closed);
5
+ * 含 ['*'] = 全部启用;否则只返回启用项,保证弹窗发现性 = 可执行性(避免展示必被拒的命令)。 */
6
+ declare function createCommandsRoute(ctx: ServerContext): Hono;
5
7
  export { createCommandsRoute };
@@ -1,11 +1,16 @@
1
1
  import { Hono } from 'hono';
2
- import { createSlashRegistry } from '../../core/slash.js';
3
- /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。 */
4
- function createCommandsRoute(_ctx) {
2
+ import { createSlashRegistry, isSlashCommandEnabled } from '../../core/slash.js';
3
+ /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。
4
+ * 按 config.slashCommands.enabled 过滤:enabled 为空 = 全部禁用(fail-closed);
5
+ * 含 ['*'] = 全部启用;否则只返回启用项,保证弹窗发现性 = 可执行性(避免展示必被拒的命令)。 */
6
+ function createCommandsRoute(ctx) {
5
7
  const app = new Hono();
6
8
  app.get('/', (c) => {
7
9
  const registry = createSlashRegistry();
8
- const commands = registry.list().map((cmd) => ({
10
+ const commands = registry
11
+ .list()
12
+ .filter((cmd) => isSlashCommandEnabled(ctx.config.slashCommands?.enabled, cmd.name))
13
+ .map((cmd) => ({
9
14
  name: cmd.name,
10
15
  description: cmd.description,
11
16
  ...(cmd.argsHint ? { argsHint: cmd.argsHint } : {}),
@@ -1,6 +1,10 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
1
3
  import { Hono } from 'hono';
2
- import { applyScopedPatch, 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
+ import { containsSecrets } from '../../core/redact.js';
3
6
  import { decryptSecret, encryptSecret, isEncryptedSecret } from '../../core/secret.js';
7
+ import { getByDirectory, getProject, trustProject } from '../../project/index.js';
4
8
  import { apiError } from '../middleware/error.js';
5
9
  import { syncRegistryFromConfig } from '../server.js';
6
10
  /** 检查每个 provider 的 apiKey 能否在本机解密(机器绑定密钥换机/容器重建后会失败)。 */
@@ -13,31 +17,146 @@ function providerApiKeyWarnings(providers) {
13
17
  decryptSecret(p.apiKey);
14
18
  }
15
19
  catch {
16
- warnings.push(`provider "${p.name || '(未命名)'}" 的 API Key 无法在本机解密(密钥与机器绑定)。` +
17
- '若配置来自其他机器或容器重建,请重新填写 API Key。');
20
+ warnings.push(`provider "${p.name || ''}" 的 apiKey 无法在本机解密(配置来自其他机器),请重新设置`);
18
21
  }
19
22
  }
20
23
  return warnings;
21
24
  }
25
+ /** 项目级配置含密钥且位于 git 仓库内时提示 .gitignore(防误提交)。 */
26
+ function projectConfigGitWarning(project, cwd) {
27
+ if (!project || !containsSecrets(project))
28
+ return null;
29
+ let dir = cwd;
30
+ while (true) {
31
+ if (existsSync(join(dir, '.git'))) {
32
+ return ('项目级配置(.c0de/config.json)含 API Key/Token 且位于 git 仓库内,' +
33
+ '建议将 .c0de/ 加入 .gitignore,防止密钥被误提交。');
34
+ }
35
+ const parent = dirname(dir);
36
+ if (parent === dir)
37
+ return null;
38
+ dir = parent;
39
+ }
40
+ }
41
+ /**
42
+ * 解析配置读取/写入的目标目录(P1-1 多项目配置贯通):
43
+ * - projectId 提供且存在 → 该项目 worktree(配置读写与设置页展示都对准该项目)。
44
+ * - 否则 → 服务启动目录(向后兼容:serve cwd 项目与全局作用域)。
45
+ */
46
+ async function resolveConfigDir(ctx, projectId) {
47
+ if (projectId) {
48
+ const project = await getProject(ctx.db, projectId);
49
+ if (!project)
50
+ return null;
51
+ return { dir: project.worktree, isProjectScoped: true };
52
+ }
53
+ return { dir: ctx.cwd, isProjectScoped: false };
54
+ }
22
55
  function createConfigRoute(ctx) {
23
56
  const app = new Hono();
24
- // GET / — 合并后配置 + 作用域信息 + apiKey 解密警告
25
- app.get('/', (c) => {
26
- const scopes = loadConfigScopes(ctx.cwd);
57
+ // GET / — 合并后配置 + 作用域信息 + apiKey 解密警告 + git 误提交警告。
58
+ // ?projectId= 时读取该项目的 global+project 作用域(默认读取服务启动目录项目,
59
+ // 复用服务内存合并配置 ctx.config)。
60
+ app.get('/', async (c) => {
61
+ const projectId = c.req.query('projectId');
62
+ const target = await resolveConfigDir(ctx, projectId);
63
+ if (!target)
64
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
65
+ const scopes = loadConfigScopes(target.dir);
66
+ const config = projectId ? mergeConfig(scopes.global, scopes.project) : ctx.config;
67
+ // 未知顶层键告警并入 warnings:用户在设置页即可看到拼写错误/过时键,不必看终端日志。
68
+ const unknownWarnings = [
69
+ ...collectUnknownConfigKeys(scopes.global).map((k) => `全局配置含未知键 "${k}",不会生效`),
70
+ ...collectUnknownConfigKeys(scopes.project).map((k) => `项目配置含未知键 "${k}",不会生效`),
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
+ }
27
112
  return c.json({
28
- config: ctx.config,
113
+ config,
29
114
  scopes: {
30
115
  global: scopes.global ?? null,
31
116
  project: scopes.project ?? null,
32
117
  },
33
- warnings: providerApiKeyWarnings(ctx.config.providers),
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),
128
+ projectDir: target.isProjectScoped ? target.dir : undefined,
34
129
  });
35
130
  });
36
131
  // PATCH / — 按作用域写入。body.scope: 'global' | 'project'(默认 project,兼容旧客户端)。
132
+ // body.projectId:目标项目(P1-1;缺省 = 服务启动目录项目)。
133
+ // 仅当写入目标是服务启动目录项目时同步服务级内存配置与 LLM registry——
134
+ // 其他项目的配置不污染服务级 provider 注册表(会话级按需解析)。
37
135
  app.patch('/', async (c) => {
38
136
  const body = (await c.req.json());
39
137
  const scope = body.scope === 'global' ? 'global' : 'project';
40
- const { scope: _omit, ...patch } = body;
138
+ const { scope: _omit, projectId, ...patch } = body;
139
+ // 未知顶层键拒绝:拼写错误/过时键经此路径写入只会埋雷(永不生效且难发现)。
140
+ const unknown = collectUnknownConfigKeys(patch);
141
+ if (unknown.length > 0) {
142
+ return apiError(c, 400, 'UNKNOWN_CONFIG_KEYS', `未知配置键:${unknown.join(', ')}。有效顶层键:${[...KNOWN_CONFIG_KEYS].join(', ')}`);
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
+ }
41
160
  // spec §24.2:provider apiKey 落盘前加密,明文不持久化。
42
161
  // 已加密(enc: 前缀)或无 apiKey 的透传。
43
162
  if (Array.isArray(patch.providers)) {
@@ -46,32 +165,70 @@ function createConfigRoute(ctx) {
46
165
  // 按作用域最小落盘:patch 只合并进指定作用域原始文件,
47
166
  // 不把合并结果(含默认值/另一作用域配置)整体序列化进文件。
48
167
  // null 值 = 删除该作用域中的键(回落到另一作用域/默认值)。
49
- const scopes = loadConfigScopes(ctx.cwd);
168
+ const target = await resolveConfigDir(ctx, projectId);
169
+ if (!target)
170
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
171
+ const scopes = loadConfigScopes(target.dir);
50
172
  const nextScoped = scope === 'global'
51
173
  ? applyScopedPatch(scopes.global ?? {}, patch)
52
174
  : applyScopedPatch(scopes.project ?? {}, patch);
53
175
  // P2-3:落盘失败必须反馈给前端(此前静默吞掉,UI 显示已保存但重启后丢失)。
54
176
  // 先落盘、成功后才更新内存配置与 registry,保证「已保存」反馈与磁盘状态一致。
55
177
  try {
56
- await saveConfigScoped(scope, ctx.cwd, nextScoped);
178
+ await saveConfigScoped(scope, target.dir, nextScoped);
57
179
  }
58
180
  catch (err) {
59
181
  return apiError(c, 500, 'CONFIG_SAVE_FAILED', `配置保存失败:${err instanceof Error ? err.message : String(err)}`);
60
182
  }
61
- ctx.config =
62
- scope === 'global'
63
- ? mergeConfig(nextScoped, scopes.project)
64
- : mergeConfig(scopes.global, nextScoped);
65
- // providers 变更后原地同步 registry,使运行中的连接立即生效
66
- syncRegistryFromConfig(ctx.llmRegistry, ctx.config);
67
- // 安全类配置(token/authEnabled/allowedOrigins)由 authManager 在启动时一次性读取,
68
- // 运行时修改不生效——告知前端提示重启。
69
- const needsRestart = patch.security !== undefined;
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
+ }
196
+ const isServerCwdProject = target.dir === ctx.cwd;
197
+ if (isServerCwdProject || scope === 'global') {
198
+ // global 作用域写入影响所有项目(含服务级 provider 注册表),
199
+ // 按服务启动目录作用域重建 ctx.config 并同步 registry。
200
+ const serverScopes = loadConfigScopes(ctx.cwd);
201
+ ctx.config = mergeConfig(serverScopes.global, serverScopes.project);
202
+ syncRegistryFromConfig(ctx.llmRegistry, ctx.config);
203
+ // P1 修复:权限默认模式运行时同步——设置页保存后立即生效,
204
+ // 与草稿页底栏开关(PUT /api/permissions,既持久化又即时生效)语义一致。
205
+ // 此前仅落盘、ctx.permissionMode 仍为启动值,UI 显示已保存但弹窗照旧。
206
+ ctx.permissionMode = ctx.config.permission.defaultMode;
207
+ // 项目级注册表基于全局作用域构建,global 变更后全部失效。
208
+ ctx.projectRegistries?.clear();
209
+ }
210
+ else if (projectId) {
211
+ // 项目作用域写入:该项目的会话级注册表缓存失效。
212
+ ctx.projectRegistries?.delete(projectId);
213
+ }
214
+ const freshScopes = loadConfigScopes(target.dir);
215
+ const merged = mergeConfig(freshScopes.global, freshScopes.project);
216
+ // 启动时一次性读取的配置:运行时修改不生效——告知前端提示重启。
217
+ // - security:authManager/CORS 在启动时构建
218
+ // - update:调度器 interval 与 handoff server 在启动时构建
219
+ // - plugins:hookRunner/插件生命周期在启动时初始化
220
+ // 仅当写入影响运行中的服务级配置(服务启动目录项目或 global 作用域)时提示。
221
+ const needsRestart = (isServerCwdProject || scope === 'global') &&
222
+ (patch.security !== undefined || patch.update !== undefined || patch.plugins !== undefined);
70
223
  return c.json({
71
- config: ctx.config,
72
- scope,
224
+ config: merged,
225
+ scopes: {
226
+ global: freshScopes.global ?? null,
227
+ project: freshScopes.project ?? null,
228
+ },
229
+ warnings: providerApiKeyWarnings(merged.providers),
73
230
  needsRestart,
74
- warnings: providerApiKeyWarnings(ctx.config.providers),
231
+ projectDir: target.isProjectScoped ? target.dir : undefined,
75
232
  });
76
233
  });
77
234
  return app;
@@ -3,15 +3,38 @@ import { dirname, join, relative } from 'node:path';
3
3
  import { Hono } from 'hono';
4
4
  import trash from 'trash';
5
5
  import { createSummarizer } from '../../core/compact.js';
6
+ import { loadConfigScopes, mergeConfig } from '../../core/config.js';
7
+ import { buildFallbackChain } from '../../llm/routing.js';
6
8
  import { getProject } from '../../project/project.js';
7
9
  import { appendToGitignore, checkIgnored, checkoutGitBranch, createGitBranch, getGitBranch, getGitDiffSummary, getGitLastCommit, getGitStatus, listGitBranches, performGitCommit, } from '../../project/resolve.js';
8
10
  import { apiError } from '../middleware/error.js';
11
+ import { buildRegistryFromConfig } from '../registry-config.js';
9
12
  import { safeResolve } from '../util/safe-path.js';
10
- /** 递归搜索时跳过的目录(体积大/为元数据噪音,避免递归进入)。 */
11
- const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules']);
12
- /** 递归收集文件列表(用于搜索)。 */
13
- async function collectFiles(dir, basePath, maxDepth = 5) {
14
- 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)
15
38
  return [];
16
39
  const results = [];
17
40
  let entries;
@@ -22,13 +45,16 @@ async function collectFiles(dir, basePath, maxDepth = 5) {
22
45
  return [];
23
46
  }
24
47
  for (const entry of entries) {
48
+ if (results.length >= budget)
49
+ break;
25
50
  if (entry.isDirectory() && SEARCH_SKIP_DIRS.has(entry.name))
26
51
  continue;
27
52
  const fullPath = join(dir, entry.name);
28
53
  const relPath = relative(basePath, fullPath);
29
54
  if (entry.isDirectory()) {
30
55
  results.push({ path: relPath, type: 'directory' });
31
- results.push(...(await collectFiles(fullPath, basePath, maxDepth - 1)));
56
+ const rest = await collectFiles(fullPath, basePath, maxDepth - 1, budget - results.length);
57
+ results.push(...rest);
32
58
  }
33
59
  else {
34
60
  results.push({ path: relPath, type: 'file' });
@@ -55,7 +81,6 @@ function contentTypeFor(name) {
55
81
  webm: 'video/webm',
56
82
  mov: 'video/quicktime',
57
83
  json: 'application/json; charset=utf-8',
58
- html: 'text/html; charset=utf-8',
59
84
  md: 'text/markdown; charset=utf-8',
60
85
  txt: 'text/plain; charset=utf-8',
61
86
  ts: 'text/plain; charset=utf-8',
@@ -63,6 +88,51 @@ function contentTypeFor(name) {
63
88
  };
64
89
  return map[ext] ?? 'application/octet-stream';
65
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
+ }
66
136
  function createFilesRoute(ctx) {
67
137
  const app = new Hono();
68
138
  // git 状态:返回 path → 状态分类 的映射(非 git 返回空对象)
@@ -125,44 +195,54 @@ function createFilesRoute(ctx) {
125
195
  .catch(() => ({}));
126
196
  // --- mode: force — 跳过检查,用传入 message 直接提交 ---
127
197
  if (body.mode === 'force') {
128
- if (!body.message) {
129
- return apiError(c, 400, 'MISSING_MESSAGE', 'mode=force requires a message');
130
- }
131
- 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);
132
203
  if ('error' in result) {
133
204
  return apiError(c, 500, 'COMMIT_FAILED', result.error);
134
205
  }
135
206
  return c.json({
136
207
  committed: true,
137
- message: body.message,
208
+ message,
138
209
  hash: result.hash,
139
210
  fileCount: summary.fileCount,
140
211
  });
141
212
  }
142
213
  // --- mode: append-ignore — 追加 .gitignore 后提交 ---
143
214
  if (body.mode === 'append-ignore') {
144
- if (!body.message) {
145
- return apiError(c, 400, 'MISSING_MESSAGE', 'mode=append-ignore requires a message');
146
- }
147
- if (!body.suggestions || body.suggestions.length === 0) {
148
- 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)');
149
222
  }
150
- appendToGitignore(root, body.suggestions);
151
- const result = performGitCommit(root, body.message);
223
+ appendToGitignore(root, suggestions);
224
+ const result = performGitCommit(root, check.message);
152
225
  if ('error' in result) {
153
226
  return apiError(c, 500, 'COMMIT_FAILED', result.error);
154
227
  }
155
228
  return c.json({
156
229
  committed: true,
157
- message: body.message,
230
+ message: check.message,
158
231
  hash: result.hash,
159
232
  fileCount: summary.fileCount,
160
233
  });
161
234
  }
162
235
  // --- 默认模式:LLM 生成 message + 检查可疑文件 ---
163
- const cm = ctx.config.commitModel;
164
- const provider = cm?.provider ?? ctx.config.defaultProvider;
165
- const model = cm?.model ?? ctx.config.defaultModel;
236
+ // P1-1:commitModel/defaultProvider 按项目配置解析(此前一律用服务启动目录配置)。
237
+ let projectConfig = ctx.config;
238
+ if (root !== ctx.cwd) {
239
+ const projectScope = loadConfigScopes(root).project;
240
+ if (projectScope)
241
+ projectConfig = mergeConfig(ctx.config, projectScope);
242
+ }
243
+ const cm = projectConfig.commitModel;
244
+ const provider = cm?.provider ?? projectConfig.defaultProvider;
245
+ const model = cm?.model ?? projectConfig.defaultModel;
166
246
  const prompt = `Based on the following git diff, generate a concise commit message in conventional-commits format (e.g. "feat: add login page").
167
247
 
168
248
  ALSO review the changed/new files: are any of them files that SHOULD be in .gitignore but are currently missing? (e.g. secrets, .env, build output, dependencies, temp files, large binaries)
@@ -175,7 +255,15 @@ If no files need ignoring, return an empty array for ignoreSuggestions.
175
255
  ${summary.diff.slice(0, 8000)}`;
176
256
  let raw;
177
257
  try {
178
- const summarizer = createSummarizer(ctx.llmRegistry, provider, model, { maxTokens: 400 });
258
+ // P1-1:项目级 provider 注册表(项目配置的 provider 才能路由)。
259
+ const registry = root !== ctx.cwd && projectConfig.providers.length > 0
260
+ ? buildRegistryFromConfig(projectConfig)
261
+ : ctx.llmRegistry;
262
+ const fallback = buildFallbackChain(projectConfig, provider, model);
263
+ const summarizer = createSummarizer(registry, provider, model, {
264
+ maxTokens: 400,
265
+ ...(fallback ? { fallback } : {}),
266
+ });
179
267
  raw = (await summarizer(prompt)).trim();
180
268
  }
181
269
  catch (err) {
@@ -198,13 +286,19 @@ ${summary.diff.slice(0, 8000)}`;
198
286
  if (!message) {
199
287
  return apiError(c, 502, 'EMPTY_MESSAGE', 'LLM returned empty commit message');
200
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
+ }
201
295
  const suggestions = Array.isArray(parsed.ignoreSuggestions) ? parsed.ignoreSuggestions : [];
202
296
  // LLM 检测到可疑文件 → 阻断提交,返回供前端审查
203
297
  if (suggestions.length > 0) {
204
- return c.json({ needsReview: true, message, suggestions });
298
+ return c.json({ needsReview: true, message: check.message, suggestions });
205
299
  }
206
300
  // 无可疑文件 → 直接提交
207
- const result = performGitCommit(root, message);
301
+ const result = performGitCommit(root, check.message);
208
302
  if ('error' in result) {
209
303
  return apiError(c, 500, 'COMMIT_FAILED', result.error);
210
304
  }
@@ -356,9 +450,20 @@ ${summary.diff.slice(0, 8000)}`;
356
450
  try {
357
451
  if (raw) {
358
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);
359
458
  return c.body(buf, 200, {
360
- 'Content-Type': contentTypeFor(filePath),
459
+ 'Content-Type': contentType,
361
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
+ : {}),
362
467
  });
363
468
  }
364
469
  const content = await readFile(resolved, 'utf-8');
@@ -385,7 +490,16 @@ ${summary.diff.slice(0, 8000)}`;
385
490
  if (!resolved) {
386
491
  return apiError(c, 403, 'FORBIDDEN', 'Path outside workspace');
387
492
  }
388
- 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
+ }
389
503
  try {
390
504
  await mkdir(dirname(resolved), { recursive: true });
391
505
  await writeFile(resolved, body.content, 'utf-8');