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.
Files changed (109) hide show
  1. package/README.md +25 -8
  2. package/dist/cli/commands/acp.js +5 -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 +10 -0
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +15 -54
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/serve.d.ts +1 -0
  11. package/dist/cli/commands/serve.js +4 -1
  12. package/dist/cli/commands/sessions.d.ts +10 -0
  13. package/dist/cli/commands/sessions.js +35 -0
  14. package/dist/cli/commands/update.js +18 -10
  15. package/dist/cli/deps.d.ts +7 -5
  16. package/dist/cli/deps.js +23 -7
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +96 -37
  19. package/dist/cli/modes/print.d.ts +3 -1
  20. package/dist/cli/modes/print.js +31 -4
  21. package/dist/core/agent.js +3 -4
  22. package/dist/core/config-path.d.ts +9 -0
  23. package/dist/core/config-path.js +48 -0
  24. package/dist/core/config.d.ts +27 -2
  25. package/dist/core/config.js +83 -13
  26. package/dist/core/index.d.ts +1 -1
  27. package/dist/core/index.js +1 -1
  28. package/dist/core/loop.js +14 -4
  29. package/dist/core/slash.js +92 -30
  30. package/dist/core/title.js +15 -0
  31. package/dist/core/types.d.ts +2 -0
  32. package/dist/core/workflows/discovery.d.ts +1 -1
  33. package/dist/core/workflows/index.d.ts +1 -1
  34. package/dist/db/client.d.ts +4 -0
  35. package/dist/db/client.js +5 -0
  36. package/dist/db/schema.d.ts +51 -0
  37. package/dist/db/schema.js +7 -1
  38. package/dist/server/agent-manager.d.ts +20 -0
  39. package/dist/server/agent-manager.js +63 -11
  40. package/dist/server/app.js +12 -1
  41. package/dist/server/auth-manager.d.ts +79 -0
  42. package/dist/server/auth-manager.js +270 -0
  43. package/dist/server/context.js +3 -0
  44. package/dist/server/middleware/auth.d.ts +4 -3
  45. package/dist/server/middleware/auth.js +5 -5
  46. package/dist/server/middleware/cors.d.ts +11 -2
  47. package/dist/server/middleware/cors.js +15 -12
  48. package/dist/server/permission/interactive.d.ts +2 -0
  49. package/dist/server/permission/interactive.js +5 -1
  50. package/dist/server/permission/store.d.ts +4 -0
  51. package/dist/server/permission/store.js +1 -0
  52. package/dist/server/routes/auth.d.ts +4 -0
  53. package/dist/server/routes/auth.js +94 -0
  54. package/dist/server/routes/chat.d.ts +6 -1
  55. package/dist/server/routes/chat.js +294 -153
  56. package/dist/server/routes/config.js +60 -8
  57. package/dist/server/routes/permissions.d.ts +4 -1
  58. package/dist/server/routes/permissions.js +27 -4
  59. package/dist/server/routes/project.js +56 -2
  60. package/dist/server/routes/session.js +37 -9
  61. package/dist/server/routes/terminal.js +29 -2
  62. package/dist/server/routes/update.d.ts +1 -0
  63. package/dist/server/routes/update.js +33 -4
  64. package/dist/server/routes/workflows.js +2 -2
  65. package/dist/server/server.d.ts +24 -1
  66. package/dist/server/server.js +149 -29
  67. package/dist/server/types.d.ts +17 -2
  68. package/dist/session/archive.d.ts +1 -1
  69. package/dist/session/branch.d.ts +11 -2
  70. package/dist/session/branch.js +107 -31
  71. package/dist/session/compaction.js +1 -0
  72. package/dist/session/context.d.ts +3 -2
  73. package/dist/session/context.js +36 -3
  74. package/dist/session/index.d.ts +1 -1
  75. package/dist/session/index.js +1 -1
  76. package/dist/session/session.d.ts +25 -5
  77. package/dist/session/session.js +129 -10
  78. package/dist/session/snapshot.d.ts +9 -3
  79. package/dist/session/snapshot.js +45 -4
  80. package/dist/session/squash.js +1 -0
  81. package/dist/session/types.d.ts +3 -1
  82. package/dist/shared/types/agent.d.ts +11 -0
  83. package/dist/shared/types/config.d.ts +12 -6
  84. package/dist/shared/types/message.d.ts +6 -2
  85. package/dist/shared/utils/path.d.ts +2 -3
  86. package/dist/shared/utils/path.js +20 -4
  87. package/dist/tools/builtin/todo.js +5 -4
  88. package/dist/tools/index.d.ts +14 -3
  89. package/dist/tools/index.js +35 -14
  90. package/dist/update/index.d.ts +2 -2
  91. package/dist/update/index.js +2 -2
  92. package/dist/update/ipc.d.ts +38 -5
  93. package/dist/update/ipc.js +56 -7
  94. package/dist/update/snapshot.d.ts +4 -0
  95. package/dist/update/snapshot.js +8 -0
  96. package/dist/update/updater.d.ts +61 -10
  97. package/dist/update/updater.js +236 -25
  98. package/dist/update/version.d.ts +6 -1
  99. package/dist/update/version.js +18 -3
  100. package/drizzle/0005_snapshot_mtime.sql +1 -0
  101. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  102. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  103. package/drizzle/meta/0005_snapshot.json +855 -0
  104. package/drizzle/meta/0006_snapshot.json +867 -0
  105. package/drizzle/meta/0007_snapshot.json +869 -0
  106. package/drizzle/meta/_journal.json +21 -0
  107. package/package.json +1 -7
  108. package/dist/mcp/index.d.ts +0 -1
  109. package/dist/mcp/index.js +0 -3
@@ -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,9 @@ function createServerContext(opts) {
35
35
  agentManager: createAgentManager(),
36
36
  permissionStore: createPermissionStore(),
37
37
  permissionMode: config.permission.defaultMode,
38
+ sessionPermissionModes: new Map(),
39
+ // 测试工厂不解析/生成 token(不触碰用户全局数据目录)。
40
+ authToken: undefined,
38
41
  agentRegistry,
39
42
  // 工作流注册表:惰性初始化,只含内置(测试工厂;生产路径走 buildServerContext)。
40
43
  get workflowRegistry() {
@@ -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();
@@ -1,10 +1,19 @@
1
1
  import type { MiddlewareHandler } from 'hono';
2
2
  /**
3
3
  * 判断 origin 是否为本地/可信来源(spec §24.2「CORS 限制本地 origin」)。
4
- * 允许:本机回环任意端口、file://(PWA 安装后)、浏览器扩展、'null'
5
- * (file 协议/隐私沙箱发出的 Origin 头)。
4
+ *
5
+ * 仅允许本机回环任意端口 + 显式配置的 allowedOrigins。
6
+ * 显式不放行 'null'(sandboxed iframe)、file:// 与浏览器扩展前缀——
7
+ * 任意网页/扩展均可伪造这些来源,等价于完全关闭 CORS 防护。
8
+ * 认证未启用时(authEnabled=false 的显式选择),本函数仍是唯一的跨域读取防线。
6
9
  */
7
10
  export declare function isLocalOrigin(origin: string): boolean;
11
+ /**
12
+ * Origin 是否被允许(本地回环 + 配置的额外 origin)。
13
+ * 供 CORS 中间件与 WebSocket 升级路径共用——浏览器对 WS 不实施 CORS,
14
+ * 但会携带 Origin 头,必须在服务端显式校验。
15
+ */
16
+ export declare function isAllowedOrigin(origin: string, allowedOrigins?: string[]): boolean;
8
17
  export type CORSOptions = {
9
18
  /** 额外允许的 origin(如远程访问场景配置的域名)。 */
10
19
  allowedOrigins?: string[];
@@ -2,23 +2,26 @@
2
2
  const LOCAL_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
3
3
  /**
4
4
  * 判断 origin 是否为本地/可信来源(spec §24.2「CORS 限制本地 origin」)。
5
- * 允许:本机回环任意端口、file://(PWA 安装后)、浏览器扩展、'null'
6
- * (file 协议/隐私沙箱发出的 Origin 头)。
5
+ *
6
+ * 仅允许本机回环任意端口 + 显式配置的 allowedOrigins。
7
+ * 显式不放行 'null'(sandboxed iframe)、file:// 与浏览器扩展前缀——
8
+ * 任意网页/扩展均可伪造这些来源,等价于完全关闭 CORS 防护。
9
+ * 认证未启用时(authEnabled=false 的显式选择),本函数仍是唯一的跨域读取防线。
7
10
  */
8
11
  export function isLocalOrigin(origin) {
9
12
  if (!origin)
10
13
  return false;
11
- if (LOCAL_ORIGIN.test(origin))
12
- return true;
13
- if (origin === 'null')
14
- return true;
15
- if (origin.startsWith('file://'))
16
- return true;
17
- if (origin.startsWith('chrome-extension://'))
18
- return true;
19
- if (origin.startsWith('moz-extension://'))
14
+ return LOCAL_ORIGIN.test(origin);
15
+ }
16
+ /**
17
+ * Origin 是否被允许(本地回环 + 配置的额外 origin)。
18
+ * 供 CORS 中间件与 WebSocket 升级路径共用——浏览器对 WS 不实施 CORS,
19
+ * 但会携带 Origin 头,必须在服务端显式校验。
20
+ */
21
+ export function isAllowedOrigin(origin, allowedOrigins) {
22
+ if (isLocalOrigin(origin))
20
23
  return true;
21
- return false;
24
+ return allowedOrigins?.includes(origin) ?? false;
22
25
  }
23
26
  /**
24
27
  * CORS 中间件:仅对本地/可信 origin 回显 Access-Control-Allow-Origin,
@@ -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 };