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.
- package/README.md +51 -23
- package/dist/cli/commands/acp.js +26 -8
- package/dist/cli/commands/chat.js +70 -6
- package/dist/cli/commands/config.d.ts +2 -0
- package/dist/cli/commands/config.js +46 -0
- package/dist/cli/commands/serve.js +11 -2
- package/dist/cli/commands/sessions.js +51 -4
- package/dist/cli/commands/trust.d.ts +11 -0
- package/dist/cli/commands/trust.js +31 -0
- package/dist/cli/commands/update.js +4 -0
- package/dist/cli/deps.d.ts +9 -6
- package/dist/cli/deps.js +66 -19
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +92 -12
- package/dist/cli/modes/acp.js +39 -8
- package/dist/cli/modes/print.d.ts +3 -0
- package/dist/cli/modes/print.js +71 -8
- package/dist/core/agent.d.ts +1 -1
- package/dist/core/agent.js +9 -4
- package/dist/core/agents/builtin.js +2 -2
- package/dist/core/agents/index.d.ts +0 -1
- package/dist/core/agents/index.js +0 -1
- package/dist/core/config.d.ts +43 -1
- package/dist/core/config.js +163 -14
- package/dist/core/loop/segment.d.ts +3 -1
- package/dist/core/loop/segment.js +24 -1
- package/dist/core/loop/subagent.js +65 -32
- package/dist/core/loop.d.ts +7 -0
- package/dist/core/loop.js +90 -6
- package/dist/core/slash.d.ts +10 -0
- package/dist/core/slash.js +150 -20
- package/dist/core/types.d.ts +20 -1
- package/dist/core/workflows/context.js +44 -4
- package/dist/core/workflows/discovery.d.ts +2 -2
- package/dist/core/workflows/discovery.js +5 -7
- package/dist/core/workflows/index.d.ts +2 -1
- package/dist/core/workflows/index.js +2 -1
- package/dist/core/workflows/registry.d.ts +13 -3
- package/dist/core/workflows/registry.js +14 -9
- package/dist/core/workflows/resolve.d.ts +22 -0
- package/dist/core/workflows/resolve.js +23 -0
- package/dist/core/workflows/runtime.d.ts +6 -1
- package/dist/core/workflows/runtime.js +19 -2
- package/dist/db/schema.d.ts +356 -1
- package/dist/db/schema.js +57 -3
- package/dist/kanban/index.d.ts +2 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +84 -2
- package/dist/kanban/store.js +358 -25
- package/dist/llm/registry.d.ts +7 -1
- package/dist/llm/registry.js +7 -1
- package/dist/plugins/init.d.ts +7 -0
- package/dist/plugins/init.js +22 -1
- package/dist/plugins/loader.d.ts +7 -1
- package/dist/plugins/loader.js +12 -4
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +23 -0
- package/dist/project/project.js +65 -0
- package/dist/project/trust.d.ts +65 -0
- package/dist/project/trust.js +328 -0
- package/dist/server/agent-manager.d.ts +6 -2
- package/dist/server/agent-manager.js +4 -2
- package/dist/server/app.js +45 -30
- package/dist/server/auth-manager.d.ts +15 -4
- package/dist/server/auth-manager.js +41 -24
- package/dist/server/context.js +11 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/middleware/auth.d.ts +11 -1
- package/dist/server/middleware/auth.js +25 -3
- package/dist/server/permission/interactive.d.ts +5 -0
- package/dist/server/permission/interactive.js +4 -1
- package/dist/server/permission/store.d.ts +2 -0
- package/dist/server/permission/store.js +8 -0
- package/dist/server/routes/auth.js +35 -9
- package/dist/server/routes/chat.d.ts +16 -7
- package/dist/server/routes/chat.js +483 -28
- package/dist/server/routes/commands.d.ts +2 -2
- package/dist/server/routes/commands.js +4 -6
- package/dist/server/routes/config.js +81 -4
- package/dist/server/routes/files.js +120 -24
- package/dist/server/routes/kanban.js +152 -22
- package/dist/server/routes/permissions.js +7 -0
- package/dist/server/routes/project.js +76 -22
- package/dist/server/routes/provider.js +6 -0
- package/dist/server/routes/session.js +177 -13
- package/dist/server/routes/tool.js +24 -5
- package/dist/server/routes/update.js +58 -2
- package/dist/server/routes/usage.d.ts +4 -0
- package/dist/server/routes/usage.js +133 -0
- package/dist/server/routes/workflows.js +476 -87
- package/dist/server/server.d.ts +12 -1
- package/dist/server/server.js +194 -33
- package/dist/server/terminal/pty-manager.d.ts +12 -0
- package/dist/server/terminal/pty-manager.js +63 -7
- package/dist/server/types.d.ts +9 -0
- package/dist/session/branch.d.ts +2 -1
- package/dist/session/branch.js +75 -10
- package/dist/session/context.js +28 -2
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/session.d.ts +67 -10
- package/dist/session/session.js +180 -67
- package/dist/session/types.d.ts +2 -0
- package/dist/session/usage.d.ts +62 -0
- package/dist/session/usage.js +194 -0
- package/dist/shared/types/agent.d.ts +19 -3
- package/dist/shared/types/config.d.ts +59 -4
- package/dist/shared/types/kanban.d.ts +4 -2
- package/dist/shared/types/message.d.ts +14 -0
- package/dist/tools/builtin/kanban.js +13 -2
- package/dist/tools/index.d.ts +14 -3
- package/dist/tools/index.js +23 -5
- package/dist/tools/websearch/index.d.ts +5 -1
- package/dist/tools/websearch/index.js +40 -4
- package/dist/tools/websearch/providers/brave.js +2 -2
- package/dist/tools/websearch/providers/duckduckgo.js +2 -1
- package/dist/tools/websearch/providers/tavily.js +2 -2
- package/dist/tools/websearch/types.d.ts +5 -1
- package/dist/tools/websearch/types.js +7 -1
- package/dist/update/scheduler.js +5 -2
- package/dist/update/snapshot.d.ts +17 -3
- package/dist/update/snapshot.js +3 -2
- package/dist/update/updater.d.ts +2 -0
- package/dist/update/updater.js +2 -1
- package/dist/update/version.d.ts +3 -0
- package/dist/update/version.js +9 -3
- package/drizzle/0010_usage_events_ledger.sql +17 -0
- package/drizzle/0011_slow_slapstick.sql +12 -0
- package/drizzle/0012_chief_timeslip.sql +1 -0
- package/drizzle/0013_swift_zeigeist.sql +1 -0
- package/drizzle/0014_flimsy_legion.sql +1 -0
- package/drizzle/meta/0010_snapshot.json +1010 -0
- package/drizzle/meta/0011_snapshot.json +1055 -0
- package/drizzle/meta/0012_snapshot.json +1062 -0
- package/drizzle/meta/0013_snapshot.json +1068 -0
- package/drizzle/meta/0014_snapshot.json +1075 -0
- package/drizzle/meta/_journal.json +35 -0
- package/package.json +24 -23
- package/dist/core/agents/discovery.d.ts +0 -6
- 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) =>
|
|
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/
|
|
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: [
|
|
79
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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,
|
|
154
|
-
const result = performGitCommit(root,
|
|
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:
|
|
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':
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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') {
|