c0de-agent 1.9.0 → 1.11.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 +25 -8
- package/dist/cli/commands/acp.js +5 -3
- package/dist/cli/commands/auth.d.ts +17 -0
- package/dist/cli/commands/auth.js +73 -0
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/config.d.ts +2 -9
- package/dist/cli/commands/config.js +15 -54
- package/dist/cli/commands/init.js +4 -2
- package/dist/cli/commands/plugin.js +8 -4
- package/dist/cli/commands/serve.d.ts +1 -0
- package/dist/cli/commands/serve.js +4 -1
- package/dist/cli/commands/sessions.d.ts +10 -0
- package/dist/cli/commands/sessions.js +35 -0
- package/dist/cli/commands/update.js +18 -10
- package/dist/cli/deps.d.ts +7 -5
- package/dist/cli/deps.js +23 -7
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +96 -37
- package/dist/cli/modes/print.d.ts +3 -1
- package/dist/cli/modes/print.js +31 -4
- package/dist/core/agent.js +3 -4
- package/dist/core/config-path.d.ts +9 -0
- package/dist/core/config-path.js +48 -0
- package/dist/core/config.d.ts +27 -2
- package/dist/core/config.js +83 -13
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/loop.js +14 -4
- package/dist/core/slash.js +92 -30
- package/dist/core/title.js +15 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/core/workflows/discovery.d.ts +1 -1
- package/dist/core/workflows/index.d.ts +1 -1
- package/dist/db/client.d.ts +4 -0
- package/dist/db/client.js +5 -0
- package/dist/db/schema.d.ts +51 -0
- package/dist/db/schema.js +7 -1
- package/dist/server/agent-manager.d.ts +20 -0
- package/dist/server/agent-manager.js +63 -11
- package/dist/server/app.js +12 -1
- package/dist/server/auth-manager.d.ts +79 -0
- package/dist/server/auth-manager.js +270 -0
- package/dist/server/context.js +3 -0
- package/dist/server/middleware/auth.d.ts +4 -3
- package/dist/server/middleware/auth.js +5 -5
- package/dist/server/middleware/cors.d.ts +11 -2
- package/dist/server/middleware/cors.js +15 -12
- package/dist/server/permission/interactive.d.ts +2 -0
- package/dist/server/permission/interactive.js +5 -1
- package/dist/server/permission/store.d.ts +4 -0
- package/dist/server/permission/store.js +1 -0
- package/dist/server/routes/auth.d.ts +4 -0
- package/dist/server/routes/auth.js +94 -0
- package/dist/server/routes/chat.d.ts +6 -1
- package/dist/server/routes/chat.js +294 -153
- package/dist/server/routes/config.js +60 -8
- package/dist/server/routes/permissions.d.ts +4 -1
- package/dist/server/routes/permissions.js +27 -4
- package/dist/server/routes/project.js +56 -2
- package/dist/server/routes/session.js +37 -9
- package/dist/server/routes/terminal.js +29 -2
- package/dist/server/routes/update.d.ts +1 -0
- package/dist/server/routes/update.js +33 -4
- package/dist/server/routes/workflows.js +2 -2
- package/dist/server/server.d.ts +24 -1
- package/dist/server/server.js +149 -29
- package/dist/server/types.d.ts +17 -2
- package/dist/session/archive.d.ts +1 -1
- package/dist/session/branch.d.ts +11 -2
- package/dist/session/branch.js +107 -31
- package/dist/session/compaction.js +1 -0
- package/dist/session/context.d.ts +3 -2
- package/dist/session/context.js +36 -3
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/session.d.ts +25 -5
- package/dist/session/session.js +129 -10
- package/dist/session/snapshot.d.ts +9 -3
- package/dist/session/snapshot.js +45 -4
- package/dist/session/squash.js +1 -0
- package/dist/session/types.d.ts +3 -1
- package/dist/shared/types/agent.d.ts +11 -0
- package/dist/shared/types/config.d.ts +12 -6
- package/dist/shared/types/message.d.ts +6 -2
- package/dist/shared/utils/path.d.ts +2 -3
- package/dist/shared/utils/path.js +20 -4
- package/dist/tools/builtin/todo.js +5 -4
- package/dist/tools/index.d.ts +14 -3
- package/dist/tools/index.js +35 -14
- package/dist/update/index.d.ts +2 -2
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +38 -5
- package/dist/update/ipc.js +56 -7
- package/dist/update/snapshot.d.ts +4 -0
- package/dist/update/snapshot.js +8 -0
- package/dist/update/updater.d.ts +61 -10
- package/dist/update/updater.js +236 -25
- package/dist/update/version.d.ts +6 -1
- package/dist/update/version.js +18 -3
- package/drizzle/0005_snapshot_mtime.sql +1 -0
- package/drizzle/0006_sour_susan_delgado.sql +2 -0
- package/drizzle/0007_same_wilson_fisk.sql +2 -0
- package/drizzle/meta/0005_snapshot.json +855 -0
- package/drizzle/meta/0006_snapshot.json +867 -0
- package/drizzle/meta/0007_snapshot.json +869 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +1 -7
- package/dist/mcp/index.d.ts +0 -1
- package/dist/mcp/index.js +0 -3
package/dist/server/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/server/server.ts
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
4
|
import { connect as tcpConnect } from 'node:net';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
@@ -12,10 +13,13 @@ import { createAndPopulateRegistry } from '../core/workflows/index.js';
|
|
|
12
13
|
import { createDB, migrateDB } from '../db/index.js';
|
|
13
14
|
import { createRegistry, overrideToCapabilities, rebuildRegistry, registerProvider, } from '../llm/registry.js';
|
|
14
15
|
import { initPlugins } from '../plugins/index.js';
|
|
16
|
+
import { purgeDeletedSessions } from '../session/session.js';
|
|
15
17
|
import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
|
|
16
18
|
import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
|
|
17
19
|
import { createAgentManager } from './agent-manager.js';
|
|
18
20
|
import { createApp } from './app.js';
|
|
21
|
+
import { createAuthManager } from './auth-manager.js';
|
|
22
|
+
import { isAllowedOrigin } from './middleware/cors.js';
|
|
19
23
|
import { createPermissionStore } from './permission/store.js';
|
|
20
24
|
import { PTYManager } from './terminal/pty-manager.js';
|
|
21
25
|
/** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
|
|
@@ -75,6 +79,59 @@ function resolveDbDir() {
|
|
|
75
79
|
return envDir;
|
|
76
80
|
return join(resolveGlobalDataRoot(), 'pglite');
|
|
77
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* 认证 token 解析(P0 安全修复):
|
|
84
|
+
* 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
|
|
85
|
+
* 2. 用户配置 security.token → 使用。
|
|
86
|
+
* 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
|
|
87
|
+
* 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
|
|
88
|
+
* 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
|
|
89
|
+
*/
|
|
90
|
+
function resolveAuthToken(config, dataDir) {
|
|
91
|
+
if (config.security.authEnabled === false)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (config.security.token && config.security.token.length > 0)
|
|
94
|
+
return config.security.token;
|
|
95
|
+
const tokenFile = join(dataDir, 'auth-token');
|
|
96
|
+
const envToken = process.env.C0DE_AUTH_TOKEN;
|
|
97
|
+
if (envToken) {
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(dataDir, { recursive: true });
|
|
100
|
+
writeFileSync(tokenFile, envToken, { mode: 0o600 });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// 落盘失败不影响本次运行
|
|
104
|
+
}
|
|
105
|
+
return envToken;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const existing = readFileSync(tokenFile, 'utf-8').trim();
|
|
109
|
+
if (existing)
|
|
110
|
+
return existing;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// 文件不存在,走生成分支
|
|
114
|
+
}
|
|
115
|
+
const generated = randomBytes(24).toString('hex');
|
|
116
|
+
try {
|
|
117
|
+
mkdirSync(dataDir, { recursive: true });
|
|
118
|
+
writeFileSync(tokenFile, generated, { mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// 落盘失败:本次运行仍可用内存 token
|
|
122
|
+
}
|
|
123
|
+
return generated;
|
|
124
|
+
}
|
|
125
|
+
/** 只读 dataDir 下已存在的 auth-token(新实例 handoff 握手用;不生成)。 */
|
|
126
|
+
function readAuthTokenFile(dataDir) {
|
|
127
|
+
try {
|
|
128
|
+
const t = readFileSync(join(dataDir, 'auth-token'), 'utf-8').trim();
|
|
129
|
+
return t || undefined;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
78
135
|
/** 一次性迁移:<cwd>/.c0de/pglite → 全局路径。仅当目标不存在时执行。
|
|
79
136
|
*
|
|
80
137
|
* 多个项目各有 .c0de/pglite 时只迁移第一个遇到的(首次启动即触发);
|
|
@@ -130,6 +187,16 @@ async function buildServerContext(db, opts = {}) {
|
|
|
130
187
|
// 工作流注册表:三级发现(builtin → global → project),eager 初始化。
|
|
131
188
|
// 此前是惰性 getter 只注册 builtin,导致项目级 .c0de/workflows/*.js 永远不可见。
|
|
132
189
|
const workflowRegistry = await createAndPopulateRegistry(cwd);
|
|
190
|
+
const dataDir = resolveDbDir();
|
|
191
|
+
// 先解析/生成 bootstrap token(落盘),再创建 authManager 读取——
|
|
192
|
+
// 首启时文件由 resolveAuthToken 生成,顺序颠倒会导致 bootstrap 读到空值。
|
|
193
|
+
const resolvedToken = resolveAuthToken(config, dataDir);
|
|
194
|
+
const authManager = createAuthManager({
|
|
195
|
+
dataDir,
|
|
196
|
+
...(config.security.token && config.security.token.length > 0
|
|
197
|
+
? { staticToken: config.security.token }
|
|
198
|
+
: {}),
|
|
199
|
+
});
|
|
133
200
|
const ctx = {
|
|
134
201
|
db,
|
|
135
202
|
config,
|
|
@@ -141,6 +208,9 @@ async function buildServerContext(db, opts = {}) {
|
|
|
141
208
|
agentManager: createAgentManager(),
|
|
142
209
|
permissionStore: createPermissionStore(),
|
|
143
210
|
permissionMode: config.permission.defaultMode,
|
|
211
|
+
sessionPermissionModes: new Map(),
|
|
212
|
+
authToken: resolvedToken,
|
|
213
|
+
authManager: config.security.authEnabled === false ? undefined : authManager,
|
|
144
214
|
// Agent 注册表:内置 4 个默认 agent;项目/用户自定义 agent 可在启动后补充加载。
|
|
145
215
|
agentRegistry: (() => {
|
|
146
216
|
const reg = createAgentRegistry();
|
|
@@ -158,30 +228,18 @@ async function buildServerContext(db, opts = {}) {
|
|
|
158
228
|
cwd,
|
|
159
229
|
ptyManager: new PTYManager(),
|
|
160
230
|
};
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// 或测试 skipHandoff 时跳过(减少后台资源占用)。
|
|
164
|
-
let handoffServer;
|
|
165
|
-
if (config.update.enabled && !opts.skipHandoff) {
|
|
166
|
-
const createHandoff = opts.createHandoffFn ?? createHandoffServer;
|
|
167
|
-
handoffServer = await createHandoff(async () => {
|
|
168
|
-
// 序列化在热更新主链路(performHotUpdate)已完成;handoff 回调只需释放资源。
|
|
169
|
-
// 这里提前停 scheduler 以避免 close 期间还在跑检查。
|
|
170
|
-
ctx.updateScheduler.stop();
|
|
171
|
-
});
|
|
172
|
-
ctx.handoff = { port: handoffServer.port, server: handoffServer };
|
|
173
|
-
}
|
|
231
|
+
// handoff server 不在此创建:它需要持有主 HTTP server 引用以触发完整关停,
|
|
232
|
+
// 由 startServer 在 serve() 之后创建(dev 模式走 vite,不创建)。
|
|
174
233
|
return {
|
|
175
234
|
ctx,
|
|
176
235
|
dispose: async () => {
|
|
177
236
|
// dev 重建前调用:中止活跃 run + settle pending permission +
|
|
178
|
-
// 停 scheduler + 关
|
|
237
|
+
// 停 scheduler + 关 devices.json watcher。**不 close db**(调用方持有)。
|
|
179
238
|
ctx.agentManager.dispose();
|
|
180
239
|
ctx.permissionStore.dispose();
|
|
181
240
|
ctx.updateScheduler.stop();
|
|
182
241
|
ctx.ptyManager.dispose();
|
|
183
|
-
|
|
184
|
-
await handoffServer.close();
|
|
242
|
+
ctx.authManager?.dispose();
|
|
185
243
|
},
|
|
186
244
|
};
|
|
187
245
|
}
|
|
@@ -288,9 +346,26 @@ async function bootstrapServerContext(opts = {}) {
|
|
|
288
346
|
db = await createDevDb(cwd);
|
|
289
347
|
}
|
|
290
348
|
const { ctx, dispose } = await buildServerContext(db, opts);
|
|
349
|
+
// 回收站清理:启动时清一次 + 每 24h 清一次(软删除保留 30 天)。
|
|
350
|
+
// fire-and-forget:清理失败不阻塞服务启动。
|
|
351
|
+
void purgeDeletedSessions(db).catch(() => { });
|
|
352
|
+
const purgeTimer = setInterval(() => {
|
|
353
|
+
void purgeDeletedSessions(db).catch(() => { });
|
|
354
|
+
}, 24 * 60 * 60 * 1000);
|
|
355
|
+
purgeTimer.unref();
|
|
356
|
+
// P2-15:PGLite WAL 周期刷盘(30s),缩短 kill -9 等突然停机时的已提交数据丢失窗口。
|
|
357
|
+
const syncTimer = db.sync
|
|
358
|
+
? setInterval(() => {
|
|
359
|
+
void db.sync?.().catch(() => { });
|
|
360
|
+
}, 30_000)
|
|
361
|
+
: null;
|
|
362
|
+
syncTimer?.unref();
|
|
291
363
|
return {
|
|
292
364
|
ctx,
|
|
293
365
|
close: async () => {
|
|
366
|
+
clearInterval(purgeTimer);
|
|
367
|
+
if (syncTimer)
|
|
368
|
+
clearInterval(syncTimer);
|
|
294
369
|
await dispose();
|
|
295
370
|
if (ownsDb)
|
|
296
371
|
await db.close();
|
|
@@ -301,13 +376,24 @@ async function bootstrapServerContext(opts = {}) {
|
|
|
301
376
|
* 请求旧实例 handoff 后轮询端口释放(spec §18.3)。旧实例收到 /handoff 后
|
|
302
377
|
* 优雅退出释放端口;新实例轮询 TCP 连接直到被拒(端口已释放)或超时报错。
|
|
303
378
|
*/
|
|
304
|
-
async function requestHandoffWithRetry(port, maxAttempts = 30, delayMs = 100) {
|
|
379
|
+
async function requestHandoffWithRetry(port, token, maxAttempts = 30, delayMs = 100) {
|
|
305
380
|
try {
|
|
306
|
-
await requestHandoff(port);
|
|
381
|
+
await requestHandoff(port, '127.0.0.1', token);
|
|
307
382
|
}
|
|
308
|
-
catch {
|
|
309
|
-
|
|
310
|
-
|
|
383
|
+
catch (err) {
|
|
384
|
+
const handoffErr = err;
|
|
385
|
+
// 连接层失败(ECONNREFUSED 等):旧实例未启 handoff 端点(update 未启用);
|
|
386
|
+
// 直接尝试绑端口。
|
|
387
|
+
if (handoffErr?.kind === 'connect')
|
|
388
|
+
return;
|
|
389
|
+
// HTTP 401:旧实例存在但 auth token 不匹配——立即明确报错退出。若继续
|
|
390
|
+
// bind 必撞 EADDRINUSE,把真实原因(token 校验失败)误报成端口占用。
|
|
391
|
+
if (handoffErr?.kind === 'http' && handoffErr.status === 401) {
|
|
392
|
+
throw new Error(`handoff: 端口 ${port} 上的旧实例拒绝让渡(HTTP 401:auth token 不匹配)。\n` +
|
|
393
|
+
`新旧实例须共享同一 token(C0DE_AUTH_TOKEN 环境变量或数据目录 auth-token 文件),\n` +
|
|
394
|
+
`请核对配置后重试。`);
|
|
395
|
+
}
|
|
396
|
+
throw err;
|
|
311
397
|
}
|
|
312
398
|
for (let i = 0; i < maxAttempts; i++) {
|
|
313
399
|
const released = await new Promise((resolve) => {
|
|
@@ -327,8 +413,10 @@ async function requestHandoffWithRetry(port, maxAttempts = 30, delayMs = 100) {
|
|
|
327
413
|
async function startServer(opts = {}) {
|
|
328
414
|
const port = opts.port ?? 3000;
|
|
329
415
|
// spec §18.3:新实例从 --handoff-port 拿到旧实例端口,请求优雅退出后绑端口。
|
|
416
|
+
// 握手 token:updater spawn 时经 C0DE_AUTH_TOKEN 环境变量传入,否则读 auth-token 文件。
|
|
330
417
|
if (opts.handoffPort && !opts.skipHandoff) {
|
|
331
|
-
|
|
418
|
+
const pendingToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
|
|
419
|
+
await requestHandoffWithRetry(opts.handoffPort, pendingToken);
|
|
332
420
|
}
|
|
333
421
|
const { ctx, close: closeCtx } = await bootstrapServerContext(opts);
|
|
334
422
|
const app = createApp(ctx);
|
|
@@ -336,10 +424,10 @@ async function startServer(opts = {}) {
|
|
|
336
424
|
if (ctx.config.update.enabled)
|
|
337
425
|
ctx.updateScheduler.start();
|
|
338
426
|
const server = serve({ fetch: app.fetch, port });
|
|
427
|
+
ctx.port = port;
|
|
339
428
|
// WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
|
|
340
429
|
// 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
|
|
341
430
|
const wss = new WebSocketServer({ noServer: true });
|
|
342
|
-
const expectedToken = ctx.config.security.authEnabled ? ctx.config.security.token : undefined;
|
|
343
431
|
server.on('upgrade', (req, socket, head) => {
|
|
344
432
|
const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
|
|
345
433
|
const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
|
|
@@ -347,10 +435,22 @@ async function startServer(opts = {}) {
|
|
|
347
435
|
socket.destroy();
|
|
348
436
|
return;
|
|
349
437
|
}
|
|
350
|
-
//
|
|
351
|
-
|
|
438
|
+
// Origin 校验:浏览器 WS 不受 CORS 约束,必须在服务端显式校验。
|
|
439
|
+
// 非浏览器客户端(无 Origin 头)放行——token 仍是硬门槛。
|
|
440
|
+
const origin = req.headers.origin;
|
|
441
|
+
if (origin && !isAllowedOrigin(origin, ctx.config.security.allowedOrigins)) {
|
|
442
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
443
|
+
socket.destroy();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)。
|
|
447
|
+
// P2-16:优先 authManager 校验(设备 token);回退 ctx.authToken 兼容测试上下文。
|
|
448
|
+
if (ctx.authManager || ctx.authToken) {
|
|
352
449
|
const token = url.searchParams.get('token');
|
|
353
|
-
|
|
450
|
+
const ok = ctx.authManager
|
|
451
|
+
? ctx.authManager.verify(token ?? undefined)
|
|
452
|
+
: token === ctx.authToken;
|
|
453
|
+
if (!ok) {
|
|
354
454
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
355
455
|
socket.destroy();
|
|
356
456
|
return;
|
|
@@ -370,7 +470,7 @@ async function startServer(opts = {}) {
|
|
|
370
470
|
});
|
|
371
471
|
});
|
|
372
472
|
let closed = false;
|
|
373
|
-
const
|
|
473
|
+
const closeMain = async () => {
|
|
374
474
|
if (closed)
|
|
375
475
|
return;
|
|
376
476
|
closed = true;
|
|
@@ -378,6 +478,26 @@ async function startServer(opts = {}) {
|
|
|
378
478
|
server.close();
|
|
379
479
|
await closeCtx();
|
|
380
480
|
};
|
|
381
|
-
|
|
481
|
+
const close = async () => {
|
|
482
|
+
await closeMain();
|
|
483
|
+
await handoffServer?.close();
|
|
484
|
+
};
|
|
485
|
+
// spec §18.3 handoff server:旧实例收到 POST /handoff 后关闭主服务与资源,
|
|
486
|
+
// 响应 200,随后退出进程(exitAfterResponse),端口让渡给新实例。
|
|
487
|
+
// config.update.enabled=false 或 skipHandoff 时跳过。
|
|
488
|
+
let handoffServer;
|
|
489
|
+
if (ctx.config.update.enabled && !opts.skipHandoff) {
|
|
490
|
+
const createHandoff = opts.createHandoffFn ?? createHandoffServer;
|
|
491
|
+
handoffServer = await createHandoff(closeMain, {
|
|
492
|
+
// P2-16:token 轮换后新旧实例 bootstrap 可能不同,用 verifyHandoff 接受
|
|
493
|
+
// 当前/历史 bootstrap 与设备 token;无 authManager 回退字符串比较。
|
|
494
|
+
...(ctx.authManager
|
|
495
|
+
? { verify: (t) => ctx.authManager?.verifyHandoff(t) ?? false }
|
|
496
|
+
: { expectedToken: ctx.authToken }),
|
|
497
|
+
exitAfterResponse: true,
|
|
498
|
+
});
|
|
499
|
+
ctx.handoff = { port: handoffServer.port, server: handoffServer };
|
|
500
|
+
}
|
|
501
|
+
return { app, port, close, ...(ctx.authToken ? { authToken: ctx.authToken } : {}) };
|
|
382
502
|
}
|
|
383
|
-
export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
|
|
503
|
+
export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
|
package/dist/server/types.d.ts
CHANGED
|
@@ -8,9 +8,10 @@ import type { Config as SharedConfig } from '../shared/types/config.js';
|
|
|
8
8
|
import type { URLRegistry } from '../shared/types/tool.js';
|
|
9
9
|
import type { ToolRegistry } from '../tools/types.js';
|
|
10
10
|
import type { HandoffServer, UpdateScheduler } from '../update/index.js';
|
|
11
|
-
import type { PTYManager } from './terminal/pty-manager.js';
|
|
12
11
|
import type { AgentManager } from './agent-manager.js';
|
|
12
|
+
import type { AuthManager } from './auth-manager.js';
|
|
13
13
|
import type { PermissionStore } from './permission/store.js';
|
|
14
|
+
import type { PTYManager } from './terminal/pty-manager.js';
|
|
14
15
|
/** 持有所有服务依赖的不可变上下文(config 字段可变用于 PATCH 更新)。 */
|
|
15
16
|
type ServerContext = {
|
|
16
17
|
db: DB;
|
|
@@ -26,8 +27,22 @@ type ServerContext = {
|
|
|
26
27
|
agentManager: AgentManager;
|
|
27
28
|
/** 全局权限确认 store(单例),独立于 agent run 生命周期。 */
|
|
28
29
|
permissionStore: PermissionStore;
|
|
29
|
-
/**
|
|
30
|
+
/** 默认授权模式(config.permission.defaultMode)。 */
|
|
30
31
|
permissionMode: 'default' | 'auto';
|
|
32
|
+
/** 会话级授权模式覆盖:Map<sessionId, mode>。会话未覆盖时回退 permissionMode。
|
|
33
|
+
* P1-5:auto 是高风险状态,按会话隔离避免一个标签页切换影响全部会话。 */
|
|
34
|
+
sessionPermissionModes: Map<string, 'default' | 'auto'>;
|
|
35
|
+
/**
|
|
36
|
+
* API Bearer token(认证未显式关闭时必存在)。
|
|
37
|
+
* 用户配置 security.token 优先;否则自动生成并持久化到数据目录 auth-token 文件
|
|
38
|
+
* (跨重启/热更新稳定,浏览器首访经 ?token= URL 获取后本地保存)。
|
|
39
|
+
* P2-16:bootstrap token 仅用于首设备注册;注册后轮换,API 请求认设备 token。
|
|
40
|
+
*/
|
|
41
|
+
authToken?: string;
|
|
42
|
+
/** 认证管理器(token 轮换 + 设备配对审批,P2-16)。authEnabled=false 或测试上下文为 undefined。 */
|
|
43
|
+
authManager?: AuthManager;
|
|
44
|
+
/** 主 HTTP 服务实际绑定端口(startServer 绑定后回填;热更新 spawn 新实例复用)。 */
|
|
45
|
+
port?: number;
|
|
31
46
|
/** Agent 类型注册表(spec: multi-agent-design)。注入 agent loop 的 runSubAgent。 */
|
|
32
47
|
agentRegistry: AgentRegistry;
|
|
33
48
|
/** 工作流注册表(spec: dynamic-workflow-design)。注入 /workflow slash 命令和 workflowz steering。 */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DB } from '../db/client.js';
|
|
2
2
|
import type { ArchiveRef, CompactionArchive, SessionEntry } from './types.js';
|
|
3
3
|
/** Archive original entries before compaction/squash. Returns the archive id. */
|
|
4
|
-
declare function archiveOriginalEntries(handle: DB, sessionId: string, entries: SessionEntry[], archiveType: 'compaction' | 'squash' | 'shake', summary: string, compactionId: string): Promise<string>;
|
|
4
|
+
declare function archiveOriginalEntries(handle: DB, sessionId: string, entries: SessionEntry[], archiveType: 'compaction' | 'squash' | 'shake' | 'clear', summary: string, compactionId: string): Promise<string>;
|
|
5
5
|
/** Get an archive by id. Returns null for invalid or non-existent ids. */
|
|
6
6
|
declare function getArchive(handle: DB, id: string): Promise<CompactionArchive | null>;
|
|
7
7
|
/** Get the original entries stored in an archive. */
|
package/dist/session/branch.d.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { DB } from '../db/client.js';
|
|
2
2
|
import type { Session, SessionTreeNode } from './types.js';
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* fork 分支点 messageIndex 越界(评审 NIT:客户端索引过期/分页 bug)。
|
|
5
|
+
* 与「会话不存在」区分开:消费路由应映射 400 并透出 message,而非 404。
|
|
6
|
+
*/
|
|
7
|
+
declare class BranchPointOutOfRangeError extends Error {
|
|
8
|
+
constructor(messageIndex: number);
|
|
9
|
+
}
|
|
10
|
+
/** Fork a session at a message index — copies all entries (messages + tool pairs)
|
|
11
|
+
* up to and including the branch point message, plus latest file snapshots. */
|
|
4
12
|
declare function forkSession(handle: DB, sessionId: string, messageIndex: number): Promise<Session>;
|
|
5
13
|
/** Get direct child sessions (branches) of a session. */
|
|
6
14
|
declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
|
|
7
15
|
/** Build a full session tree from root sessions down.
|
|
8
16
|
* 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
|
|
17
|
+
* 排除软删除会话与 CLI 来源会话(Web 树仅展示 web 会话)。
|
|
9
18
|
*/
|
|
10
19
|
declare function getTree(handle: DB): Promise<SessionTreeNode[]>;
|
|
11
|
-
export { forkSession, getBranches, getTree };
|
|
20
|
+
export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
|
package/dist/session/branch.js
CHANGED
|
@@ -1,54 +1,130 @@
|
|
|
1
|
-
import { eq } from 'drizzle-orm';
|
|
1
|
+
import { and, eq, isNull, ne, or } from 'drizzle-orm';
|
|
2
2
|
import { sessions } from '../db/schema.js';
|
|
3
3
|
import { generateId } from '../shared/index.js';
|
|
4
|
-
import {
|
|
4
|
+
import { getEntries, insertEntry } from './message.js';
|
|
5
5
|
import { createSession, getSession, rowToSession } from './session.js';
|
|
6
|
-
|
|
6
|
+
import { copyFileSnapshots } from './snapshot.js';
|
|
7
|
+
/**
|
|
8
|
+
* fork 分支点 messageIndex 越界(评审 NIT:客户端索引过期/分页 bug)。
|
|
9
|
+
* 与「会话不存在」区分开:消费路由应映射 400 并透出 message,而非 404。
|
|
10
|
+
*/
|
|
11
|
+
class BranchPointOutOfRangeError extends Error {
|
|
12
|
+
constructor(messageIndex) {
|
|
13
|
+
super(`Branch point message index ${messageIndex} out of range`);
|
|
14
|
+
this.name = 'BranchPointOutOfRangeError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** 把 SessionEntry 还原为 sessionEntries 原始行(fork 复制用)。 */
|
|
18
|
+
function entryToRow(e, sessionId) {
|
|
19
|
+
const id = generateId();
|
|
20
|
+
// Message 无 _tag(共享类型);其余条目经 _tag 判别。
|
|
21
|
+
if (!('_tag' in e)) {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
sessionId,
|
|
25
|
+
tag: 'message',
|
|
26
|
+
role: e.role,
|
|
27
|
+
content: e.content,
|
|
28
|
+
tokenCount: e.tokenCount,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
switch (e._tag) {
|
|
32
|
+
case 'compaction':
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
sessionId,
|
|
36
|
+
tag: 'compaction',
|
|
37
|
+
content: {
|
|
38
|
+
summary: e.summary,
|
|
39
|
+
originalEntryIds: e.originalEntryIds,
|
|
40
|
+
archiveId: e.archiveId,
|
|
41
|
+
},
|
|
42
|
+
tokenCount: e.tokenCount,
|
|
43
|
+
};
|
|
44
|
+
case 'squash':
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
sessionId,
|
|
48
|
+
tag: 'squash',
|
|
49
|
+
content: {
|
|
50
|
+
summary: e.summary,
|
|
51
|
+
squashedEntryIds: e.squashedEntryIds,
|
|
52
|
+
archiveId: e.archiveId,
|
|
53
|
+
},
|
|
54
|
+
tokenCount: e.tokenCount,
|
|
55
|
+
};
|
|
56
|
+
case 'branch_summary':
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
sessionId,
|
|
60
|
+
tag: 'branch_summary',
|
|
61
|
+
content: { summary: e.summary, sourceSessionId: e.sourceSessionId },
|
|
62
|
+
};
|
|
63
|
+
case 'steering':
|
|
64
|
+
return { id, sessionId, tag: 'steering', content: { text: e.content } };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Fork a session at a message index — copies all entries (messages + tool pairs)
|
|
68
|
+
* up to and including the branch point message, plus latest file snapshots. */
|
|
7
69
|
async function forkSession(handle, sessionId, messageIndex) {
|
|
8
70
|
const source = await getSession(handle, sessionId);
|
|
9
71
|
if (!source)
|
|
10
72
|
throw new Error(`Session not found: ${sessionId}`);
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
73
|
+
const entries = await getEntries(handle, sessionId);
|
|
74
|
+
const msgEntries = entries.filter((e) => !('_tag' in e));
|
|
75
|
+
const target = msgEntries[messageIndex];
|
|
76
|
+
if (!target) {
|
|
77
|
+
throw new BranchPointOutOfRangeError(messageIndex);
|
|
78
|
+
}
|
|
79
|
+
const targetIdx = entries.findIndex((e) => e.id === target.id);
|
|
80
|
+
const toCopy = entries.slice(0, targetIdx + 1);
|
|
81
|
+
// 多表写入(sessions insert/update + sessionEntries 复制 + 快照复制)包单个事务:
|
|
82
|
+
// 中途失败整体回滚,不残留永久可见的半成品分支(同 compactSession/squashRecent 写法)。
|
|
83
|
+
const updated = await handle.db.transaction(async (tx) => {
|
|
84
|
+
const txHandle = { db: tx, close: handle.close };
|
|
85
|
+
const forked = await createSession(txHandle, `Branch of ${source.title}`, source.projectId ?? undefined, undefined, source.source === 'cli' ? 'cli' : 'web');
|
|
86
|
+
await tx
|
|
87
|
+
.update(sessions)
|
|
88
|
+
.set({ parentId: sessionId, branchPoint: messageIndex })
|
|
89
|
+
.where(eq(sessions.id, forked.id));
|
|
90
|
+
for (const e of toCopy) {
|
|
91
|
+
await insertEntry(txHandle, entryToRow(e, forked.id));
|
|
92
|
+
}
|
|
93
|
+
await insertEntry(txHandle, {
|
|
20
94
|
id: generateId(),
|
|
21
95
|
sessionId: forked.id,
|
|
22
|
-
tag: '
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
96
|
+
tag: 'branch_summary',
|
|
97
|
+
content: {
|
|
98
|
+
summary: `Branched from session ${sessionId} at message ${messageIndex}`,
|
|
99
|
+
sourceSessionId: sessionId,
|
|
100
|
+
},
|
|
26
101
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
summary: `Branched from session ${sessionId} at message ${messageIndex}`,
|
|
34
|
-
sourceSessionId: sessionId,
|
|
35
|
-
},
|
|
102
|
+
// 复制源会话最新文件快照(P1-4:@文件上下文随分支保留)。
|
|
103
|
+
await copyFileSnapshots(txHandle, sessionId, forked.id);
|
|
104
|
+
const created = await getSession(txHandle, forked.id);
|
|
105
|
+
if (!created)
|
|
106
|
+
throw new Error('Forked session not found after creation');
|
|
107
|
+
return created;
|
|
36
108
|
});
|
|
37
|
-
const updated = await getSession(handle, forked.id);
|
|
38
|
-
if (!updated)
|
|
39
|
-
throw new Error('Forked session not found after creation');
|
|
40
109
|
return updated;
|
|
41
110
|
}
|
|
42
111
|
/** Get direct child sessions (branches) of a session. */
|
|
43
112
|
async function getBranches(handle, sessionId) {
|
|
44
|
-
const rows = await handle.db
|
|
113
|
+
const rows = await handle.db
|
|
114
|
+
.select()
|
|
115
|
+
.from(sessions)
|
|
116
|
+
.where(and(eq(sessions.parentId, sessionId), isNull(sessions.deletedAt)));
|
|
45
117
|
return rows.map(rowToSession);
|
|
46
118
|
}
|
|
47
119
|
/** Build a full session tree from root sessions down.
|
|
48
120
|
* 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
|
|
121
|
+
* 排除软删除会话与 CLI 来源会话(Web 树仅展示 web 会话)。
|
|
49
122
|
*/
|
|
50
123
|
async function getTree(handle) {
|
|
51
|
-
const rows = await handle.db
|
|
124
|
+
const rows = await handle.db
|
|
125
|
+
.select()
|
|
126
|
+
.from(sessions)
|
|
127
|
+
.where(and(isNull(sessions.deletedAt), or(isNull(sessions.source), ne(sessions.source, 'cli'))));
|
|
52
128
|
const byParent = new Map();
|
|
53
129
|
for (const row of rows) {
|
|
54
130
|
const session = rowToSession(row);
|
|
@@ -67,4 +143,4 @@ async function getTree(handle) {
|
|
|
67
143
|
}));
|
|
68
144
|
return build(null);
|
|
69
145
|
}
|
|
70
|
-
export { forkSession, getBranches, getTree };
|
|
146
|
+
export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
|
|
@@ -228,6 +228,7 @@ async function compactSession(handle, sessionId, summarizer, config) {
|
|
|
228
228
|
if (config?.preserveSnapshots !== false) {
|
|
229
229
|
const hotFiles = extractHotFiles(compactMessages);
|
|
230
230
|
for (const file of hotFiles) {
|
|
231
|
+
// 不传 mtimeMs:由 upsertFileSnapshot 从已有行透传,压缩后热文件不误判过期。
|
|
231
232
|
const id = await upsertFileSnapshot(txHandle, sessionId, file.path, file.content);
|
|
232
233
|
fileSnapshotIds.push(id);
|
|
233
234
|
}
|
|
@@ -8,8 +8,9 @@ declare function messageToChatMessage(msg: Message): ChatMessage;
|
|
|
8
8
|
declare function entriesToChatMessages(entries: SessionEntry[], snapshots: FileSnapshot[]): ChatMessage[];
|
|
9
9
|
/** Inject file snapshot block after the first message (preserves cache prefix). */
|
|
10
10
|
declare function injectSnapshots(messages: ChatMessage[], snapshots: FileSnapshot[]): ChatMessage[];
|
|
11
|
-
/** Get the full session context: all entries + file snapshots.
|
|
12
|
-
|
|
11
|
+
/** Get the full session context: all entries + file snapshots.
|
|
12
|
+
* cwd 提供时刷新过期快照:文件磁盘 mtime 晚于快照记录 → 重读并升版本(P1-5)。 */
|
|
13
|
+
declare function getSessionContext(handle: DB, sessionId: string, cwd?: string): Promise<{
|
|
13
14
|
entries: SessionEntry[];
|
|
14
15
|
snapshots: FileSnapshot[];
|
|
15
16
|
}>;
|
package/dist/session/context.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { getEntries } from './message.js';
|
|
2
|
-
import { getFileSnapshots } from './snapshot.js';
|
|
4
|
+
import { getFileSnapshots, upsertFileSnapshot } from './snapshot.js';
|
|
3
5
|
/**
|
|
4
6
|
* Drop tool messages whose id cannot be paired with an assistant tool_call,
|
|
5
7
|
* and inject synthetic tool results for orphan tool_calls (assistant 发出了
|
|
@@ -162,12 +164,43 @@ function injectSnapshots(messages, snapshots) {
|
|
|
162
164
|
return [snapshotMessage];
|
|
163
165
|
return [first, snapshotMessage, ...messages.slice(1)];
|
|
164
166
|
}
|
|
165
|
-
/** Get the full session context: all entries + file snapshots.
|
|
166
|
-
|
|
167
|
+
/** Get the full session context: all entries + file snapshots.
|
|
168
|
+
* cwd 提供时刷新过期快照:文件磁盘 mtime 晚于快照记录 → 重读并升版本(P1-5)。 */
|
|
169
|
+
async function getSessionContext(handle, sessionId, cwd) {
|
|
170
|
+
if (cwd)
|
|
171
|
+
await refreshStaleSnapshots(handle, sessionId, cwd);
|
|
167
172
|
const [entries, snapshots] = await Promise.all([
|
|
168
173
|
getEntries(handle, sessionId),
|
|
169
174
|
getFileSnapshots(handle, sessionId),
|
|
170
175
|
]);
|
|
171
176
|
return { entries, snapshots };
|
|
172
177
|
}
|
|
178
|
+
/** 比对每个文件最新快照与磁盘 mtime,过期则重读并写入新版本。 */
|
|
179
|
+
async function refreshStaleSnapshots(handle, sessionId, cwd) {
|
|
180
|
+
const snaps = await getFileSnapshots(handle, sessionId);
|
|
181
|
+
const latest = new Map();
|
|
182
|
+
for (const s of snaps) {
|
|
183
|
+
const prev = latest.get(s.filePath);
|
|
184
|
+
if (!prev || s.version > prev.version)
|
|
185
|
+
latest.set(s.filePath, s);
|
|
186
|
+
}
|
|
187
|
+
for (const s of latest.values()) {
|
|
188
|
+
const p = resolve(cwd, s.filePath);
|
|
189
|
+
// try 只覆盖 fs 错误(stat/readFile):文件已删除/不可读 → 保留旧快照
|
|
190
|
+
// (读工具会报错提示模型),跳过该文件继续处理其余快照。
|
|
191
|
+
const st = await stat(p).catch(() => null);
|
|
192
|
+
if (!st)
|
|
193
|
+
continue;
|
|
194
|
+
// upsertFileSnapshot 落盘时对 mtimeMs 取整(bigint 列),比对侧同样取整,
|
|
195
|
+
// 避免向下舍入导致"存储值 < 真实值"的永久误判过期。
|
|
196
|
+
if ((s.mtimeMs ?? 0) >= Math.round(st.mtimeMs))
|
|
197
|
+
continue;
|
|
198
|
+
const content = await readFile(p, 'utf-8').catch(() => null);
|
|
199
|
+
if (content === null)
|
|
200
|
+
continue;
|
|
201
|
+
// DB 写入放在 try 之外:upsertFileSnapshot 失败必须向上抛,
|
|
202
|
+
// 否则过期内容被静默注入上下文(评审 NIT:错误吞没范围过大)。
|
|
203
|
+
await upsertFileSnapshot(handle, sessionId, s.filePath, content, st.mtimeMs);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
173
206
|
export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage };
|
package/dist/session/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { forkSession, getBranches, getTree } from './branch.js';
|
|
|
3
3
|
export { buildCompactionPrompt, compactSession, extractHotFiles, findSafeCutPoint, } from './compaction.js';
|
|
4
4
|
export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage, } from './context.js';
|
|
5
5
|
export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, } from './message.js';
|
|
6
|
-
export { createSession,
|
|
6
|
+
export { createSession, getLLMSegments, getSession, listDeletedSessions, listSessions, migrateLegacyDetails, purgeDeletedSessions, restoreSession, saveLLMSegments, segmentFingerprint, softDeleteSession, touchSession, updateSessionTitle, } from './session.js';
|
|
7
7
|
export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, } from './snapshot.js';
|
|
8
8
|
export { squashRecent } from './squash.js';
|
|
9
9
|
export { estimateMessageTokens, estimateTokens } from './token.js';
|
package/dist/session/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { forkSession, getBranches, getTree } from './branch.js';
|
|
|
4
4
|
export { buildCompactionPrompt, compactSession, extractHotFiles, findSafeCutPoint, } from './compaction.js';
|
|
5
5
|
export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage, } from './context.js';
|
|
6
6
|
export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, } from './message.js';
|
|
7
|
-
export { createSession,
|
|
7
|
+
export { createSession, getLLMSegments, getSession, listDeletedSessions, listSessions, migrateLegacyDetails, purgeDeletedSessions, restoreSession, saveLLMSegments, segmentFingerprint, softDeleteSession, touchSession, updateSessionTitle, } from './session.js';
|
|
8
8
|
export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, } from './snapshot.js';
|
|
9
9
|
export { squashRecent } from './squash.js';
|
|
10
10
|
export { estimateMessageTokens, estimateTokens } from './token.js';
|