c0de-agent 1.10.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.
Files changed (79) hide show
  1. package/README.md +5 -2
  2. package/dist/cli/commands/acp.js +3 -3
  3. package/dist/cli/commands/auth.d.ts +17 -0
  4. package/dist/cli/commands/auth.js +73 -0
  5. package/dist/cli/commands/chat.js +8 -0
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +9 -52
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/sessions.d.ts +10 -0
  11. package/dist/cli/commands/sessions.js +35 -0
  12. package/dist/cli/commands/update.js +18 -10
  13. package/dist/cli/index.js +30 -2
  14. package/dist/cli/modes/print.js +11 -1
  15. package/dist/core/config-path.d.ts +9 -0
  16. package/dist/core/config-path.js +48 -0
  17. package/dist/core/config.d.ts +10 -2
  18. package/dist/core/config.js +32 -10
  19. package/dist/core/index.d.ts +1 -1
  20. package/dist/core/index.js +1 -1
  21. package/dist/core/loop.js +13 -3
  22. package/dist/core/slash.js +92 -30
  23. package/dist/core/title.js +15 -0
  24. package/dist/core/types.d.ts +2 -0
  25. package/dist/db/client.d.ts +4 -0
  26. package/dist/db/client.js +5 -0
  27. package/dist/db/schema.d.ts +34 -0
  28. package/dist/db/schema.js +4 -0
  29. package/dist/server/agent-manager.d.ts +5 -0
  30. package/dist/server/agent-manager.js +28 -0
  31. package/dist/server/app.js +12 -2
  32. package/dist/server/auth-manager.d.ts +79 -0
  33. package/dist/server/auth-manager.js +270 -0
  34. package/dist/server/context.js +1 -0
  35. package/dist/server/middleware/auth.d.ts +4 -3
  36. package/dist/server/middleware/auth.js +5 -5
  37. package/dist/server/permission/interactive.d.ts +2 -0
  38. package/dist/server/permission/interactive.js +5 -1
  39. package/dist/server/permission/store.d.ts +4 -0
  40. package/dist/server/permission/store.js +1 -0
  41. package/dist/server/routes/auth.d.ts +4 -0
  42. package/dist/server/routes/auth.js +94 -0
  43. package/dist/server/routes/chat.d.ts +6 -1
  44. package/dist/server/routes/chat.js +87 -7
  45. package/dist/server/routes/config.js +59 -11
  46. package/dist/server/routes/permissions.d.ts +4 -1
  47. package/dist/server/routes/permissions.js +27 -4
  48. package/dist/server/routes/project.js +56 -2
  49. package/dist/server/routes/session.js +31 -7
  50. package/dist/server/routes/terminal.js +17 -2
  51. package/dist/server/routes/update.js +23 -1
  52. package/dist/server/server.js +47 -8
  53. package/dist/server/types.d.ts +8 -1
  54. package/dist/session/archive.d.ts +1 -1
  55. package/dist/session/branch.d.ts +1 -0
  56. package/dist/session/branch.js +12 -5
  57. package/dist/session/index.d.ts +1 -1
  58. package/dist/session/index.js +1 -1
  59. package/dist/session/session.d.ts +25 -5
  60. package/dist/session/session.js +129 -10
  61. package/dist/session/types.d.ts +1 -1
  62. package/dist/shared/types/agent.d.ts +11 -0
  63. package/dist/shared/types/config.d.ts +5 -0
  64. package/dist/shared/types/message.d.ts +6 -2
  65. package/dist/tools/index.js +2 -3
  66. package/dist/update/index.d.ts +1 -1
  67. package/dist/update/index.js +1 -1
  68. package/dist/update/ipc.d.ts +6 -0
  69. package/dist/update/ipc.js +7 -4
  70. package/dist/update/snapshot.d.ts +4 -0
  71. package/dist/update/snapshot.js +8 -0
  72. package/dist/update/updater.d.ts +55 -8
  73. package/dist/update/updater.js +230 -27
  74. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  75. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  76. package/drizzle/meta/0006_snapshot.json +867 -0
  77. package/drizzle/meta/0007_snapshot.json +869 -0
  78. package/drizzle/meta/_journal.json +14 -0
  79. package/package.json +1 -1
@@ -0,0 +1,270 @@
1
+ // src/server/auth-manager.ts
2
+ // P2-16:认证 token 轮换 + 新设备配对审批。
3
+ //
4
+ // 安全模型(基于用户决策:URL token / shell history 不可视为安全):
5
+ // - bootstrap token(auth-token 文件 / security.token / C0DE_AUTH_TOKEN)只用于
6
+ // 「首次设备注册」:首个设备凭 bootstrap 换发设备 token 后,bootstrap 立即轮换失效。
7
+ // - 所有 API/WS 请求只认设备 token(sha256 哈希存储)。
8
+ // - 新设备无有效 token 时走配对流程:请求配对码 → 已授权设备审批 → 下发设备 token。
9
+ // - 显式配置 security.token 时为静态模式:不轮换、不配对(CI/脚本场景)。
10
+ // - authEnabled=false 时不启用。
11
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
12
+ import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from 'node:fs';
13
+ import { basename, dirname, join } from 'node:path';
14
+ const DEVICES_FILENAME = 'devices.json';
15
+ function hashToken(token) {
16
+ return createHash('sha256').update(token).digest('hex');
17
+ }
18
+ function safeEqual(a, b) {
19
+ const ba = Buffer.from(a);
20
+ const bb = Buffer.from(b);
21
+ if (ba.length !== bb.length)
22
+ return false;
23
+ return timingSafeEqual(ba, bb);
24
+ }
25
+ export function createAuthManager(opts) {
26
+ const { dataDir, staticToken, tokenFilePath, now = Date.now } = opts;
27
+ const pairingTtlMs = opts.pairingTtlMs ?? 10 * 60 * 1000;
28
+ const handoffRetention = opts.handoffTokenRetention ?? 3;
29
+ const tokenPath = tokenFilePath ?? join(dataDir, 'auth-token');
30
+ const devicesPath = join(dataDir, DEVICES_FILENAME);
31
+ // ── 状态加载 ──
32
+ let bootstrap = staticToken && staticToken.length > 0 ? staticToken : readBootstrapToken(tokenPath);
33
+ const devices = new Map();
34
+ loadDevices();
35
+ // 轮换历史 bootstrap(仅 handoff 校验接受,API 不接受)
36
+ const previousBootstraps = [];
37
+ const pending = new Map();
38
+ function readBootstrapToken(path) {
39
+ try {
40
+ if (!existsSync(path))
41
+ return undefined;
42
+ const t = readFileSync(path, 'utf-8').trim();
43
+ return t.length > 0 ? t : undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ function loadDevices() {
50
+ devices.clear();
51
+ try {
52
+ if (!existsSync(devicesPath))
53
+ return;
54
+ const raw = JSON.parse(readFileSync(devicesPath, 'utf-8'));
55
+ for (const d of raw.devices ?? []) {
56
+ if (d && typeof d.id === 'string' && typeof d.tokenHash === 'string') {
57
+ devices.set(d.id, d);
58
+ }
59
+ }
60
+ }
61
+ catch {
62
+ // 注册表损坏:视为空(设备需重新配对/注册)
63
+ }
64
+ }
65
+ // 热重载:CLI `c0de auth revoke/reset` 或手工编辑 devices.json 后立即生效。
66
+ // 监听目录而非文件——文件可能尚不存在(首启),persist/revoke 会创建它。
67
+ // 自身 persist 写入也会触发一次无伤重载。
68
+ let watcher;
69
+ try {
70
+ watcher = watch(dirname(devicesPath), (_event, filename) => {
71
+ if (filename === basename(devicesPath))
72
+ loadDevices();
73
+ });
74
+ // 不让 watcher 独占事件循环(测试/短生命周期进程可正常退出)
75
+ watcher.unref();
76
+ }
77
+ catch {
78
+ // 目录不可 watch(极端环境):降级为仅启动时加载
79
+ }
80
+ function persist() {
81
+ try {
82
+ if (!existsSync(dataDir))
83
+ mkdirSync(dataDir, { recursive: true });
84
+ const file = {
85
+ version: 1,
86
+ devices: Array.from(devices.values()),
87
+ };
88
+ writeFileSync(devicesPath, JSON.stringify(file, null, 2), 'utf-8');
89
+ }
90
+ catch {
91
+ // 持久化失败不致命(内存态继续有效,重启后需重新注册)
92
+ }
93
+ }
94
+ /** 轮换 bootstrap:写入新 token 文件并保留历史(handoff 用)。 */
95
+ function rotateBootstrap() {
96
+ if (staticToken && staticToken.length > 0)
97
+ return; // 静态模式不轮换
98
+ const next = randomBytes(32).toString('hex');
99
+ if (bootstrap) {
100
+ previousBootstraps.push(bootstrap);
101
+ if (previousBootstraps.length > handoffRetention)
102
+ previousBootstraps.shift();
103
+ }
104
+ bootstrap = next;
105
+ try {
106
+ if (!existsSync(dataDir))
107
+ mkdirSync(dataDir, { recursive: true });
108
+ writeFileSync(tokenPath, next, 'utf-8');
109
+ }
110
+ catch {
111
+ // 写文件失败:内存 token 仍已轮换,进程内一致
112
+ }
113
+ }
114
+ function verifyDeviceToken(token) {
115
+ const h = hashToken(token);
116
+ for (const d of devices.values()) {
117
+ if (safeEqual(d.tokenHash, h))
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ function verifyBootstrap(token) {
123
+ if (!token || !bootstrap)
124
+ return false;
125
+ return safeEqual(token, bootstrap);
126
+ }
127
+ function cleanExpiredPairings() {
128
+ const cutoff = now() - pairingTtlMs;
129
+ for (const [id, p] of pending) {
130
+ if (p.status === 'pending' && p.createdAt <= cutoff) {
131
+ pending.delete(id);
132
+ }
133
+ }
134
+ }
135
+ return {
136
+ get bootstrap() {
137
+ return bootstrap;
138
+ },
139
+ verify(token) {
140
+ if (!token)
141
+ return false;
142
+ if (staticToken && staticToken.length > 0)
143
+ return safeEqual(token, staticToken);
144
+ return verifyDeviceToken(token);
145
+ },
146
+ verifyHandoff(token) {
147
+ if (!token)
148
+ return false;
149
+ if (verifyDeviceToken(token))
150
+ return true;
151
+ if (verifyBootstrap(token))
152
+ return true;
153
+ for (const prev of previousBootstraps) {
154
+ if (safeEqual(token, prev))
155
+ return true;
156
+ }
157
+ return false;
158
+ },
159
+ async registerFirstDevice(bootstrapToken, deviceName) {
160
+ if (staticToken && staticToken.length > 0)
161
+ return null; // 静态模式无设备注册
162
+ if (!verifyBootstrap(bootstrapToken))
163
+ return null;
164
+ // 已有设备 → bootstrap 已失效,不得再凭它注册(需配对审批)
165
+ if (devices.size > 0)
166
+ return null;
167
+ const deviceToken = randomBytes(32).toString('hex');
168
+ const record = {
169
+ id: randomBytes(16).toString('hex'),
170
+ name: deviceName || '设备',
171
+ tokenHash: hashToken(deviceToken),
172
+ createdAt: now(),
173
+ };
174
+ devices.set(record.id, record);
175
+ persist();
176
+ rotateBootstrap(); // 注册成功即轮换,旧 bootstrap(URL/shell history 中)立即失效
177
+ return deviceToken;
178
+ },
179
+ requestPairing(deviceName) {
180
+ if (staticToken && staticToken.length > 0)
181
+ return null;
182
+ cleanExpiredPairings();
183
+ // 限流:最多 10 个待审批
184
+ const pendingCount = Array.from(pending.values()).filter((p) => p.status === 'pending').length;
185
+ if (pendingCount >= 10)
186
+ return null;
187
+ const pairingId = randomBytes(16).toString('hex');
188
+ const code = String(100000 + Math.floor(Math.random() * 900000)); // 6 位数字
189
+ pending.set(pairingId, {
190
+ pairingId,
191
+ deviceName: deviceName || '新设备',
192
+ code,
193
+ createdAt: now(),
194
+ status: 'pending',
195
+ });
196
+ return { pairingId, code };
197
+ },
198
+ pairingStatus(pairingId) {
199
+ const p = pending.get(pairingId);
200
+ if (!p)
201
+ return { status: 'not_found' };
202
+ if (p.status === 'approved')
203
+ return { status: 'approved', deviceToken: p.deviceToken ?? '' };
204
+ if (p.status === 'denied')
205
+ return { status: 'denied' };
206
+ if (p.createdAt <= now() - pairingTtlMs) {
207
+ pending.delete(pairingId);
208
+ return { status: 'not_found' };
209
+ }
210
+ return { status: 'pending' };
211
+ },
212
+ listPairings() {
213
+ cleanExpiredPairings();
214
+ return Array.from(pending.values())
215
+ .filter((p) => p.status === 'pending')
216
+ .map((p) => ({
217
+ pairingId: p.pairingId,
218
+ deviceName: p.deviceName,
219
+ code: p.code,
220
+ createdAt: p.createdAt,
221
+ }));
222
+ },
223
+ approvePairing(pairingId) {
224
+ const p = pending.get(pairingId);
225
+ if (!p || p.status !== 'pending')
226
+ return false;
227
+ if (p.createdAt <= now() - pairingTtlMs) {
228
+ pending.delete(pairingId);
229
+ return false;
230
+ }
231
+ const deviceToken = randomBytes(32).toString('hex');
232
+ const record = {
233
+ id: randomBytes(16).toString('hex'),
234
+ name: p.deviceName,
235
+ tokenHash: hashToken(deviceToken),
236
+ createdAt: now(),
237
+ };
238
+ devices.set(record.id, record);
239
+ p.status = 'approved';
240
+ p.deviceToken = deviceToken;
241
+ persist();
242
+ return true;
243
+ },
244
+ denyPairing(pairingId) {
245
+ const p = pending.get(pairingId);
246
+ if (!p || p.status !== 'pending')
247
+ return false;
248
+ p.status = 'denied';
249
+ return true;
250
+ },
251
+ listDevices() {
252
+ return Array.from(devices.values()).map((d) => ({
253
+ id: d.id,
254
+ name: d.name,
255
+ createdAt: d.createdAt,
256
+ }));
257
+ },
258
+ revokeDevice(id) {
259
+ const ok = devices.delete(id);
260
+ if (ok)
261
+ persist();
262
+ return ok;
263
+ },
264
+ dispose() {
265
+ watcher?.close();
266
+ watcher = undefined;
267
+ },
268
+ persist,
269
+ };
270
+ }
@@ -35,6 +35,7 @@ function createServerContext(opts) {
35
35
  agentManager: createAgentManager(),
36
36
  permissionStore: createPermissionStore(),
37
37
  permissionMode: config.permission.defaultMode,
38
+ sessionPermissionModes: new Map(),
38
39
  // 测试工厂不解析/生成 token(不触碰用户全局数据目录)。
39
40
  authToken: undefined,
40
41
  agentRegistry,
@@ -2,14 +2,15 @@ import type { MiddlewareHandler } from 'hono';
2
2
  export type AuthOptions = {
3
3
  /** 不需认证的路径(默认放行 /api/health 探活)。 */
4
4
  publicPaths?: string[];
5
+ /** P2-16:自定义校验函数(authManager.verify)。提供时忽略 token 字符串比较。 */
6
+ verify?: (token: string | undefined) => boolean;
5
7
  };
6
8
  /**
7
9
  * Bearer token 认证中间件(spec §24.2「认证」)。
8
10
  *
9
11
  * - 未配置 token(undefined/空)→ 放行,适配本地开发场景。
10
12
  * - 配置了 token → 校验 `Authorization: Bearer <token>`,不匹配返回 401。
11
- * - publicPaths 中的路径始终放行(如健康检查)。
12
- *
13
- * 生产或远程访问场景应在 config.security.token 配置随机 token。
13
+ * - verify 提供时(P2-16 设备 token)→ 以 verify 结果为准。
14
+ * - publicPaths 中的路径始终放行(如健康检查、认证/配对引导端点)。
14
15
  */
15
16
  export declare function createAuthMiddleware(token: string | undefined, opts?: AuthOptions): MiddlewareHandler;
@@ -4,15 +4,14 @@ import { apiError } from './error.js';
4
4
  *
5
5
  * - 未配置 token(undefined/空)→ 放行,适配本地开发场景。
6
6
  * - 配置了 token → 校验 `Authorization: Bearer <token>`,不匹配返回 401。
7
- * - publicPaths 中的路径始终放行(如健康检查)。
8
- *
9
- * 生产或远程访问场景应在 config.security.token 配置随机 token。
7
+ * - verify 提供时(P2-16 设备 token)→ 以 verify 结果为准。
8
+ * - publicPaths 中的路径始终放行(如健康检查、认证/配对引导端点)。
10
9
  */
11
10
  export function createAuthMiddleware(token, opts = {}) {
12
11
  const expected = token && token.length > 0 ? `Bearer ${token}` : '';
13
12
  const publicPaths = new Set(opts.publicPaths ?? ['/api/health']);
14
13
  return async (c, next) => {
15
- if (!expected) {
14
+ if (!expected && !opts.verify) {
16
15
  await next();
17
16
  return;
18
17
  }
@@ -21,7 +20,8 @@ export function createAuthMiddleware(token, opts = {}) {
21
20
  return;
22
21
  }
23
22
  const auth = c.req.header('Authorization') ?? '';
24
- if (auth !== expected) {
23
+ const ok = opts.verify ? opts.verify(auth.replace(/^Bearer\s+/i, '')) : auth === expected;
24
+ if (!ok) {
25
25
  return apiError(c, 401, 'UNAUTHORIZED', 'Missing or invalid bearer token');
26
26
  }
27
27
  await next();
@@ -11,6 +11,8 @@ type InteractivePermissionCheckerOptions = {
11
11
  getMode?: () => PermissionMode;
12
12
  /** 遇到 ask 权限时调用(用于通知前端)。 */
13
13
  onPermissionRequired?: (request: PermissionRequest) => void | Promise<void>;
14
+ /** 权限确认超时被自动拒绝时调用(P1-6:SSE 通知前端)。 */
15
+ onPermissionTimeout?: (request: PermissionRequest) => void;
14
16
  };
15
17
  /** 阻塞式权限检查器:ask 权限会阻塞等待用户确认。
16
18
  * pending 存在全局 PermissionStore 中,独立于 agent run 生命周期。 */
@@ -26,7 +26,11 @@ function createInteractivePermissionChecker(store, opts = {}) {
26
26
  // pending 注册到全局 store,confirm 端点按 toolCallId 直接寻址,
27
27
  // 不依赖当前 agent run 是否仍在 agentManager 中注册。
28
28
  // store 内置超时:到期自动 deny 并清理条目(防永久挂起 + Map 泄漏)。
29
- store.register(toolCallId, { request, resolve });
29
+ store.register(toolCallId, {
30
+ request,
31
+ resolve,
32
+ ...(opts.onPermissionTimeout ? { onTimeout: opts.onPermissionTimeout } : {}),
33
+ });
30
34
  });
31
35
  await opts.onPermissionRequired?.(request);
32
36
  // agent abort 联动:abort 时主动 deny 并清理 store 条目,
@@ -12,11 +12,15 @@ type PermissionRequest = {
12
12
  type PendingPermission = {
13
13
  request: PermissionRequest;
14
14
  resolve: (result: PermissionResult) => void;
15
+ /** 该请求超时自动拒绝时的回调(P1-6:按请求绑定 SSE 流)。 */
16
+ onTimeout?: (request: PermissionRequest) => void;
15
17
  };
16
18
  /** createPermissionStore 的配置。 */
17
19
  type PermissionStoreOptions = {
18
20
  /** pending 超时毫秒数;省略时取 DEFAULT_PERMISSION_TIMEOUT_MS(5 分钟)。 */
19
21
  timeoutMs?: number;
22
+ /** 超时自动拒绝时回调(P1-6:通知前端「确认超时已拒绝」,提供重新询问入口)。 */
23
+ onTimeout?: (request: PermissionRequest) => void;
20
24
  };
21
25
  /** 全局权限确认 store:注册/解析 pending permission。 */
22
26
  type PermissionStore = {
@@ -30,6 +30,7 @@ function createPermissionStore(opts = {}) {
30
30
  clearTimeout(existing.timer);
31
31
  const timer = setTimeout(() => {
32
32
  settle(toolCallId, { _tag: 'deny', reason: 'Permission request timed out' });
33
+ entry.onTimeout?.(entry.request);
33
34
  }, timeoutMs);
34
35
  pending.set(toolCallId, { ...entry, timer });
35
36
  },
@@ -0,0 +1,4 @@
1
+ import { Hono } from 'hono';
2
+ import type { ServerContext } from '../types.js';
3
+ declare function createAuthRoute(ctx: ServerContext): Hono;
4
+ export { createAuthRoute };
@@ -0,0 +1,94 @@
1
+ // src/server/routes/auth.ts
2
+ // P2-16:认证引导 + 设备配对审批端点。
3
+ //
4
+ // 公开端点(未认证,中间件 publicPaths 放行):
5
+ // POST /api/auth/register 首设备注册:凭 bootstrap token 换发设备 token
6
+ // POST /api/auth/pairing/request 新设备发起配对:返回 pairingId + 6 位配对码
7
+ // GET /api/auth/pairing/status?pairingId=xxx 新设备轮询审批结果
8
+ //
9
+ // 需认证端点(已授权设备调用):
10
+ // GET /api/auth/pairing 列出待审批配对
11
+ // POST /api/auth/pairing/approve 审批通过(签发设备 token)
12
+ // POST /api/auth/pairing/deny 拒绝配对
13
+ import { Hono } from 'hono';
14
+ import { apiError } from '../middleware/error.js';
15
+ function createAuthRoute(ctx) {
16
+ const app = new Hono();
17
+ // 首设备注册:凭 bootstrap token(URL ?token=)换发设备 token,bootstrap 随即轮换失效。
18
+ app.post('/register', async (c) => {
19
+ if (!ctx.authManager) {
20
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
21
+ }
22
+ const body = (await c.req.json().catch(() => ({})));
23
+ if (!body.token) {
24
+ return apiError(c, 400, 'BAD_REQUEST', 'token is required');
25
+ }
26
+ const deviceToken = await ctx.authManager.registerFirstDevice(body.token, body.deviceName ?? '设备');
27
+ if (!deviceToken) {
28
+ // bootstrap 已失效/已注册过设备 → 引导走配对审批
29
+ return apiError(c, 403, 'BOOTSTRAP_CONSUMED', 'bootstrap token 已失效(已有设备注册)。请在新设备发起配对,由已授权设备审批。');
30
+ }
31
+ return c.json({ deviceToken });
32
+ });
33
+ // 新设备发起配对请求(公开)。
34
+ app.post('/pairing/request', async (c) => {
35
+ if (!ctx.authManager) {
36
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
37
+ }
38
+ const body = (await c.req.json().catch(() => ({})));
39
+ const result = ctx.authManager.requestPairing(body.deviceName ?? '新设备');
40
+ if (!result) {
41
+ return apiError(c, 429, 'PAIRING_LIMIT', '待审批的配对请求过多,请稍后再试');
42
+ }
43
+ return c.json(result);
44
+ });
45
+ // 新设备轮询配对审批结果(公开)。
46
+ app.get('/pairing/status', (c) => {
47
+ if (!ctx.authManager) {
48
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
49
+ }
50
+ const pairingId = c.req.query('pairingId') ?? '';
51
+ if (!pairingId)
52
+ return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
53
+ const status = ctx.authManager.pairingStatus(pairingId);
54
+ if (status.status === 'not_found') {
55
+ return apiError(c, 404, 'PAIRING_NOT_FOUND', '配对请求不存在或已过期');
56
+ }
57
+ return c.json(status);
58
+ });
59
+ // 列出待审批配对(需认证)。
60
+ app.get('/pairing', (c) => {
61
+ if (!ctx.authManager) {
62
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
63
+ }
64
+ return c.json({ pairings: ctx.authManager.listPairings() });
65
+ });
66
+ // 审批通过(需认证)。
67
+ app.post('/pairing/approve', async (c) => {
68
+ if (!ctx.authManager) {
69
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
70
+ }
71
+ const body = (await c.req.json().catch(() => ({})));
72
+ if (!body.pairingId)
73
+ return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
74
+ const ok = ctx.authManager.approvePairing(body.pairingId);
75
+ if (!ok)
76
+ return apiError(c, 404, 'PAIRING_NOT_FOUND', '配对请求不存在或已过期');
77
+ return c.json({ ok: true });
78
+ });
79
+ // 拒绝配对(需认证)。
80
+ app.post('/pairing/deny', async (c) => {
81
+ if (!ctx.authManager) {
82
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
83
+ }
84
+ const body = (await c.req.json().catch(() => ({})));
85
+ if (!body.pairingId)
86
+ return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
87
+ const ok = ctx.authManager.denyPairing(body.pairingId);
88
+ if (!ok)
89
+ return apiError(c, 404, 'PAIRING_NOT_FOUND', '配对请求不存在或已过期');
90
+ return c.json({ ok: true });
91
+ });
92
+ return app;
93
+ }
94
+ export { createAuthRoute };
@@ -1,8 +1,13 @@
1
1
  import { Hono } from 'hono';
2
2
  import type { ServerContext } from '../types.js';
3
- /** 按 session.projectId 解析 agent 工作目录;无项目回退 ctx.cwd。 */
3
+ /** 按 session.projectId / worktreePath 解析 agent 工作目录:
4
+ * - 有 projectId → 项目 worktree(缺失/目录失效时抛错,由上层转 409)。
5
+ * - 无 projectId(项目已删除后从回收站恢复的会话)→ 用 worktreePath 兜底:
6
+ * 目录失效同样抛错,绝不静默回退 serve cwd 导致 agent 在错误目录执行工具。
7
+ * - 两者皆无(从未绑定项目的会话,如 CLI print)→ 回退 ctx.cwd。 */
4
8
  export declare function resolveAgentCwd(ctx: ServerContext, session: {
5
9
  projectId: string | null;
10
+ worktreePath?: string | null;
6
11
  }): Promise<string>;
7
12
  declare function createChatRoute(ctx: ServerContext): Hono;
8
13
  export { createChatRoute };
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { readFile, stat } from 'node:fs/promises';
2
3
  import { Hono } from 'hono';
3
4
  import { streamSSE } from 'hono/streaming';
@@ -6,6 +7,7 @@ import { compactContext } from '../../core/loop.js';
6
7
  import { createSlashRegistry, parseSlashInput } from '../../core/slash.js';
7
8
  import { injectSteering } from '../../core/steering.js';
8
9
  import { buildWorkflowNotice, containsWorkflow } from '../../core/workflow.js';
10
+ import { resolveRoute } from '../../llm/registry.js';
9
11
  import { getProject } from '../../project/project.js';
10
12
  import { getLLMSegments, getSession, updateSessionLastRun } from '../../session/session.js';
11
13
  import { upsertFileSnapshot } from '../../session/snapshot.js';
@@ -14,12 +16,28 @@ import { autoAllowChecker } from '../../tools/permission.js';
14
16
  import { apiError } from '../middleware/error.js';
15
17
  import { createInteractivePermissionChecker } from '../permission/interactive.js';
16
18
  import { safeResolve } from '../util/safe-path.js';
17
- /** 按 session.projectId 解析 agent 工作目录;无项目回退 ctx.cwd。 */
19
+ /** 按 session.projectId / worktreePath 解析 agent 工作目录:
20
+ * - 有 projectId → 项目 worktree(缺失/目录失效时抛错,由上层转 409)。
21
+ * - 无 projectId(项目已删除后从回收站恢复的会话)→ 用 worktreePath 兜底:
22
+ * 目录失效同样抛错,绝不静默回退 serve cwd 导致 agent 在错误目录执行工具。
23
+ * - 两者皆无(从未绑定项目的会话,如 CLI print)→ 回退 ctx.cwd。 */
18
24
  export async function resolveAgentCwd(ctx, session) {
19
- if (!session.projectId)
20
- return ctx.cwd;
25
+ if (!session.projectId) {
26
+ if (!session.worktreePath)
27
+ return ctx.cwd;
28
+ if (!existsSync(session.worktreePath)) {
29
+ throw new Error(`WORKTREE_MISSING: 会话工作目录已失效(${session.worktreePath})。请检查目录是否被移动或删除`);
30
+ }
31
+ return session.worktreePath;
32
+ }
21
33
  const project = await getProject(ctx.db, session.projectId);
22
- return project?.worktree ?? ctx.cwd;
34
+ if (!project) {
35
+ throw new Error(`PROJECT_MISSING: 会话所属项目已不存在(projectId=${session.projectId})`);
36
+ }
37
+ if (!existsSync(project.worktree)) {
38
+ throw new Error(`WORKTREE_MISSING: 项目工作目录已失效(${project.worktree})。请检查目录是否被移动或删除`);
39
+ }
40
+ return project.worktree;
23
41
  }
24
42
  function createChatRoute(ctx) {
25
43
  const app = new Hono();
@@ -42,16 +60,48 @@ function createChatRoute(ctx) {
42
60
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
43
61
  }
44
62
  // 提前解析 cwd(slash 拦截与 agent 路径都需使用)
45
- const cwd = await resolveAgentCwd(ctx, session);
63
+ // P1-9:worktree 失效/项目缺失 → 明确 409,不静默回退服务端 cwd。
64
+ let cwd;
65
+ try {
66
+ cwd = await resolveAgentCwd(ctx, session);
67
+ }
68
+ catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ if (msg.startsWith('PROJECT_MISSING')) {
71
+ return apiError(c, 409, 'PROJECT_MISSING', msg.replace('PROJECT_MISSING: ', ''));
72
+ }
73
+ if (msg.startsWith('WORKTREE_MISSING')) {
74
+ return apiError(c, 409, 'WORKTREE_MISSING', msg.replace('WORKTREE_MISSING: ', ''));
75
+ }
76
+ return apiError(c, 500, 'CWD_RESOLVE_FAILED', msg);
77
+ }
46
78
  // 斜杠命令拦截
47
79
  const parsed = parseSlashInput(message);
48
80
  if (parsed) {
49
81
  const registry = createSlashRegistry();
50
82
  const cmd = registry.get(parsed.name);
51
83
  if (cmd) {
84
+ // P2-4:执行 config.slashCommands.enabled 过滤(此前该配置无任何消费方)。
85
+ // enabled 为空 = 全部启用(与 tools.enabled 语义一致);名称兼容带/不带前缀斜杠。
86
+ const enabledList = ctx.config.slashCommands?.enabled ?? [];
87
+ const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
88
+ if (enabledSet.size > 0 && !enabledSet.has(parsed.name)) {
89
+ return streamSSE(c, async (stream) => {
90
+ await stream.writeSSE({
91
+ event: 'text_delta',
92
+ data: JSON.stringify({
93
+ _tag: 'text_delta',
94
+ text: `斜杠命令 /${parsed.name} 未启用(config.slashCommands.enabled)`,
95
+ }),
96
+ });
97
+ await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
98
+ });
99
+ }
52
100
  const commandCtx = {
53
101
  cwd,
54
102
  config: ctx.config,
103
+ // 当前会话 id:/clear /fork 等命令默认作用于当前会话(P2-4)。
104
+ sessionId,
55
105
  // 内置斜杠命令(/clear、/fork、/config)仅需 db + config;
56
106
  // 但 /workflow run 会走 executeWorkflow → buildWorkflowContext → runSubAgent,
57
107
  // 该路径需要 agentRegistry 来派生子 agent,因此必须注入。
@@ -184,6 +234,25 @@ function createChatRoute(ctx) {
184
234
  }
185
235
  const provider = body.provider ?? ctx.config.defaultProvider;
186
236
  const model = body.model ?? ctx.config.defaultModel;
237
+ // P0-1 首次运行引导:provider 未配置/未注册时立即返回可操作错误,
238
+ // 不让错误延迟到 SSE 流内以英文 NoRoute 冒出。前端据此引导用户去设置页。
239
+ // 注:测试注入 ctx.chatStream 时跳过——测试 registry 空壳不代表生产未配置。
240
+ if (!ctx.chatStream) {
241
+ try {
242
+ resolveRoute(ctx.llmRegistry, provider, model);
243
+ }
244
+ catch {
245
+ const configured = ctx.config.providers
246
+ .map((p) => p.name)
247
+ .filter(Boolean)
248
+ .join(', ');
249
+ return apiError(c, 400, 'NO_PROVIDER_CONFIGURED', `未配置可用的 AI 服务(provider: ${provider})。请前往「设置 → Provider」添加 API 服务并测试连接`, {
250
+ provider,
251
+ configuredProviders: ctx.config.providers.map((p) => p.name).filter(Boolean),
252
+ ...(configured ? { hint: `已配置:${configured}` } : {}),
253
+ });
254
+ }
255
+ }
187
256
  // 并发守卫(P0-4):同步原子占位 tryAcquire(Map has+set 一气呵成、无 await)。
188
257
  // 旧实现先 get 检查、真正 register 在 SSE 回调内,两者之间隔多个 await
189
258
  // (getLLMSegments、createAgent 等):双发 POST 在窗口内均通过守卫,后注册
@@ -241,15 +310,25 @@ function createChatRoute(ctx) {
241
310
  let doneSent = false;
242
311
  let runStartedAt;
243
312
  try {
244
- // 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认
313
+ // 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认。
314
+ // P1-5:模式按会话隔离(sessionPermissionModes 覆盖优先)。
245
315
  const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
246
- getMode: () => ctx.permissionMode,
316
+ getMode: () => ctx.sessionPermissionModes.get(sessionId) ?? ctx.permissionMode,
247
317
  onPermissionRequired: async (req) => {
248
318
  await stream.writeSSE({
249
319
  event: 'permission_required',
250
320
  data: JSON.stringify({ _tag: 'permission_required', ...req }),
251
321
  });
252
322
  },
323
+ // P1-6:确认超时(5 分钟)被自动拒绝时通知前端,避免用户误以为已执行
324
+ onPermissionTimeout: (req) => {
325
+ stream
326
+ .writeSSE({
327
+ event: 'permission_timeout',
328
+ data: JSON.stringify({ _tag: 'permission_timeout', ...req }),
329
+ })
330
+ .catch(() => { });
331
+ },
253
332
  });
254
333
  // cwd 已在斜杠拦截前解析,此处直接复用
255
334
  // 构建 agent 依赖(注入测试用 chatStream)
@@ -284,6 +363,7 @@ function createChatRoute(ctx) {
284
363
  model: resolvedModel,
285
364
  startedAt: runStartedAt,
286
365
  });
366
+ state.lastRunStartedAt = runStartedAt;
287
367
  // 填充 tryAcquire 留下的占位(原 register 语义:按 sessionId 覆盖写入)
288
368
  ctx.agentManager.register({ sessionId, state, deps });
289
369
  // workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),