c0de-agent 1.11.0 → 1.12.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 +24 -5
- package/dist/cli/commands/chat.js +87 -1
- package/dist/cli/commands/config.js +17 -3
- package/dist/cli/commands/sessions.js +62 -3
- package/dist/cli/index.js +26 -7
- package/dist/cli/modes/print.js +9 -2
- package/dist/core/compact.d.ts +2 -0
- package/dist/core/compact.js +1 -0
- package/dist/core/config.d.ts +11 -2
- package/dist/core/config.js +73 -6
- package/dist/core/loop/compaction.d.ts +2 -1
- package/dist/core/loop/compaction.js +19 -6
- package/dist/core/loop/stream-collect.js +9 -1
- package/dist/core/loop/subagent.js +23 -5
- package/dist/core/redact.d.ts +11 -0
- package/dist/core/redact.js +49 -0
- package/dist/core/slash.js +93 -51
- package/dist/core/title.js +7 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/db/schema.d.ts +17 -0
- package/dist/db/schema.js +3 -1
- package/dist/kanban/index.d.ts +1 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +5 -1
- package/dist/kanban/store.js +76 -2
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/provider.js +1 -1
- package/dist/llm/retry.d.ts +5 -2
- package/dist/llm/retry.js +6 -5
- package/dist/llm/routing.d.ts +24 -1
- package/dist/llm/routing.js +29 -1
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +14 -0
- package/dist/project/project.js +77 -1
- package/dist/server/agent-manager.d.ts +10 -1
- package/dist/server/agent-manager.js +14 -1
- package/dist/server/auth-manager.d.ts +11 -5
- package/dist/server/auth-manager.js +39 -20
- package/dist/server/context.js +3 -0
- package/dist/server/permission/interactive.d.ts +6 -3
- package/dist/server/permission/interactive.js +20 -4
- package/dist/server/permission/store.d.ts +12 -2
- package/dist/server/permission/store.js +31 -3
- package/dist/server/registry-config.d.ts +14 -0
- package/dist/server/registry-config.js +44 -0
- package/dist/server/routes/auth.js +32 -2
- package/dist/server/routes/chat.d.ts +12 -0
- package/dist/server/routes/chat.js +216 -38
- package/dist/server/routes/commands.d.ts +4 -2
- package/dist/server/routes/commands.js +10 -3
- package/dist/server/routes/config.js +103 -23
- package/dist/server/routes/files.js +22 -4
- package/dist/server/routes/kanban.js +68 -6
- package/dist/server/routes/permissions.d.ts +0 -4
- package/dist/server/routes/permissions.js +133 -7
- package/dist/server/routes/project.js +59 -10
- package/dist/server/routes/provider.js +48 -3
- package/dist/server/routes/session.js +298 -22
- package/dist/server/routes/update.d.ts +11 -5
- package/dist/server/routes/update.js +88 -28
- package/dist/server/server.d.ts +4 -12
- package/dist/server/server.js +198 -102
- package/dist/server/types.d.ts +7 -0
- package/dist/session/archive.d.ts +3 -1
- package/dist/session/archive.js +13 -1
- package/dist/session/branch.js +20 -2
- package/dist/session/import.d.ts +44 -0
- package/dist/session/import.js +83 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/jobs.d.ts +12 -0
- package/dist/session/jobs.js +53 -0
- package/dist/session/message.d.ts +7 -1
- package/dist/session/message.js +21 -1
- package/dist/session/session.d.ts +128 -11
- package/dist/session/session.js +461 -30
- package/dist/session/snapshot.d.ts +5 -2
- package/dist/session/snapshot.js +10 -2
- package/dist/session/types.d.ts +11 -1
- package/dist/shared/types/agent.d.ts +11 -0
- package/dist/shared/types/config.d.ts +5 -1
- package/dist/shared/types/kanban.d.ts +13 -0
- package/dist/shared/types/message.d.ts +9 -0
- package/dist/tools/websearch/index.js +6 -3
- package/dist/update/index.d.ts +3 -3
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +13 -2
- package/dist/update/ipc.js +36 -2
- package/dist/update/snapshot.d.ts +1 -0
- package/dist/update/snapshot.js +2 -0
- package/dist/update/updater.d.ts +15 -1
- package/dist/update/updater.js +45 -17
- package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
- package/drizzle/meta/0008_snapshot.json +875 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +41 -42
package/dist/llm/retry.d.ts
CHANGED
|
@@ -4,9 +4,10 @@ declare const RETRY_MAX_DELAY_NO_HEADERS = 30000;
|
|
|
4
4
|
declare const RETRY_MAX_DELAY = 2147483647;
|
|
5
5
|
/**
|
|
6
6
|
* Compute the delay before the next retry attempt (ms).
|
|
7
|
-
* Honors retry-after / retry-after-ms headers when present, else exponential backoff
|
|
7
|
+
* Honors retry-after / retry-after-ms headers when present, else exponential backoff
|
|
8
|
+
* from the given base delay (defaults to RETRY_INITIAL_DELAY).
|
|
8
9
|
*/
|
|
9
|
-
declare const delay: (attempt: number, error?: unknown) => number;
|
|
10
|
+
declare const delay: (attempt: number, error?: unknown, initialDelayMs?: number) => number;
|
|
10
11
|
/** A normalized, retryable error descriptor for the session layer. */
|
|
11
12
|
type Retryable = {
|
|
12
13
|
message: string;
|
|
@@ -29,6 +30,8 @@ type RetryOptions = {
|
|
|
29
30
|
delayMs: number;
|
|
30
31
|
error: unknown;
|
|
31
32
|
}) => void;
|
|
33
|
+
/** Base delay for exponential backoff (default RETRY_INITIAL_DELAY). */
|
|
34
|
+
initialDelayMs?: number;
|
|
32
35
|
};
|
|
33
36
|
/**
|
|
34
37
|
* Run an async operation with retry. Retries only on retryable LLM errors.
|
package/dist/llm/retry.js
CHANGED
|
@@ -17,9 +17,10 @@ const errorHeaders = (error) => {
|
|
|
17
17
|
const capDelay = (ms) => Math.min(ms, RETRY_MAX_DELAY);
|
|
18
18
|
/**
|
|
19
19
|
* Compute the delay before the next retry attempt (ms).
|
|
20
|
-
* Honors retry-after / retry-after-ms headers when present, else exponential backoff
|
|
20
|
+
* Honors retry-after / retry-after-ms headers when present, else exponential backoff
|
|
21
|
+
* from the given base delay (defaults to RETRY_INITIAL_DELAY).
|
|
21
22
|
*/
|
|
22
|
-
const delay = (attempt, error) => {
|
|
23
|
+
const delay = (attempt, error, initialDelayMs = RETRY_INITIAL_DELAY) => {
|
|
23
24
|
const headers = errorHeaders(error);
|
|
24
25
|
if (headers) {
|
|
25
26
|
const retryAfterMs = headers['retry-after-ms'];
|
|
@@ -37,9 +38,9 @@ const delay = (attempt, error) => {
|
|
|
37
38
|
if (!Number.isNaN(parsed) && parsed > 0)
|
|
38
39
|
return capDelay(Math.ceil(parsed));
|
|
39
40
|
}
|
|
40
|
-
return capDelay(
|
|
41
|
+
return capDelay(initialDelayMs * RETRY_BACKOFF_FACTOR ** (attempt - 1));
|
|
41
42
|
}
|
|
42
|
-
return capDelay(Math.min(
|
|
43
|
+
return capDelay(Math.min(initialDelayMs * RETRY_BACKOFF_FACTOR ** (attempt - 1), RETRY_MAX_DELAY_NO_HEADERS));
|
|
43
44
|
};
|
|
44
45
|
/**
|
|
45
46
|
* Decide whether a thrown error is retryable. Returns undefined when not retryable
|
|
@@ -74,7 +75,7 @@ const withRetry = async (fn, options) => {
|
|
|
74
75
|
const fallbackReason = isLLMError(error)
|
|
75
76
|
? error.reason
|
|
76
77
|
: { _tag: 'InvalidRequest', message: '' };
|
|
77
|
-
const delayMs = Math.min(reasonRetryAfterMs(fallbackReason) ?? delay(attempt, error), canRetry.policy.maxDelay);
|
|
78
|
+
const delayMs = Math.min(reasonRetryAfterMs(fallbackReason) ?? delay(attempt, error, options.initialDelayMs), canRetry.policy.maxDelay);
|
|
78
79
|
options.onRetry?.({ attempt, delayMs, error });
|
|
79
80
|
await sleep(delayMs);
|
|
80
81
|
}
|
package/dist/llm/routing.d.ts
CHANGED
|
@@ -32,5 +32,28 @@ declare const runWithFallback: <T>(registry: Registry, chain: FallbackChain, run
|
|
|
32
32
|
* errors propagate without trying fallbacks.
|
|
33
33
|
*/
|
|
34
34
|
declare const shouldFallOver: (error: unknown) => boolean;
|
|
35
|
+
/** 回退目标所需的最小 provider 信息。 */
|
|
36
|
+
type FallbackSourceProvider = {
|
|
37
|
+
name?: string;
|
|
38
|
+
_tag?: string;
|
|
39
|
+
/** 声明了模型清单的 provider 只对清单内的模型回退;未声明视为不支持(保守)。 */
|
|
40
|
+
models?: Record<string, unknown>;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* 从 config.fallback 构建回退链(主 chat 循环/压缩/标题等 LLM 调用的接线点)。
|
|
44
|
+
* - enabled=false → undefined(保持内置默认:3 次重试、无跨 provider 回退)。
|
|
45
|
+
* - enabled=true → 按配置 maxRetries/retryDelay 重试,并对「声明了同一模型」的
|
|
46
|
+
* 其他 provider 依次回退。无合格回退目标时 fallbacks 为空——重试参数仍然生效。
|
|
47
|
+
* 回退只接受声明了该模型的 provider:对未声明的 provider 盲目回退会在其 API 处
|
|
48
|
+
* 得到 InvalidRequest 并中断整条链(shouldFallOver 不放过非回退错误)。
|
|
49
|
+
*/
|
|
50
|
+
declare function buildFallbackChain(config: {
|
|
51
|
+
providers: FallbackSourceProvider[];
|
|
52
|
+
fallback: {
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
maxRetries: number;
|
|
55
|
+
retryDelay: number;
|
|
56
|
+
};
|
|
57
|
+
}, provider: string, model: string): FallbackChain | undefined;
|
|
35
58
|
export type { FallbackChain };
|
|
36
|
-
export { runWithFallback, shouldFallOver };
|
|
59
|
+
export { buildFallbackChain, runWithFallback, shouldFallOver };
|
package/dist/llm/routing.js
CHANGED
|
@@ -20,6 +20,7 @@ const runWithFallback = async (registry, chain, run) => {
|
|
|
20
20
|
const result = await withRetry(() => run(target.provider, target.model), {
|
|
21
21
|
maxRetries: chain.maxRetries,
|
|
22
22
|
sleep: chain.sleep,
|
|
23
|
+
initialDelayMs: chain.retryDelay,
|
|
23
24
|
});
|
|
24
25
|
return { result, provider: target.provider, model: target.model };
|
|
25
26
|
}
|
|
@@ -50,4 +51,31 @@ const shouldFallOver = (error) => {
|
|
|
50
51
|
return true;
|
|
51
52
|
return false;
|
|
52
53
|
};
|
|
53
|
-
|
|
54
|
+
/**
|
|
55
|
+
* 从 config.fallback 构建回退链(主 chat 循环/压缩/标题等 LLM 调用的接线点)。
|
|
56
|
+
* - enabled=false → undefined(保持内置默认:3 次重试、无跨 provider 回退)。
|
|
57
|
+
* - enabled=true → 按配置 maxRetries/retryDelay 重试,并对「声明了同一模型」的
|
|
58
|
+
* 其他 provider 依次回退。无合格回退目标时 fallbacks 为空——重试参数仍然生效。
|
|
59
|
+
* 回退只接受声明了该模型的 provider:对未声明的 provider 盲目回退会在其 API 处
|
|
60
|
+
* 得到 InvalidRequest 并中断整条链(shouldFallOver 不放过非回退错误)。
|
|
61
|
+
*/
|
|
62
|
+
function buildFallbackChain(config, provider, model) {
|
|
63
|
+
const fb = config.fallback;
|
|
64
|
+
if (!fb.enabled)
|
|
65
|
+
return undefined;
|
|
66
|
+
const fallbacks = config.providers
|
|
67
|
+
.filter((p) => {
|
|
68
|
+
const name = p.name || p._tag;
|
|
69
|
+
if (!name || name === provider)
|
|
70
|
+
return false;
|
|
71
|
+
return p.models !== undefined && model in p.models;
|
|
72
|
+
})
|
|
73
|
+
.map((p) => ({ provider: (p.name || p._tag), model }));
|
|
74
|
+
return {
|
|
75
|
+
primary: { provider, model },
|
|
76
|
+
fallbacks,
|
|
77
|
+
maxRetries: fb.maxRetries,
|
|
78
|
+
retryDelay: fb.retryDelay,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export { buildFallbackChain, runWithFallback, shouldFallOver };
|
package/dist/project/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type { Project } from './project.js';
|
|
2
|
-
export { fromDirectory, getByDirectory, getProject, listProjects, updateProjectName, } from './project.js';
|
|
2
|
+
export { fromDirectory, getByDirectory, getProject, listProjects, relocateProject, updateProjectName, } from './project.js';
|
|
3
3
|
export type { ResolvedProject } from './resolve.js';
|
|
4
4
|
export { resolveProject } from './resolve.js';
|
package/dist/project/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { fromDirectory, getByDirectory, getProject, listProjects, updateProjectName, } from './project.js';
|
|
1
|
+
export { fromDirectory, getByDirectory, getProject, listProjects, relocateProject, updateProjectName, } from './project.js';
|
|
2
2
|
export { resolveProject } from './resolve.js';
|
|
@@ -21,3 +21,17 @@ export declare function listProjects(handle: DB): Promise<Project[]>;
|
|
|
21
21
|
export declare function getProject(handle: DB, id: string): Promise<Project | null>;
|
|
22
22
|
export declare function getByDirectory(handle: DB, directory: string): Promise<Project | null>;
|
|
23
23
|
export declare function updateProjectName(handle: DB, id: string, name: string): Promise<Project | null>;
|
|
24
|
+
/**
|
|
25
|
+
* 项目重新定位(A1:目录被移动/重命名后的恢复通道)。
|
|
26
|
+
*
|
|
27
|
+
* 项目身份 = sha256(worktree),目录移动后旧项目记录的 worktree 失效且不可改 id。
|
|
28
|
+
* 此前唯一恢复路径是「删除项目」:会话打散进孤儿回收站逐条恢复,看板永久丢失。
|
|
29
|
+
* 本函数在单事务内把项目整体迁移到新目录身份(新 id),保住看板与会话归属:
|
|
30
|
+
* 1. 以新目录解析出的 id upsert 项目行(沿用旧名称);
|
|
31
|
+
* 2. 会话 projectId/worktreePath 迁移到新身份;
|
|
32
|
+
* 3. 看板 projectId 迁移(目标为新身份,唯一约束无冲突);
|
|
33
|
+
* 4. 删除旧项目行。
|
|
34
|
+
* 同一 id(如 git 仓库根未变、仅路径符号变化)→ 仅更新 worktree,不换身份。
|
|
35
|
+
* 目标目录已注册为另一项目时抛 TARGET_OCCUPIED(调用方转 409),绝不静默合并。
|
|
36
|
+
*/
|
|
37
|
+
export declare function relocateProject(handle: DB, id: string, directory: string): Promise<Project>;
|
package/dist/project/project.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { eq } from 'drizzle-orm';
|
|
3
|
-
import { projects, sessions } from '../db/schema.js';
|
|
3
|
+
import { kanbanBoards, projects, sessions } from '../db/schema.js';
|
|
4
4
|
import { resolveProject } from './resolve.js';
|
|
5
5
|
function rowToProject(row) {
|
|
6
6
|
return {
|
|
@@ -85,3 +85,79 @@ export async function updateProjectName(handle, id, name) {
|
|
|
85
85
|
.returning();
|
|
86
86
|
return row ? rowToProject(row) : null;
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* 项目重新定位(A1:目录被移动/重命名后的恢复通道)。
|
|
90
|
+
*
|
|
91
|
+
* 项目身份 = sha256(worktree),目录移动后旧项目记录的 worktree 失效且不可改 id。
|
|
92
|
+
* 此前唯一恢复路径是「删除项目」:会话打散进孤儿回收站逐条恢复,看板永久丢失。
|
|
93
|
+
* 本函数在单事务内把项目整体迁移到新目录身份(新 id),保住看板与会话归属:
|
|
94
|
+
* 1. 以新目录解析出的 id upsert 项目行(沿用旧名称);
|
|
95
|
+
* 2. 会话 projectId/worktreePath 迁移到新身份;
|
|
96
|
+
* 3. 看板 projectId 迁移(目标为新身份,唯一约束无冲突);
|
|
97
|
+
* 4. 删除旧项目行。
|
|
98
|
+
* 同一 id(如 git 仓库根未变、仅路径符号变化)→ 仅更新 worktree,不换身份。
|
|
99
|
+
* 目标目录已注册为另一项目时抛 TARGET_OCCUPIED(调用方转 409),绝不静默合并。
|
|
100
|
+
*/
|
|
101
|
+
export async function relocateProject(handle, id, directory) {
|
|
102
|
+
const current = await getProject(handle, id);
|
|
103
|
+
if (!current)
|
|
104
|
+
throw new Error('PROJECT_NOT_FOUND');
|
|
105
|
+
const resolved = resolveProject(directory);
|
|
106
|
+
if (resolved.id === id) {
|
|
107
|
+
// 身份不变:仅刷新 worktree 与 git 元数据;会话 worktreePath 同步。
|
|
108
|
+
await handle.db.transaction(async (tx) => {
|
|
109
|
+
await tx
|
|
110
|
+
.update(projects)
|
|
111
|
+
.set({
|
|
112
|
+
worktree: resolved.worktree,
|
|
113
|
+
vcs: resolved.vcs,
|
|
114
|
+
gitRemote: resolved.gitRemote,
|
|
115
|
+
updatedAt: new Date(),
|
|
116
|
+
})
|
|
117
|
+
.where(eq(projects.id, id));
|
|
118
|
+
await tx
|
|
119
|
+
.update(sessions)
|
|
120
|
+
.set({ worktreePath: resolved.worktree })
|
|
121
|
+
.where(eq(sessions.projectId, id));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const occupied = await getProject(handle, resolved.id);
|
|
126
|
+
if (occupied) {
|
|
127
|
+
throw new Error(`TARGET_OCCUPIED: 目标目录已注册为项目「${occupied.name ?? '未命名项目'}」`);
|
|
128
|
+
}
|
|
129
|
+
await handle.db.transaction(async (tx) => {
|
|
130
|
+
await tx
|
|
131
|
+
.insert(projects)
|
|
132
|
+
.values({
|
|
133
|
+
id: resolved.id,
|
|
134
|
+
worktree: resolved.worktree,
|
|
135
|
+
vcs: resolved.vcs,
|
|
136
|
+
name: current.name ?? basename(resolved.worktree),
|
|
137
|
+
gitRemote: resolved.gitRemote,
|
|
138
|
+
})
|
|
139
|
+
.onConflictDoUpdate({
|
|
140
|
+
target: projects.id,
|
|
141
|
+
set: {
|
|
142
|
+
worktree: resolved.worktree,
|
|
143
|
+
vcs: resolved.vcs,
|
|
144
|
+
gitRemote: resolved.gitRemote,
|
|
145
|
+
updatedAt: new Date(),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
await tx
|
|
149
|
+
.update(sessions)
|
|
150
|
+
.set({ projectId: resolved.id, worktreePath: resolved.worktree })
|
|
151
|
+
.where(eq(sessions.projectId, id));
|
|
152
|
+
await tx
|
|
153
|
+
.update(kanbanBoards)
|
|
154
|
+
.set({ projectId: resolved.id })
|
|
155
|
+
.where(eq(kanbanBoards.projectId, id));
|
|
156
|
+
await tx.delete(projects).where(eq(projects.id, id));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const result = await getProject(handle, resolved.id);
|
|
160
|
+
if (!result)
|
|
161
|
+
throw new Error(`Relocate failed for ${directory}`);
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
@@ -36,16 +36,25 @@ type AgentManager = {
|
|
|
36
36
|
abort(sessionId: string): boolean;
|
|
37
37
|
pause(sessionId: string): boolean;
|
|
38
38
|
resume(sessionId: string): boolean;
|
|
39
|
-
/** 暂停全部活跃 run 并等待到达安全点;超时强制中止(热更新前调用)。
|
|
39
|
+
/** 暂停全部活跃 run 并等待到达安全点;超时强制中止(热更新前调用)。
|
|
40
|
+
* 返回 pausedIds:成功暂停的会话 id 列表——调用方在后续步骤(spawn 等)
|
|
41
|
+
* 失败时必须对其逐个 resume 回滚,避免更新失败连带冻结用户对话。 */
|
|
40
42
|
pauseAll(timeoutMs?: number): Promise<{
|
|
41
43
|
paused: number;
|
|
42
44
|
forcedAbort: number;
|
|
45
|
+
pausedIds: string[];
|
|
43
46
|
}>;
|
|
44
47
|
steer(sessionId: string, message: string): boolean;
|
|
45
48
|
/** 查询某 session 的所有子 agent run(恢复/展示用)。 */
|
|
46
49
|
children(parentSessionId: string): ActiveRun[];
|
|
47
50
|
/** 查询所有后台任务(jobId 非空的 run)。 */
|
|
48
51
|
backgroundJobs(): ActiveRun[];
|
|
52
|
+
/** 列出全部活跃 run(含子 agent)——热更新影响面展示用。 */
|
|
53
|
+
listActive(): Array<{
|
|
54
|
+
sessionId: string;
|
|
55
|
+
agentType?: string;
|
|
56
|
+
parentSessionId?: string;
|
|
57
|
+
}>;
|
|
49
58
|
/** 中止所有活跃 run 并清空(dev 热重载重建前调用)。 */
|
|
50
59
|
dispose(): void;
|
|
51
60
|
};
|
|
@@ -80,7 +80,10 @@ function createAgentManager() {
|
|
|
80
80
|
forcedAbort += 1;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
const pausedIds = active
|
|
84
|
+
.filter((r) => r.state.status._tag === 'paused')
|
|
85
|
+
.map((r) => r.sessionId);
|
|
86
|
+
return { paused: active.length - forcedAbort, forcedAbort, pausedIds };
|
|
84
87
|
},
|
|
85
88
|
steer(sessionId, message) {
|
|
86
89
|
const run = slotRun(runs.get(sessionId));
|
|
@@ -99,6 +102,16 @@ function createAgentManager() {
|
|
|
99
102
|
.map((slot) => slotRun(slot))
|
|
100
103
|
.filter((r) => r?.jobId !== undefined);
|
|
101
104
|
},
|
|
105
|
+
listActive() {
|
|
106
|
+
return Array.from(runs.values())
|
|
107
|
+
.map((slot) => slotRun(slot))
|
|
108
|
+
.filter((r) => r !== undefined)
|
|
109
|
+
.map((r) => ({
|
|
110
|
+
sessionId: r.sessionId,
|
|
111
|
+
agentType: r.agentType,
|
|
112
|
+
parentSessionId: r.parentSessionId,
|
|
113
|
+
}));
|
|
114
|
+
},
|
|
102
115
|
dispose() {
|
|
103
116
|
// dev 热重载重建前调用:中止所有活跃 run(loop 在 turn/流边界检测 signal
|
|
104
117
|
// → unwind → 调用方 finally 持久化 + unregister),然后清空 Map。
|
|
@@ -10,6 +10,8 @@ type PendingPairing = {
|
|
|
10
10
|
code: string;
|
|
11
11
|
createdAt: number;
|
|
12
12
|
status: 'pending' | 'approved' | 'denied';
|
|
13
|
+
/** 请求来源(尽力而为:x-forwarded-for 首跳,本地回环时为 local)。仅展示 + 软限流,不作身份断言。 */
|
|
14
|
+
source: string;
|
|
13
15
|
deviceToken?: string;
|
|
14
16
|
};
|
|
15
17
|
type AuthManagerOptions = {
|
|
@@ -20,8 +22,9 @@ type AuthManagerOptions = {
|
|
|
20
22
|
tokenFilePath?: string;
|
|
21
23
|
/** 配对码有效期(ms),默认 10 分钟。 */
|
|
22
24
|
pairingTtlMs?: number;
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
+
/** 首设备 bootstrap TTL(ms):>0 时,bootstrap 超过此时长即拒绝首设备注册
|
|
26
|
+
* (需重启 serve 重新生成)。0=不限制。用于缩短「URL 泄漏后被抢先注册」的窗口。 */
|
|
27
|
+
firstDeviceTtlMs?: number;
|
|
25
28
|
now?: () => number;
|
|
26
29
|
};
|
|
27
30
|
export type AuthManager = {
|
|
@@ -29,14 +32,16 @@ export type AuthManager = {
|
|
|
29
32
|
readonly bootstrap: string | undefined;
|
|
30
33
|
/** API/WS 请求校验:仅设备 token 有效(静态模式下 staticToken 有效)。 */
|
|
31
34
|
verify(token: string | undefined): boolean;
|
|
32
|
-
/** handoff 端点校验:设备 token +
|
|
35
|
+
/** handoff 端点校验:设备 token + 当前 bootstrap(历史 bootstrap 不接受,
|
|
36
|
+
* 否则已泄漏的旧 URL token 仍可借 handoff 强杀本地服务)。 */
|
|
33
37
|
verifyHandoff(token: string | undefined): boolean;
|
|
34
38
|
/** 首次设备注册:校验 bootstrap 后换发设备 token 并轮换 bootstrap。
|
|
35
39
|
* 无设备时首个凭 bootstrap 的请求即视为首设备(免审批),后续请求需配对审批。
|
|
36
40
|
* 返回新设备 token;bootstrap 已失效/已注册过 → 返回 null。 */
|
|
37
41
|
registerFirstDevice(bootstrapToken: string, deviceName: string): Promise<string | null>;
|
|
38
|
-
/** 发起配对请求(未认证):返回 { pairingId, code };静态模式/未启用返回 null。
|
|
39
|
-
|
|
42
|
+
/** 发起配对请求(未认证):返回 { pairingId, code };静态模式/未启用返回 null。
|
|
43
|
+
* source 为请求来源(IP 等,尽力而为),用于审批展示与按来源限流。 */
|
|
44
|
+
requestPairing(deviceName: string, source?: string): {
|
|
40
45
|
pairingId: string;
|
|
41
46
|
code: string;
|
|
42
47
|
} | null;
|
|
@@ -57,6 +62,7 @@ export type AuthManager = {
|
|
|
57
62
|
deviceName: string;
|
|
58
63
|
code: string;
|
|
59
64
|
createdAt: number;
|
|
65
|
+
source: string;
|
|
60
66
|
}>;
|
|
61
67
|
/** 审批配对(需已认证设备调用):通过后为请求设备签发 token。 */
|
|
62
68
|
approvePairing(pairingId: string): boolean;
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
// - 显式配置 security.token 时为静态模式:不轮换、不配对(CI/脚本场景)。
|
|
10
10
|
// - authEnabled=false 时不启用。
|
|
11
11
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync, } from 'node:fs';
|
|
13
13
|
import { basename, dirname, join } from 'node:path';
|
|
14
14
|
const DEVICES_FILENAME = 'devices.json';
|
|
15
|
+
/** 首设备 bootstrap token 默认有效期:5 分钟。缩短「URL 泄漏后抢先注册」竞态窗口。
|
|
16
|
+
* 重新 `c0de serve` 会重新生成 bootstrap(无已注册设备时),刷新该窗口。 */
|
|
17
|
+
const DEFAULT_FIRST_DEVICE_TTL_MS = 5 * 60 * 1000;
|
|
15
18
|
function hashToken(token) {
|
|
16
19
|
return createHash('sha256').update(token).digest('hex');
|
|
17
20
|
}
|
|
@@ -25,15 +28,13 @@ function safeEqual(a, b) {
|
|
|
25
28
|
export function createAuthManager(opts) {
|
|
26
29
|
const { dataDir, staticToken, tokenFilePath, now = Date.now } = opts;
|
|
27
30
|
const pairingTtlMs = opts.pairingTtlMs ?? 10 * 60 * 1000;
|
|
28
|
-
const
|
|
31
|
+
const firstDeviceTtlMs = opts.firstDeviceTtlMs ?? DEFAULT_FIRST_DEVICE_TTL_MS;
|
|
29
32
|
const tokenPath = tokenFilePath ?? join(dataDir, 'auth-token');
|
|
30
33
|
const devicesPath = join(dataDir, DEVICES_FILENAME);
|
|
31
34
|
// ── 状态加载 ──
|
|
32
35
|
let bootstrap = staticToken && staticToken.length > 0 ? staticToken : readBootstrapToken(tokenPath);
|
|
33
36
|
const devices = new Map();
|
|
34
37
|
loadDevices();
|
|
35
|
-
// 轮换历史 bootstrap(仅 handoff 校验接受,API 不接受)
|
|
36
|
-
const previousBootstraps = [];
|
|
37
38
|
const pending = new Map();
|
|
38
39
|
function readBootstrapToken(path) {
|
|
39
40
|
try {
|
|
@@ -95,17 +96,11 @@ export function createAuthManager(opts) {
|
|
|
95
96
|
function rotateBootstrap() {
|
|
96
97
|
if (staticToken && staticToken.length > 0)
|
|
97
98
|
return; // 静态模式不轮换
|
|
98
|
-
|
|
99
|
-
if (bootstrap) {
|
|
100
|
-
previousBootstraps.push(bootstrap);
|
|
101
|
-
if (previousBootstraps.length > handoffRetention)
|
|
102
|
-
previousBootstraps.shift();
|
|
103
|
-
}
|
|
104
|
-
bootstrap = next;
|
|
99
|
+
bootstrap = randomBytes(32).toString('hex');
|
|
105
100
|
try {
|
|
106
101
|
if (!existsSync(dataDir))
|
|
107
102
|
mkdirSync(dataDir, { recursive: true });
|
|
108
|
-
writeFileSync(tokenPath,
|
|
103
|
+
writeFileSync(tokenPath, bootstrap, 'utf-8');
|
|
109
104
|
}
|
|
110
105
|
catch {
|
|
111
106
|
// 写文件失败:内存 token 仍已轮换,进程内一致
|
|
@@ -124,6 +119,17 @@ export function createAuthManager(opts) {
|
|
|
124
119
|
return false;
|
|
125
120
|
return safeEqual(token, bootstrap);
|
|
126
121
|
}
|
|
122
|
+
/** bootstrap 自生成(token 文件 mtime)起的存活时长;静态模式或无法读取返回 null。 */
|
|
123
|
+
function bootstrapAge() {
|
|
124
|
+
if (staticToken && staticToken.length > 0)
|
|
125
|
+
return null;
|
|
126
|
+
try {
|
|
127
|
+
return now() - statSync(tokenPath).mtimeMs;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
127
133
|
function cleanExpiredPairings() {
|
|
128
134
|
const cutoff = now() - pairingTtlMs;
|
|
129
135
|
for (const [id, p] of pending) {
|
|
@@ -150,10 +156,6 @@ export function createAuthManager(opts) {
|
|
|
150
156
|
return true;
|
|
151
157
|
if (verifyBootstrap(token))
|
|
152
158
|
return true;
|
|
153
|
-
for (const prev of previousBootstraps) {
|
|
154
|
-
if (safeEqual(token, prev))
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
157
159
|
return false;
|
|
158
160
|
},
|
|
159
161
|
async registerFirstDevice(bootstrapToken, deviceName) {
|
|
@@ -164,6 +166,12 @@ export function createAuthManager(opts) {
|
|
|
164
166
|
// 已有设备 → bootstrap 已失效,不得再凭它注册(需配对审批)
|
|
165
167
|
if (devices.size > 0)
|
|
166
168
|
return null;
|
|
169
|
+
// 首设备注册窗口:bootstrap 超过 firstDeviceTtlMs 后拒绝(缩短先到先得竞态窗口)。
|
|
170
|
+
if (firstDeviceTtlMs > 0) {
|
|
171
|
+
const age = bootstrapAge();
|
|
172
|
+
if (age != null && age > firstDeviceTtlMs)
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
167
175
|
const deviceToken = randomBytes(32).toString('hex');
|
|
168
176
|
const record = {
|
|
169
177
|
id: randomBytes(16).toString('hex'),
|
|
@@ -176,14 +184,19 @@ export function createAuthManager(opts) {
|
|
|
176
184
|
rotateBootstrap(); // 注册成功即轮换,旧 bootstrap(URL/shell history 中)立即失效
|
|
177
185
|
return deviceToken;
|
|
178
186
|
},
|
|
179
|
-
requestPairing(deviceName) {
|
|
187
|
+
requestPairing(deviceName, source) {
|
|
180
188
|
if (staticToken && staticToken.length > 0)
|
|
181
189
|
return null;
|
|
182
190
|
cleanExpiredPairings();
|
|
183
|
-
// 限流:最多 10
|
|
191
|
+
// 限流:最多 10 个待审批;同一来源最多 3 个(防本地/远程进程刷满队列,
|
|
192
|
+
// 使合法设备 10 分钟内无法配对)。source 尽力而为,缺省视为 'unknown'。
|
|
184
193
|
const pendingCount = Array.from(pending.values()).filter((p) => p.status === 'pending').length;
|
|
185
194
|
if (pendingCount >= 10)
|
|
186
195
|
return null;
|
|
196
|
+
const src = source ?? 'unknown';
|
|
197
|
+
const perSource = Array.from(pending.values()).filter((p) => p.status === 'pending' && p.source === src).length;
|
|
198
|
+
if (perSource >= 3)
|
|
199
|
+
return null;
|
|
187
200
|
const pairingId = randomBytes(16).toString('hex');
|
|
188
201
|
const code = String(100000 + Math.floor(Math.random() * 900000)); // 6 位数字
|
|
189
202
|
pending.set(pairingId, {
|
|
@@ -192,6 +205,7 @@ export function createAuthManager(opts) {
|
|
|
192
205
|
code,
|
|
193
206
|
createdAt: now(),
|
|
194
207
|
status: 'pending',
|
|
208
|
+
source: src,
|
|
195
209
|
});
|
|
196
210
|
return { pairingId, code };
|
|
197
211
|
},
|
|
@@ -218,11 +232,14 @@ export function createAuthManager(opts) {
|
|
|
218
232
|
deviceName: p.deviceName,
|
|
219
233
|
code: p.code,
|
|
220
234
|
createdAt: p.createdAt,
|
|
235
|
+
source: p.source,
|
|
221
236
|
}));
|
|
222
237
|
},
|
|
223
238
|
approvePairing(pairingId) {
|
|
224
239
|
const p = pending.get(pairingId);
|
|
225
|
-
if (!p
|
|
240
|
+
if (!p)
|
|
241
|
+
return false;
|
|
242
|
+
if (p.status !== 'pending')
|
|
226
243
|
return false;
|
|
227
244
|
if (p.createdAt <= now() - pairingTtlMs) {
|
|
228
245
|
pending.delete(pairingId);
|
|
@@ -243,7 +260,9 @@ export function createAuthManager(opts) {
|
|
|
243
260
|
},
|
|
244
261
|
denyPairing(pairingId) {
|
|
245
262
|
const p = pending.get(pairingId);
|
|
246
|
-
if (!p
|
|
263
|
+
if (!p)
|
|
264
|
+
return false;
|
|
265
|
+
if (p.status !== 'pending')
|
|
247
266
|
return false;
|
|
248
267
|
p.status = 'denied';
|
|
249
268
|
return true;
|
package/dist/server/context.js
CHANGED
|
@@ -36,6 +36,9 @@ function createServerContext(opts) {
|
|
|
36
36
|
permissionStore: createPermissionStore(),
|
|
37
37
|
permissionMode: config.permission.defaultMode,
|
|
38
38
|
sessionPermissionModes: new Map(),
|
|
39
|
+
sessionAlwaysAllow: new Map(),
|
|
40
|
+
// P1-1:项目级 LLM 注册表缓存(按项目合并配置构建,chat 路由按需创建)。
|
|
41
|
+
projectRegistries: new Map(),
|
|
39
42
|
// 测试工厂不解析/生成 token(不触碰用户全局数据目录)。
|
|
40
43
|
authToken: undefined,
|
|
41
44
|
agentRegistry,
|
|
@@ -3,16 +3,19 @@ import type { PermissionRequest, PermissionStore } from './store.js';
|
|
|
3
3
|
export type { PermissionRequest };
|
|
4
4
|
type PermissionMode = 'default' | 'auto';
|
|
5
5
|
type InteractivePermissionCheckerOptions = {
|
|
6
|
-
/** 始终允许的工具名(覆盖 permission
|
|
7
|
-
|
|
6
|
+
/** 始终允许的工具名(覆盖 permission 字段)。可为 getter:会话级「始终允许」
|
|
7
|
+
* 白名单在 run 期间可被用户追加,getter 使每次 check 读取最新集合。 */
|
|
8
|
+
alwaysAllow?: string[] | (() => string[]);
|
|
8
9
|
/** 始终拒绝的工具名(覆盖 permission 字段)。 */
|
|
9
10
|
alwaysDeny?: string[];
|
|
10
11
|
/** 读取当前授权模式:'auto' 时跳过 ask 交互确认(YOLO 自动授权)。 */
|
|
11
12
|
getMode?: () => PermissionMode;
|
|
12
13
|
/** 遇到 ask 权限时调用(用于通知前端)。 */
|
|
13
14
|
onPermissionRequired?: (request: PermissionRequest) => void | Promise<void>;
|
|
14
|
-
/**
|
|
15
|
+
/** 首层确认超时(仅提示,pending 保持)时调用(P1-6:SSE 通知前端)。 */
|
|
15
16
|
onPermissionTimeout?: (request: PermissionRequest) => void;
|
|
17
|
+
/** 兜底过期(提示后仍无响应,自动拒绝)时调用(P0 双层超时:前端清理弹窗状态)。 */
|
|
18
|
+
onPermissionExpired?: (request: PermissionRequest) => void;
|
|
16
19
|
};
|
|
17
20
|
/** 阻塞式权限检查器:ask 权限会阻塞等待用户确认。
|
|
18
21
|
* pending 存在全局 PermissionStore 中,独立于 agent run 生命周期。 */
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
function createInteractivePermissionChecker(store, opts = {}) {
|
|
3
|
-
const allowSet =
|
|
3
|
+
const allowSet = opts.alwaysAllow
|
|
4
|
+
? Array.isArray(opts.alwaysAllow)
|
|
5
|
+
? new Set(opts.alwaysAllow)
|
|
6
|
+
: null
|
|
7
|
+
: null;
|
|
4
8
|
const denySet = new Set(opts.alwaysDeny ?? []);
|
|
5
9
|
return {
|
|
6
10
|
check: async (tool, input, ctx) => {
|
|
7
11
|
if (denySet.has(tool.name)) {
|
|
8
12
|
return { _tag: 'deny', reason: `Tool "${tool.name}" is denied by configuration` };
|
|
9
13
|
}
|
|
10
|
-
|
|
14
|
+
// 静态集合或动态 getter(会话级白名单可在 run 期间追加)
|
|
15
|
+
const dynamicAllowed = allowSet === null && typeof opts.alwaysAllow === 'function'
|
|
16
|
+
? opts.alwaysAllow().includes(tool.name)
|
|
17
|
+
: allowSet?.has(tool.name);
|
|
18
|
+
if (dynamicAllowed || tool.permission === 'auto') {
|
|
11
19
|
return { _tag: 'allow' };
|
|
12
20
|
}
|
|
13
21
|
if (tool.permission === 'deny') {
|
|
@@ -21,15 +29,23 @@ function createInteractivePermissionChecker(store, opts = {}) {
|
|
|
21
29
|
}
|
|
22
30
|
// 交互式确认
|
|
23
31
|
const toolCallId = randomUUID();
|
|
24
|
-
const request = {
|
|
32
|
+
const request = {
|
|
33
|
+
toolCallId,
|
|
34
|
+
tool: tool.name,
|
|
35
|
+
input,
|
|
36
|
+
// P1:绑定会话 id——挂起期间用户切换页面后,前端经
|
|
37
|
+
// GET /api/permissions/:sessionId/pending 重挂确认弹窗。
|
|
38
|
+
...(ctx.session?.id ? { sessionId: ctx.session.id } : {}),
|
|
39
|
+
};
|
|
25
40
|
const promise = new Promise((resolve) => {
|
|
26
41
|
// pending 注册到全局 store,confirm 端点按 toolCallId 直接寻址,
|
|
27
42
|
// 不依赖当前 agent run 是否仍在 agentManager 中注册。
|
|
28
|
-
// store
|
|
43
|
+
// store 内置双层超时:首层提示(pending 保持),宽限期后兜底 deny。
|
|
29
44
|
store.register(toolCallId, {
|
|
30
45
|
request,
|
|
31
46
|
resolve,
|
|
32
47
|
...(opts.onPermissionTimeout ? { onTimeout: opts.onPermissionTimeout } : {}),
|
|
48
|
+
...(opts.onPermissionExpired ? { onExpired: opts.onPermissionExpired } : {}),
|
|
33
49
|
});
|
|
34
50
|
});
|
|
35
51
|
await opts.onPermissionRequired?.(request);
|
|
@@ -2,23 +2,31 @@ import type { PermissionResult } from '../../tools/types.js';
|
|
|
2
2
|
/** 默认 pending 超时时长:5 分钟(300_000ms)。
|
|
3
3
|
* 超过后自动 resolve 为 deny 并清理 store 条目,避免 promise 永久挂起与 pending Map 泄漏。 */
|
|
4
4
|
declare const DEFAULT_PERMISSION_TIMEOUT_MS: number;
|
|
5
|
+
/** 首层超时(仅提示)后到兜底自动拒绝的宽限期:25 分钟。双层超时总窗口 30 分钟。 */
|
|
6
|
+
declare const DEFAULT_PERMISSION_EXPIRE_GRACE_MS: number;
|
|
5
7
|
/** 权限请求(需要用户确认)。 */
|
|
6
8
|
type PermissionRequest = {
|
|
7
9
|
toolCallId: string;
|
|
8
10
|
tool: string;
|
|
9
11
|
input: unknown;
|
|
12
|
+
/** 请求所属会话(P1:跨页面挂起恢复用;无会话上下文的调用方省略)。 */
|
|
13
|
+
sessionId?: string;
|
|
10
14
|
};
|
|
11
15
|
/** 待处理的权限确认。 */
|
|
12
16
|
type PendingPermission = {
|
|
13
17
|
request: PermissionRequest;
|
|
14
18
|
resolve: (result: PermissionResult) => void;
|
|
15
|
-
/**
|
|
19
|
+
/** 首层超时回调(P1-6:按请求绑定 SSE 流,仅提示不终结)。 */
|
|
16
20
|
onTimeout?: (request: PermissionRequest) => void;
|
|
21
|
+
/** 兜底过期回调(P0 双层超时:提示后仍无响应 → 自动拒绝,通知前端清理弹窗状态)。 */
|
|
22
|
+
onExpired?: (request: PermissionRequest) => void;
|
|
17
23
|
};
|
|
18
24
|
/** createPermissionStore 的配置。 */
|
|
19
25
|
type PermissionStoreOptions = {
|
|
20
26
|
/** pending 超时毫秒数;省略时取 DEFAULT_PERMISSION_TIMEOUT_MS(5 分钟)。 */
|
|
21
27
|
timeoutMs?: number;
|
|
28
|
+
/** 首层超时后到兜底自动拒绝的宽限毫秒数;省略时 25 分钟。 */
|
|
29
|
+
expireGraceMs?: number;
|
|
22
30
|
/** 超时自动拒绝时回调(P1-6:通知前端「确认超时已拒绝」,提供重新询问入口)。 */
|
|
23
31
|
onTimeout?: (request: PermissionRequest) => void;
|
|
24
32
|
};
|
|
@@ -28,9 +36,11 @@ type PermissionStore = {
|
|
|
28
36
|
resolve(toolCallId: string, approved: boolean): boolean;
|
|
29
37
|
has(toolCallId: string): boolean;
|
|
30
38
|
size(): number;
|
|
39
|
+
/** 查询某会话当前挂起的权限请求(P1:前端重挂弹窗;无则 null)。 */
|
|
40
|
+
pendingForSession(sessionId: string): PermissionRequest | null;
|
|
31
41
|
/** settle 所有 pending 为 deny 并清空(dev 热重载重建前调用)。 */
|
|
32
42
|
dispose(): void;
|
|
33
43
|
};
|
|
34
44
|
declare function createPermissionStore(opts?: PermissionStoreOptions): PermissionStore;
|
|
35
45
|
export type { PendingPermission, PermissionRequest, PermissionStore, PermissionStoreOptions };
|
|
36
|
-
export { createPermissionStore, DEFAULT_PERMISSION_TIMEOUT_MS };
|
|
46
|
+
export { createPermissionStore, DEFAULT_PERMISSION_EXPIRE_GRACE_MS, DEFAULT_PERMISSION_TIMEOUT_MS };
|