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