c0de-agent 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +21 -7
  2. package/dist/cli/commands/acp.js +2 -0
  3. package/dist/cli/commands/chat.js +2 -0
  4. package/dist/cli/commands/config.js +8 -4
  5. package/dist/cli/commands/serve.d.ts +1 -0
  6. package/dist/cli/commands/serve.js +4 -1
  7. package/dist/cli/deps.d.ts +7 -5
  8. package/dist/cli/deps.js +23 -7
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.js +68 -37
  11. package/dist/cli/modes/print.d.ts +3 -1
  12. package/dist/cli/modes/print.js +21 -4
  13. package/dist/core/agent.js +3 -4
  14. package/dist/core/config.d.ts +18 -1
  15. package/dist/core/config.js +55 -7
  16. package/dist/core/loop.js +1 -1
  17. package/dist/core/workflows/discovery.d.ts +1 -1
  18. package/dist/core/workflows/index.d.ts +1 -1
  19. package/dist/db/schema.d.ts +17 -0
  20. package/dist/db/schema.js +3 -1
  21. package/dist/server/agent-manager.d.ts +15 -0
  22. package/dist/server/agent-manager.js +35 -11
  23. package/dist/server/app.js +2 -1
  24. package/dist/server/context.js +2 -0
  25. package/dist/server/middleware/cors.d.ts +11 -2
  26. package/dist/server/middleware/cors.js +15 -12
  27. package/dist/server/routes/chat.js +211 -150
  28. package/dist/server/routes/config.js +7 -3
  29. package/dist/server/routes/session.js +6 -2
  30. package/dist/server/routes/terminal.js +12 -0
  31. package/dist/server/routes/update.d.ts +1 -0
  32. package/dist/server/routes/update.js +11 -4
  33. package/dist/server/routes/workflows.js +2 -2
  34. package/dist/server/server.d.ts +24 -1
  35. package/dist/server/server.js +107 -26
  36. package/dist/server/types.d.ts +9 -1
  37. package/dist/session/branch.d.ts +10 -2
  38. package/dist/session/branch.js +98 -29
  39. package/dist/session/compaction.js +1 -0
  40. package/dist/session/context.d.ts +3 -2
  41. package/dist/session/context.js +36 -3
  42. package/dist/session/snapshot.d.ts +9 -3
  43. package/dist/session/snapshot.js +45 -4
  44. package/dist/session/squash.js +1 -0
  45. package/dist/session/types.d.ts +2 -0
  46. package/dist/shared/types/config.d.ts +7 -6
  47. package/dist/shared/utils/path.d.ts +2 -3
  48. package/dist/shared/utils/path.js +20 -4
  49. package/dist/tools/builtin/todo.js +5 -4
  50. package/dist/tools/index.d.ts +14 -3
  51. package/dist/tools/index.js +36 -14
  52. package/dist/update/index.d.ts +1 -1
  53. package/dist/update/index.js +1 -1
  54. package/dist/update/ipc.d.ts +32 -5
  55. package/dist/update/ipc.js +53 -7
  56. package/dist/update/updater.d.ts +7 -3
  57. package/dist/update/updater.js +14 -6
  58. package/dist/update/version.d.ts +6 -1
  59. package/dist/update/version.js +18 -3
  60. package/drizzle/0005_snapshot_mtime.sql +1 -0
  61. package/drizzle/meta/0005_snapshot.json +855 -0
  62. package/drizzle/meta/_journal.json +7 -0
  63. package/package.json +1 -7
  64. package/dist/mcp/index.d.ts +0 -1
  65. package/dist/mcp/index.js +0 -3
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { mergeConfig, saveConfig } from '../../core/config.js';
2
+ import { loadConfigScopes, mergeConfig, mergeRaw, saveConfigScoped } from '../../core/config.js';
3
3
  import { encryptSecret, isEncryptedSecret } from '../../core/secret.js';
4
4
  import { syncRegistryFromConfig } from '../server.js';
5
5
  function createConfigRoute(ctx) {
@@ -14,10 +14,14 @@ function createConfigRoute(ctx) {
14
14
  if (Array.isArray(patch.providers)) {
15
15
  patch.providers = patch.providers.map((p) => p.apiKey && !isEncryptedSecret(p.apiKey) ? { ...p, apiKey: encryptSecret(p.apiKey) } : p);
16
16
  }
17
- ctx.config = mergeConfig(ctx.config, patch);
17
+ // 按作用域最小落盘:patch 只合并进项目作用域原始文件,
18
+ // 不把合并结果(含默认值/global 配置)整体序列化进项目文件。
19
+ const scopes = loadConfigScopes(ctx.cwd);
20
+ const nextProject = mergeRaw(scopes.project ?? {}, patch);
21
+ ctx.config = mergeConfig(scopes.global, nextProject);
18
22
  // providers 变更后原地同步 registry,使运行中的连接立即生效
19
23
  syncRegistryFromConfig(ctx.llmRegistry, ctx.config);
20
- await saveConfig(ctx.config, 'project', ctx.cwd).catch(() => {
24
+ await saveConfigScoped('project', ctx.cwd, nextProject).catch(() => {
21
25
  // 保存失败不影响内存配置
22
26
  });
23
27
  return c.json(ctx.config);
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
2
2
  import { createSummarizer, runCompaction } from '../../core/compact.js';
3
3
  import { fromDirectory } from '../../project/index.js';
4
4
  import { archiveOriginalEntries } from '../../session/archive.js';
5
- import { forkSession, getBranches, getTree } from '../../session/branch.js';
5
+ import { BranchPointOutOfRangeError, forkSession, getBranches, getTree, } from '../../session/branch.js';
6
6
  import { deleteEntriesByIds, getMessages, insertEntry } from '../../session/message.js';
7
7
  import { createSession, deleteSession, getLLMSegments, getSession, listSessions, listSessionsByProject, touchLastOpened, } from '../../session/session.js';
8
8
  import { applyShakeRegions, collectShakeRegions, DEFAULT_SHAKE_CONFIG, toRegionViews, } from '../../session/shake.js';
@@ -63,7 +63,11 @@ function createSessionRoute(ctx) {
63
63
  const forked = await forkSession(ctx.db, id, messageIndex);
64
64
  return c.json(forked, 201);
65
65
  }
66
- catch {
66
+ catch (error) {
67
+ // 分支点越界是客户端索引/分页 bug → 400 并透出明确语义;归 404 会误导排查
68
+ if (error instanceof BranchPointOutOfRangeError) {
69
+ return apiError(c, 400, 'BRANCH_POINT_OUT_OF_RANGE', error.message);
70
+ }
67
71
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
68
72
  }
69
73
  });
@@ -1,6 +1,9 @@
1
1
  // src/server/routes/terminal.ts
2
+ import { isAbsolute } from 'node:path';
2
3
  import { Hono } from 'hono';
3
4
  import { apiError } from '../middleware/error.js';
5
+ /** 允许的 shell 白名单(P2-6:拒绝路径分隔符与任意二进制)。 */
6
+ const ALLOWED_SHELLS = new Set(['bash', 'zsh', 'fish', 'sh']);
4
7
  /** 创建终端路由(REST 部分;WebSocket 升级在 server.ts 中处理)。 */
5
8
  function createTerminalRoute(ctx) {
6
9
  const app = new Hono();
@@ -18,6 +21,15 @@ function createTerminalRoute(ctx) {
18
21
  const title = typeof body.title === 'string' ? body.title : undefined;
19
22
  const shell = typeof body.shell === 'string' && body.shell.length > 0 ? body.shell : undefined;
20
23
  const projectId = typeof body.projectId === 'string' && body.projectId.length > 0 ? body.projectId : undefined;
24
+ // 入参校验(P2-6):shell 白名单;cwd 必须为绝对路径。
25
+ if (shell !== undefined) {
26
+ if (shell.includes('/') || shell.includes('\\') || !ALLOWED_SHELLS.has(shell)) {
27
+ return apiError(c, 400, 'INVALID_SHELL', `shell 必须是 ${[...ALLOWED_SHELLS].join('/')} 之一`);
28
+ }
29
+ }
30
+ if (typeof cwd === 'string' && cwd.length > 0 && !isAbsolute(cwd)) {
31
+ return apiError(c, 400, 'INVALID_CWD', 'cwd 必须是绝对路径');
32
+ }
21
33
  try {
22
34
  const info = mgr.create({ cwd, cols, rows, title, shell, projectId });
23
35
  return c.json(info, 201);
@@ -9,6 +9,7 @@ import type { ServerContext } from '../types.js';
9
9
  * 序列化当前会话 → performHotUpdate(npm install -g + spawn 新实例 + handoffPort)。
10
10
  * 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
11
11
  * 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
12
+ * dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
12
13
  */
13
14
  declare function createUpdateRoute(ctx: ServerContext): Hono;
14
15
  export { createUpdateRoute };
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { performHotUpdate, serializeSessions } from '../../update/index.js';
2
+ import { getCurrentVersion, performHotUpdate, serializeSessions } from '../../update/index.js';
3
3
  import { apiError } from '../middleware/error.js';
4
4
  /**
5
5
  * GET /api/update — 返回后台调度器缓存的版本检查结果(spec §18.1)。
@@ -10,6 +10,7 @@ import { apiError } from '../middleware/error.js';
10
10
  * 序列化当前会话 → performHotUpdate(npm install -g + spawn 新实例 + handoffPort)。
11
11
  * 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
12
12
  * 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
13
+ * dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
13
14
  */
14
15
  function createUpdateRoute(ctx) {
15
16
  const app = new Hono();
@@ -20,20 +21,26 @@ function createUpdateRoute(ctx) {
20
21
  // 无缓存(首次启动延迟未到):同步触发一次,避免前端首屏空。
21
22
  // 不 await——保持 GET 语义非阻塞;前端下次轮询拿到结果。
22
23
  void ctx.updateScheduler.checkNow();
24
+ const v = getCurrentVersion();
23
25
  return c.json({
24
26
  hasUpdate: false,
25
- currentVersion: '0.0.0',
26
- latestVersion: '0.0.0',
27
+ currentVersion: v,
28
+ latestVersion: v,
27
29
  });
28
30
  });
29
31
  app.post('/apply', async (c) => {
32
+ if (!ctx.handoff) {
33
+ return apiError(c, 409, 'HOT_UPDATE_UNAVAILABLE', '热更新仅在独立 serve 进程可用(dev 模式请手动更新)');
34
+ }
30
35
  const result = await ctx.updateScheduler.checkNow();
31
36
  if (!result.hasUpdate) {
32
37
  return apiError(c, 409, 'NO_UPDATE', '已是最新版本,无需热更新');
33
38
  }
34
39
  const snapshot = await serializeSessions(ctx.db, ctx.config);
35
40
  const r = await performHotUpdate(snapshot, {
36
- handoffPort: ctx.handoff?.port,
41
+ handoffPort: ctx.handoff.port,
42
+ port: ctx.port,
43
+ authToken: ctx.authToken,
37
44
  });
38
45
  if (r._tag === 'success') {
39
46
  return c.json({
@@ -2,9 +2,9 @@ import { unlink } from 'node:fs/promises';
2
2
  import { Hono } from 'hono';
3
3
  import { streamSSE } from 'hono/streaming';
4
4
  import { createAgent } from '../../core/agent.js';
5
- import { executeWorkflow } from '../../core/workflows/runtime.js';
6
- import { reloadRegistry } from '../../core/workflows/registry.js';
7
5
  import { discoverWorkflows, saveWorkflow } from '../../core/workflows/discovery.js';
6
+ import { reloadRegistry } from '../../core/workflows/registry.js';
7
+ import { executeWorkflow } from '../../core/workflows/runtime.js';
8
8
  import { getProject } from '../../project/project.js';
9
9
  import { createSession } from '../../session/session.js';
10
10
  import { autoAllowChecker } from '../../tools/permission.js';
@@ -23,6 +23,8 @@ type StartServerOptions = {
23
23
  type RunningServer = {
24
24
  app: Hono;
25
25
  port: number;
26
+ /** 认证 token(authEnabled=false 时为 undefined);serve 命令据此打印带 token 的 URL。 */
27
+ authToken?: string;
26
28
  close(): Promise<void>;
27
29
  };
28
30
  type BootstrappedServer = {
@@ -43,6 +45,15 @@ declare function syncRegistryFromConfig(registry: Registry, config: Config): voi
43
45
  * 历史上数据库放在 <cwd>/.c0de/pglite(项目级),但项目 DB schema 通过 projectId
44
46
  * 区分项目,设计本意是全局共享单库。旧路径已在 migrateLegacyPglite 中自动迁移。 */
45
47
  declare function resolveDbDir(): string;
48
+ /**
49
+ * 认证 token 解析(P0 安全修复):
50
+ * 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
51
+ * 2. 用户配置 security.token → 使用。
52
+ * 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
53
+ * 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
54
+ * 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
55
+ */
56
+ declare function resolveAuthToken(config: Config, dataDir: string): string | undefined;
46
57
  /**
47
58
  * 围绕已有 DB handle 组装 ServerContext(不建/不关闭 DB)。
48
59
  *
@@ -54,6 +65,18 @@ declare function buildServerContext(db: DB, opts?: StartServerOptions): Promise<
54
65
  ctx: ServerContext;
55
66
  dispose: () => Promise<void>;
56
67
  }>;
68
+ /**
69
+ * Cross-process guard for PGLite dataDir.
70
+ *
71
+ * PGLite is single-writer WASM Postgres — two processes on the same dataDir
72
+ * always abort (`RuntimeError: Aborted()`). This lock prevents silent
73
+ * corruption when multiple dev servers (e.g. different ports) target the
74
+ * same project.
75
+ *
76
+ * - Live PID in lock → throw clear error instead of cryptic WASM abort.
77
+ * - Dead PID in lock → stale: remove `.dev.lock` + `postmaster.pid`, proceed.
78
+ */
79
+ declare function acquireDevDbLock(dataDir: string): void;
57
80
  /** Release the dev DB lock (best-effort, stale-detection covers missed calls). */
58
81
  declare function releaseDevDbLock(dataDir: string): void;
59
82
  /** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
@@ -62,4 +85,4 @@ declare function createDevDb(cwd: string): Promise<DB>;
62
85
  declare function bootstrapServerContext(opts?: StartServerOptions): Promise<BootstrappedServer>;
63
86
  declare function startServer(opts?: StartServerOptions): Promise<RunningServer>;
64
87
  export type { BootstrappedServer, RunningServer, StartServerOptions };
65
- export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
88
+ export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
@@ -1,5 +1,6 @@
1
1
  // src/server/server.ts
2
2
  import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { randomBytes } from 'node:crypto';
3
4
  import { connect as tcpConnect } from 'node:net';
4
5
  import { homedir } from 'node:os';
5
6
  import { dirname, join } from 'node:path';
@@ -16,6 +17,7 @@ import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.
16
17
  import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
17
18
  import { createAgentManager } from './agent-manager.js';
18
19
  import { createApp } from './app.js';
20
+ import { isAllowedOrigin } from './middleware/cors.js';
19
21
  import { createPermissionStore } from './permission/store.js';
20
22
  import { PTYManager } from './terminal/pty-manager.js';
21
23
  /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
@@ -75,6 +77,59 @@ function resolveDbDir() {
75
77
  return envDir;
76
78
  return join(resolveGlobalDataRoot(), 'pglite');
77
79
  }
80
+ /**
81
+ * 认证 token 解析(P0 安全修复):
82
+ * 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
83
+ * 2. 用户配置 security.token → 使用。
84
+ * 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
85
+ * 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
86
+ * 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
87
+ */
88
+ function resolveAuthToken(config, dataDir) {
89
+ if (config.security.authEnabled === false)
90
+ return undefined;
91
+ if (config.security.token && config.security.token.length > 0)
92
+ return config.security.token;
93
+ const tokenFile = join(dataDir, 'auth-token');
94
+ const envToken = process.env.C0DE_AUTH_TOKEN;
95
+ if (envToken) {
96
+ try {
97
+ mkdirSync(dataDir, { recursive: true });
98
+ writeFileSync(tokenFile, envToken, { mode: 0o600 });
99
+ }
100
+ catch {
101
+ // 落盘失败不影响本次运行
102
+ }
103
+ return envToken;
104
+ }
105
+ try {
106
+ const existing = readFileSync(tokenFile, 'utf-8').trim();
107
+ if (existing)
108
+ return existing;
109
+ }
110
+ catch {
111
+ // 文件不存在,走生成分支
112
+ }
113
+ const generated = randomBytes(24).toString('hex');
114
+ try {
115
+ mkdirSync(dataDir, { recursive: true });
116
+ writeFileSync(tokenFile, generated, { mode: 0o600 });
117
+ }
118
+ catch {
119
+ // 落盘失败:本次运行仍可用内存 token
120
+ }
121
+ return generated;
122
+ }
123
+ /** 只读 dataDir 下已存在的 auth-token(新实例 handoff 握手用;不生成)。 */
124
+ function readAuthTokenFile(dataDir) {
125
+ try {
126
+ const t = readFileSync(join(dataDir, 'auth-token'), 'utf-8').trim();
127
+ return t || undefined;
128
+ }
129
+ catch {
130
+ return undefined;
131
+ }
132
+ }
78
133
  /** 一次性迁移:<cwd>/.c0de/pglite → 全局路径。仅当目标不存在时执行。
79
134
  *
80
135
  * 多个项目各有 .c0de/pglite 时只迁移第一个遇到的(首次启动即触发);
@@ -141,6 +196,7 @@ async function buildServerContext(db, opts = {}) {
141
196
  agentManager: createAgentManager(),
142
197
  permissionStore: createPermissionStore(),
143
198
  permissionMode: config.permission.defaultMode,
199
+ authToken: resolveAuthToken(config, resolveDbDir()),
144
200
  // Agent 注册表:内置 4 个默认 agent;项目/用户自定义 agent 可在启动后补充加载。
145
201
  agentRegistry: (() => {
146
202
  const reg = createAgentRegistry();
@@ -158,30 +214,17 @@ async function buildServerContext(db, opts = {}) {
158
214
  cwd,
159
215
  ptyManager: new PTYManager(),
160
216
  };
161
- // spec §18.3 handoff server:旧实例监听随机端口,收到 POST /handoff 时
162
- // 序列化当前会话状态 + 优雅关闭,让新实例接管。config.update.enabled=false
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
- }
217
+ // handoff server 不在此创建:它需要持有主 HTTP server 引用以触发完整关停,
218
+ // 由 startServer 在 serve() 之后创建(dev 模式走 vite,不创建)。
174
219
  return {
175
220
  ctx,
176
221
  dispose: async () => {
177
222
  // dev 重建前调用:中止活跃 run + settle pending permission +
178
- // 停 scheduler + 关 handoff。**不 close db**(调用方持有)。
223
+ // 停 scheduler。**不 close db**(调用方持有)。
179
224
  ctx.agentManager.dispose();
180
225
  ctx.permissionStore.dispose();
181
226
  ctx.updateScheduler.stop();
182
227
  ctx.ptyManager.dispose();
183
- if (handoffServer)
184
- await handoffServer.close();
185
228
  },
186
229
  };
187
230
  }
@@ -301,13 +344,24 @@ async function bootstrapServerContext(opts = {}) {
301
344
  * 请求旧实例 handoff 后轮询端口释放(spec §18.3)。旧实例收到 /handoff 后
302
345
  * 优雅退出释放端口;新实例轮询 TCP 连接直到被拒(端口已释放)或超时报错。
303
346
  */
304
- async function requestHandoffWithRetry(port, maxAttempts = 30, delayMs = 100) {
347
+ async function requestHandoffWithRetry(port, token, maxAttempts = 30, delayMs = 100) {
305
348
  try {
306
- await requestHandoff(port);
349
+ await requestHandoff(port, '127.0.0.1', token);
307
350
  }
308
- catch {
309
- // 旧实例未启 handoff 端点(update 未启用);直接尝试绑端口。
310
- return;
351
+ catch (err) {
352
+ const handoffErr = err;
353
+ // 连接层失败(ECONNREFUSED 等):旧实例未启 handoff 端点(update 未启用);
354
+ // 直接尝试绑端口。
355
+ if (handoffErr?.kind === 'connect')
356
+ return;
357
+ // HTTP 401:旧实例存在但 auth token 不匹配——立即明确报错退出。若继续
358
+ // bind 必撞 EADDRINUSE,把真实原因(token 校验失败)误报成端口占用。
359
+ if (handoffErr?.kind === 'http' && handoffErr.status === 401) {
360
+ throw new Error(`handoff: 端口 ${port} 上的旧实例拒绝让渡(HTTP 401:auth token 不匹配)。\n` +
361
+ `新旧实例须共享同一 token(C0DE_AUTH_TOKEN 环境变量或数据目录 auth-token 文件),\n` +
362
+ `请核对配置后重试。`);
363
+ }
364
+ throw err;
311
365
  }
312
366
  for (let i = 0; i < maxAttempts; i++) {
313
367
  const released = await new Promise((resolve) => {
@@ -327,8 +381,10 @@ async function requestHandoffWithRetry(port, maxAttempts = 30, delayMs = 100) {
327
381
  async function startServer(opts = {}) {
328
382
  const port = opts.port ?? 3000;
329
383
  // spec §18.3:新实例从 --handoff-port 拿到旧实例端口,请求优雅退出后绑端口。
384
+ // 握手 token:updater spawn 时经 C0DE_AUTH_TOKEN 环境变量传入,否则读 auth-token 文件。
330
385
  if (opts.handoffPort && !opts.skipHandoff) {
331
- await requestHandoffWithRetry(opts.handoffPort);
386
+ const pendingToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
387
+ await requestHandoffWithRetry(opts.handoffPort, pendingToken);
332
388
  }
333
389
  const { ctx, close: closeCtx } = await bootstrapServerContext(opts);
334
390
  const app = createApp(ctx);
@@ -336,10 +392,11 @@ async function startServer(opts = {}) {
336
392
  if (ctx.config.update.enabled)
337
393
  ctx.updateScheduler.start();
338
394
  const server = serve({ fetch: app.fetch, port });
395
+ ctx.port = port;
339
396
  // WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
340
397
  // 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
341
398
  const wss = new WebSocketServer({ noServer: true });
342
- const expectedToken = ctx.config.security.authEnabled ? ctx.config.security.token : undefined;
399
+ const expectedToken = ctx.authToken;
343
400
  server.on('upgrade', (req, socket, head) => {
344
401
  const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
345
402
  const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
@@ -347,6 +404,14 @@ async function startServer(opts = {}) {
347
404
  socket.destroy();
348
405
  return;
349
406
  }
407
+ // Origin 校验:浏览器 WS 不受 CORS 约束,必须在服务端显式校验。
408
+ // 非浏览器客户端(无 Origin 头)放行——token 仍是硬门槛。
409
+ const origin = req.headers.origin;
410
+ if (origin && !isAllowedOrigin(origin, ctx.config.security.allowedOrigins)) {
411
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
412
+ socket.destroy();
413
+ return;
414
+ }
350
415
  // 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)
351
416
  if (expectedToken) {
352
417
  const token = url.searchParams.get('token');
@@ -370,7 +435,7 @@ async function startServer(opts = {}) {
370
435
  });
371
436
  });
372
437
  let closed = false;
373
- const close = async () => {
438
+ const closeMain = async () => {
374
439
  if (closed)
375
440
  return;
376
441
  closed = true;
@@ -378,6 +443,22 @@ async function startServer(opts = {}) {
378
443
  server.close();
379
444
  await closeCtx();
380
445
  };
381
- return { app, port, close };
446
+ const close = async () => {
447
+ await closeMain();
448
+ await handoffServer?.close();
449
+ };
450
+ // spec §18.3 handoff server:旧实例收到 POST /handoff 后关闭主服务与资源,
451
+ // 响应 200,随后退出进程(exitAfterResponse),端口让渡给新实例。
452
+ // config.update.enabled=false 或 skipHandoff 时跳过。
453
+ let handoffServer;
454
+ if (ctx.config.update.enabled && !opts.skipHandoff) {
455
+ const createHandoff = opts.createHandoffFn ?? createHandoffServer;
456
+ handoffServer = await createHandoff(closeMain, {
457
+ expectedToken: ctx.authToken,
458
+ exitAfterResponse: true,
459
+ });
460
+ ctx.handoff = { port: handoffServer.port, server: handoffServer };
461
+ }
462
+ return { app, port, close, ...(ctx.authToken ? { authToken: ctx.authToken } : {}) };
382
463
  }
383
- export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
464
+ export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
@@ -8,9 +8,9 @@ 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';
13
12
  import type { PermissionStore } from './permission/store.js';
13
+ import type { PTYManager } from './terminal/pty-manager.js';
14
14
  /** 持有所有服务依赖的不可变上下文(config 字段可变用于 PATCH 更新)。 */
15
15
  type ServerContext = {
16
16
  db: DB;
@@ -28,6 +28,14 @@ type ServerContext = {
28
28
  permissionStore: PermissionStore;
29
29
  /** 全局授权模式(运行时可临时切换):'default' 逐个确认;'auto' 自动放行 ask 工具(YOLO)。启动时取 config.permission.defaultMode,PUT /api/permissions 临时切换不回写 config。 */
30
30
  permissionMode: 'default' | 'auto';
31
+ /**
32
+ * API Bearer token(认证未显式关闭时必存在)。
33
+ * 用户配置 security.token 优先;否则自动生成并持久化到数据目录 auth-token 文件
34
+ * (跨重启/热更新稳定,浏览器首访经 ?token= URL 获取后本地保存)。
35
+ */
36
+ authToken?: string;
37
+ /** 主 HTTP 服务实际绑定端口(startServer 绑定后回填;热更新 spawn 新实例复用)。 */
38
+ port?: number;
31
39
  /** Agent 类型注册表(spec: multi-agent-design)。注入 agent loop 的 runSubAgent。 */
32
40
  agentRegistry: AgentRegistry;
33
41
  /** 工作流注册表(spec: dynamic-workflow-design)。注入 /workflow slash 命令和 workflowz steering。 */
@@ -1,6 +1,14 @@
1
1
  import type { DB } from '../db/client.js';
2
2
  import type { Session, SessionTreeNode } from './types.js';
3
- /** Fork a session at a message index — copies messages 0..index into a new child session. */
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[]>;
@@ -8,4 +16,4 @@ declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
8
16
  * 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
9
17
  */
10
18
  declare function getTree(handle: DB): Promise<SessionTreeNode[]>;
11
- export { forkSession, getBranches, getTree };
19
+ export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
@@ -1,42 +1,111 @@
1
1
  import { eq } from 'drizzle-orm';
2
- import { sessions } from '../db/schema.js';
2
+ import { sessionEntries, sessions } from '../db/schema.js';
3
3
  import { generateId } from '../shared/index.js';
4
- import { getMessages, insertEntry } from './message.js';
4
+ import { getEntries, insertEntry } from './message.js';
5
5
  import { createSession, getSession, rowToSession } from './session.js';
6
- /** Fork a session at a message index — copies messages 0..index into a new child session. */
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 messages = await getMessages(handle, sessionId);
12
- const toCopy = messages.slice(0, messageIndex + 1);
13
- const forked = await createSession(handle, `Branch of ${source.title}`, source.projectId ?? undefined);
14
- await handle.db
15
- .update(sessions)
16
- .set({ parentId: sessionId, branchPoint: messageIndex })
17
- .where(eq(sessions.id, forked.id));
18
- for (const msg of toCopy) {
19
- await insertEntry(handle, {
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);
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: 'message',
23
- role: msg.role,
24
- content: msg.content,
25
- tokenCount: msg.tokenCount,
96
+ tag: 'branch_summary',
97
+ content: {
98
+ summary: `Branched from session ${sessionId} at message ${messageIndex}`,
99
+ sourceSessionId: sessionId,
100
+ },
26
101
  });
27
- }
28
- await insertEntry(handle, {
29
- id: generateId(),
30
- sessionId: forked.id,
31
- tag: 'branch_summary',
32
- content: {
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. */
@@ -67,4 +136,4 @@ async function getTree(handle) {
67
136
  }));
68
137
  return build(null);
69
138
  }
70
- export { forkSession, getBranches, getTree };
139
+ 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
- declare function getSessionContext(handle: DB, sessionId: string): Promise<{
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
  }>;