c0de-agent 1.11.0 → 1.13.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 (177) hide show
  1. package/README.md +63 -16
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +151 -1
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +63 -3
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +109 -3
  8. package/dist/cli/commands/trust.d.ts +11 -0
  9. package/dist/cli/commands/trust.js +31 -0
  10. package/dist/cli/commands/update.js +4 -0
  11. package/dist/cli/deps.d.ts +9 -6
  12. package/dist/cli/deps.js +66 -19
  13. package/dist/cli/index.d.ts +4 -1
  14. package/dist/cli/index.js +110 -11
  15. package/dist/cli/modes/acp.js +39 -8
  16. package/dist/cli/modes/print.d.ts +3 -0
  17. package/dist/cli/modes/print.js +78 -8
  18. package/dist/core/agent.d.ts +1 -1
  19. package/dist/core/agent.js +9 -4
  20. package/dist/core/agents/builtin.js +2 -2
  21. package/dist/core/agents/index.d.ts +0 -1
  22. package/dist/core/agents/index.js +0 -1
  23. package/dist/core/compact.d.ts +2 -0
  24. package/dist/core/compact.js +1 -0
  25. package/dist/core/config.d.ts +53 -2
  26. package/dist/core/config.js +235 -19
  27. package/dist/core/loop/compaction.d.ts +2 -1
  28. package/dist/core/loop/compaction.js +19 -6
  29. package/dist/core/loop/segment.d.ts +3 -1
  30. package/dist/core/loop/segment.js +24 -1
  31. package/dist/core/loop/stream-collect.js +9 -1
  32. package/dist/core/loop/subagent.js +88 -37
  33. package/dist/core/loop.d.ts +7 -0
  34. package/dist/core/loop.js +90 -6
  35. package/dist/core/redact.d.ts +11 -0
  36. package/dist/core/redact.js +49 -0
  37. package/dist/core/slash.d.ts +10 -0
  38. package/dist/core/slash.js +237 -65
  39. package/dist/core/title.js +7 -1
  40. package/dist/core/types.d.ts +22 -1
  41. package/dist/core/workflows/context.js +44 -4
  42. package/dist/core/workflows/discovery.d.ts +2 -2
  43. package/dist/core/workflows/discovery.js +5 -7
  44. package/dist/core/workflows/index.d.ts +2 -1
  45. package/dist/core/workflows/index.js +2 -1
  46. package/dist/core/workflows/registry.d.ts +13 -3
  47. package/dist/core/workflows/registry.js +14 -9
  48. package/dist/core/workflows/resolve.d.ts +22 -0
  49. package/dist/core/workflows/resolve.js +23 -0
  50. package/dist/core/workflows/runtime.d.ts +6 -1
  51. package/dist/core/workflows/runtime.js +19 -2
  52. package/dist/db/schema.d.ts +373 -1
  53. package/dist/db/schema.js +60 -4
  54. package/dist/kanban/index.d.ts +2 -1
  55. package/dist/kanban/index.js +1 -1
  56. package/dist/kanban/store.d.ts +87 -1
  57. package/dist/kanban/store.js +429 -22
  58. package/dist/llm/index.d.ts +1 -1
  59. package/dist/llm/index.js +1 -1
  60. package/dist/llm/provider.js +1 -1
  61. package/dist/llm/registry.d.ts +7 -1
  62. package/dist/llm/registry.js +7 -1
  63. package/dist/llm/retry.d.ts +5 -2
  64. package/dist/llm/retry.js +6 -5
  65. package/dist/llm/routing.d.ts +24 -1
  66. package/dist/llm/routing.js +29 -1
  67. package/dist/plugins/init.d.ts +7 -0
  68. package/dist/plugins/init.js +22 -1
  69. package/dist/plugins/loader.d.ts +7 -1
  70. package/dist/plugins/loader.js +12 -4
  71. package/dist/project/index.d.ts +1 -1
  72. package/dist/project/index.js +1 -1
  73. package/dist/project/project.d.ts +37 -0
  74. package/dist/project/project.js +142 -1
  75. package/dist/project/trust.d.ts +65 -0
  76. package/dist/project/trust.js +328 -0
  77. package/dist/server/agent-manager.d.ts +15 -2
  78. package/dist/server/agent-manager.js +18 -3
  79. package/dist/server/app.js +45 -30
  80. package/dist/server/auth-manager.d.ts +26 -9
  81. package/dist/server/auth-manager.js +78 -42
  82. package/dist/server/context.js +14 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/index.js +1 -1
  85. package/dist/server/middleware/auth.d.ts +11 -1
  86. package/dist/server/middleware/auth.js +25 -3
  87. package/dist/server/permission/interactive.d.ts +11 -3
  88. package/dist/server/permission/interactive.js +23 -4
  89. package/dist/server/permission/store.d.ts +14 -2
  90. package/dist/server/permission/store.js +39 -3
  91. package/dist/server/registry-config.d.ts +14 -0
  92. package/dist/server/registry-config.js +44 -0
  93. package/dist/server/routes/auth.js +66 -10
  94. package/dist/server/routes/chat.d.ts +21 -0
  95. package/dist/server/routes/chat.js +685 -52
  96. package/dist/server/routes/commands.d.ts +4 -2
  97. package/dist/server/routes/commands.js +9 -4
  98. package/dist/server/routes/config.js +180 -23
  99. package/dist/server/routes/files.js +142 -28
  100. package/dist/server/routes/kanban.js +218 -26
  101. package/dist/server/routes/permissions.d.ts +0 -4
  102. package/dist/server/routes/permissions.js +140 -7
  103. package/dist/server/routes/project.js +132 -29
  104. package/dist/server/routes/provider.js +54 -3
  105. package/dist/server/routes/session.js +463 -23
  106. package/dist/server/routes/tool.js +24 -5
  107. package/dist/server/routes/update.d.ts +11 -5
  108. package/dist/server/routes/update.js +145 -29
  109. package/dist/server/routes/usage.d.ts +4 -0
  110. package/dist/server/routes/usage.js +133 -0
  111. package/dist/server/routes/workflows.js +476 -87
  112. package/dist/server/server.d.ts +16 -13
  113. package/dist/server/server.js +364 -107
  114. package/dist/server/terminal/pty-manager.d.ts +12 -0
  115. package/dist/server/terminal/pty-manager.js +63 -7
  116. package/dist/server/types.d.ts +16 -0
  117. package/dist/session/archive.d.ts +3 -1
  118. package/dist/session/archive.js +13 -1
  119. package/dist/session/branch.d.ts +2 -1
  120. package/dist/session/branch.js +92 -9
  121. package/dist/session/context.js +28 -2
  122. package/dist/session/import.d.ts +44 -0
  123. package/dist/session/import.js +83 -0
  124. package/dist/session/index.d.ts +1 -1
  125. package/dist/session/index.js +1 -1
  126. package/dist/session/jobs.d.ts +12 -0
  127. package/dist/session/jobs.js +53 -0
  128. package/dist/session/message.d.ts +7 -1
  129. package/dist/session/message.js +21 -1
  130. package/dist/session/session.d.ts +186 -12
  131. package/dist/session/session.js +577 -33
  132. package/dist/session/snapshot.d.ts +5 -2
  133. package/dist/session/snapshot.js +10 -2
  134. package/dist/session/types.d.ts +13 -1
  135. package/dist/session/usage.d.ts +62 -0
  136. package/dist/session/usage.js +194 -0
  137. package/dist/shared/types/agent.d.ts +29 -2
  138. package/dist/shared/types/config.d.ts +64 -5
  139. package/dist/shared/types/kanban.d.ts +17 -2
  140. package/dist/shared/types/message.d.ts +23 -0
  141. package/dist/tools/builtin/kanban.js +13 -2
  142. package/dist/tools/index.d.ts +14 -3
  143. package/dist/tools/index.js +23 -5
  144. package/dist/tools/websearch/index.d.ts +5 -1
  145. package/dist/tools/websearch/index.js +46 -7
  146. package/dist/tools/websearch/providers/brave.js +2 -2
  147. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  148. package/dist/tools/websearch/providers/tavily.js +2 -2
  149. package/dist/tools/websearch/types.d.ts +5 -1
  150. package/dist/tools/websearch/types.js +7 -1
  151. package/dist/update/index.d.ts +3 -3
  152. package/dist/update/index.js +2 -2
  153. package/dist/update/ipc.d.ts +13 -2
  154. package/dist/update/ipc.js +36 -2
  155. package/dist/update/scheduler.js +5 -2
  156. package/dist/update/snapshot.d.ts +18 -3
  157. package/dist/update/snapshot.js +5 -2
  158. package/dist/update/updater.d.ts +17 -1
  159. package/dist/update/updater.js +47 -18
  160. package/dist/update/version.d.ts +3 -0
  161. package/dist/update/version.js +9 -3
  162. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  163. package/drizzle/0010_usage_events_ledger.sql +17 -0
  164. package/drizzle/0011_slow_slapstick.sql +12 -0
  165. package/drizzle/0012_chief_timeslip.sql +1 -0
  166. package/drizzle/0013_swift_zeigeist.sql +1 -0
  167. package/drizzle/0014_flimsy_legion.sql +1 -0
  168. package/drizzle/meta/0008_snapshot.json +875 -0
  169. package/drizzle/meta/0010_snapshot.json +1010 -0
  170. package/drizzle/meta/0011_snapshot.json +1055 -0
  171. package/drizzle/meta/0012_snapshot.json +1062 -0
  172. package/drizzle/meta/0013_snapshot.json +1068 -0
  173. package/drizzle/meta/0014_snapshot.json +1075 -0
  174. package/drizzle/meta/_journal.json +42 -0
  175. package/package.json +42 -42
  176. package/dist/core/agents/discovery.d.ts +0 -6
  177. package/dist/core/agents/discovery.js +0 -96
@@ -9,9 +9,12 @@
9
9
  // - 显式配置 security.token 时为静态模式:不轮换、不配对(CI/脚本场景)。
10
10
  // - authEnabled=false 时不启用。
11
11
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
12
- import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync, } from 'node:fs';
13
13
  import { basename, dirname, join } from 'node:path';
14
14
  const DEVICES_FILENAME = 'devices.json';
15
+ /** 首设备 bootstrap token 默认有效期:5 分钟。缩短「URL 泄漏后抢先注册」竞态窗口。
16
+ * 重新 `c0de serve` 会重新生成 bootstrap(无已注册设备时),刷新该窗口。 */
17
+ const DEFAULT_FIRST_DEVICE_TTL_MS = 5 * 60 * 1000;
15
18
  function hashToken(token) {
16
19
  return createHash('sha256').update(token).digest('hex');
17
20
  }
@@ -25,15 +28,13 @@ function safeEqual(a, b) {
25
28
  export function createAuthManager(opts) {
26
29
  const { dataDir, staticToken, tokenFilePath, now = Date.now } = opts;
27
30
  const pairingTtlMs = opts.pairingTtlMs ?? 10 * 60 * 1000;
28
- const handoffRetention = opts.handoffTokenRetention ?? 3;
31
+ const firstDeviceTtlMs = opts.firstDeviceTtlMs ?? DEFAULT_FIRST_DEVICE_TTL_MS;
29
32
  const tokenPath = tokenFilePath ?? join(dataDir, 'auth-token');
30
33
  const devicesPath = join(dataDir, DEVICES_FILENAME);
31
34
  // ── 状态加载 ──
32
35
  let bootstrap = staticToken && staticToken.length > 0 ? staticToken : readBootstrapToken(tokenPath);
33
36
  const devices = new Map();
34
37
  loadDevices();
35
- // 轮换历史 bootstrap(仅 handoff 校验接受,API 不接受)
36
- const previousBootstraps = [];
37
38
  const pending = new Map();
38
39
  function readBootstrapToken(path) {
39
40
  try {
@@ -95,17 +96,11 @@ export function createAuthManager(opts) {
95
96
  function rotateBootstrap() {
96
97
  if (staticToken && staticToken.length > 0)
97
98
  return; // 静态模式不轮换
98
- 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;
99
+ bootstrap = randomBytes(32).toString('hex');
105
100
  try {
106
101
  if (!existsSync(dataDir))
107
102
  mkdirSync(dataDir, { recursive: true });
108
- writeFileSync(tokenPath, next, 'utf-8');
103
+ writeFileSync(tokenPath, bootstrap, 'utf-8');
109
104
  }
110
105
  catch {
111
106
  // 写文件失败:内存 token 仍已轮换,进程内一致
@@ -124,12 +119,24 @@ export function createAuthManager(opts) {
124
119
  return false;
125
120
  return safeEqual(token, bootstrap);
126
121
  }
122
+ /** bootstrap 自生成(token 文件 mtime)起的存活时长;静态模式或无法读取返回 null。 */
123
+ function bootstrapAge() {
124
+ if (staticToken && staticToken.length > 0)
125
+ return null;
126
+ try {
127
+ return now() - statSync(tokenPath).mtimeMs;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
127
133
  function cleanExpiredPairings() {
128
134
  const cutoff = now() - pairingTtlMs;
129
135
  for (const [id, p] of pending) {
130
- if (p.status === 'pending' && p.createdAt <= cutoff) {
136
+ // 不论状态一律过期清理:approved 但新设备始终未取走 token 的条目(客户端放弃轮询)
137
+ // 同样需要回收,否则长驻内存且其明文 token 无期限留存。
138
+ if (p.createdAt <= cutoff)
131
139
  pending.delete(id);
132
- }
133
140
  }
134
141
  }
135
142
  return {
@@ -150,20 +157,24 @@ export function createAuthManager(opts) {
150
157
  return true;
151
158
  if (verifyBootstrap(token))
152
159
  return true;
153
- for (const prev of previousBootstraps) {
154
- if (safeEqual(token, prev))
155
- return true;
156
- }
157
160
  return false;
158
161
  },
159
162
  async registerFirstDevice(bootstrapToken, deviceName) {
160
163
  if (staticToken && staticToken.length > 0)
161
- return null; // 静态模式无设备注册
162
- if (!verifyBootstrap(bootstrapToken))
163
- return null;
164
- // 已有设备 → bootstrap 已失效,不得再凭它注册(需配对审批)
164
+ return { ok: false, reason: 'static_mode' };
165
+ // 已有设备 → bootstrap 已失效(注册即轮换),不得再凭它注册(需配对审批)。
166
+ // 先于 verifyBootstrap 判定:轮换后的旧 URL token 属「已消费」而非「无效」,
167
+ // 两者给用户的恢复指引不同(配对审批 vs 检查 token)。
165
168
  if (devices.size > 0)
166
- return null;
169
+ return { ok: false, reason: 'devices_exist' };
170
+ if (!verifyBootstrap(bootstrapToken))
171
+ return { ok: false, reason: 'invalid_bootstrap' };
172
+ // 首设备注册窗口:bootstrap 超过 firstDeviceTtlMs 后拒绝(缩短先到先得竞态窗口)。
173
+ if (firstDeviceTtlMs > 0) {
174
+ const age = bootstrapAge();
175
+ if (age != null && age > firstDeviceTtlMs)
176
+ return { ok: false, reason: 'ttl_expired' };
177
+ }
167
178
  const deviceToken = randomBytes(32).toString('hex');
168
179
  const record = {
169
180
  id: randomBytes(16).toString('hex'),
@@ -174,16 +185,21 @@ export function createAuthManager(opts) {
174
185
  devices.set(record.id, record);
175
186
  persist();
176
187
  rotateBootstrap(); // 注册成功即轮换,旧 bootstrap(URL/shell history 中)立即失效
177
- return deviceToken;
188
+ return { ok: true, deviceToken };
178
189
  },
179
- requestPairing(deviceName) {
190
+ requestPairing(deviceName, source) {
180
191
  if (staticToken && staticToken.length > 0)
181
192
  return null;
182
193
  cleanExpiredPairings();
183
- // 限流:最多 10 个待审批
194
+ // 限流:最多 10 个待审批;同一来源最多 3 个(防本地/远程进程刷满队列,
195
+ // 使合法设备 10 分钟内无法配对)。source 尽力而为,缺省视为 'unknown'。
184
196
  const pendingCount = Array.from(pending.values()).filter((p) => p.status === 'pending').length;
185
197
  if (pendingCount >= 10)
186
198
  return null;
199
+ const src = source ?? 'unknown';
200
+ const perSource = Array.from(pending.values()).filter((p) => p.status === 'pending' && p.source === src).length;
201
+ if (perSource >= 3)
202
+ return null;
187
203
  const pairingId = randomBytes(16).toString('hex');
188
204
  const code = String(100000 + Math.floor(Math.random() * 900000)); // 6 位数字
189
205
  pending.set(pairingId, {
@@ -192,6 +208,7 @@ export function createAuthManager(opts) {
192
208
  code,
193
209
  createdAt: now(),
194
210
  status: 'pending',
211
+ source: src,
195
212
  });
196
213
  return { pairingId, code };
197
214
  },
@@ -199,8 +216,24 @@ export function createAuthManager(opts) {
199
216
  const p = pending.get(pairingId);
200
217
  if (!p)
201
218
  return { status: 'not_found' };
202
- if (p.status === 'approved')
203
- return { status: 'approved', deviceToken: p.deviceToken ?? '' };
219
+ if (p.status === 'approved') {
220
+ // 交付点:把设备登记落盘与「新设备取走 token」原子化到同一时刻——
221
+ // 审批后、取 token 前服务重启时 pending 随内存清空(status=not_found),
222
+ // 不会留下拿不到 token 的僵尸设备条目;此后新设备重新发起配对即可。
223
+ const deviceToken = p.deviceToken ?? '';
224
+ if (deviceToken) {
225
+ const record = {
226
+ id: randomBytes(16).toString('hex'),
227
+ name: p.deviceName,
228
+ tokenHash: hashToken(deviceToken),
229
+ createdAt: now(),
230
+ };
231
+ devices.set(record.id, record);
232
+ persist();
233
+ pending.delete(pairingId);
234
+ }
235
+ return { status: 'approved', deviceToken };
236
+ }
204
237
  if (p.status === 'denied')
205
238
  return { status: 'denied' };
206
239
  if (p.createdAt <= now() - pairingTtlMs) {
@@ -218,32 +251,35 @@ export function createAuthManager(opts) {
218
251
  deviceName: p.deviceName,
219
252
  code: p.code,
220
253
  createdAt: p.createdAt,
254
+ source: p.source,
221
255
  }));
222
256
  },
223
- approvePairing(pairingId) {
257
+ approvePairing(pairingId, code) {
224
258
  const p = pending.get(pairingId);
225
- if (!p || p.status !== 'pending')
226
- return false;
259
+ if (!p)
260
+ return 'not_found';
261
+ if (p.status !== 'pending')
262
+ return 'already_handled';
227
263
  if (p.createdAt <= now() - pairingTtlMs) {
228
264
  pending.delete(pairingId);
229
- return false;
265
+ return 'expired';
230
266
  }
267
+ if (String(code ?? '').trim() !== p.code)
268
+ return 'code_mismatch';
269
+ // P:登记推迟到新设备真正取走 token 时(pairingStatus 消费)。此前审批即落盘,
270
+ // 若审批后、新设备轮询前服务重启,devices.json 里会出现一个永远拿不到 token
271
+ // 的僵尸条目(pending 不落盘、明文 token 只在内存)——「已授权设备」列表与
272
+ // 实际可登录设备自相矛盾,用户只能撤销后重新配对。
231
273
  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
274
  p.status = 'approved';
240
275
  p.deviceToken = deviceToken;
241
- persist();
242
- return true;
276
+ return 'approved';
243
277
  },
244
278
  denyPairing(pairingId) {
245
279
  const p = pending.get(pairingId);
246
- if (!p || p.status !== 'pending')
280
+ if (!p)
281
+ return false;
282
+ if (p.status !== 'pending')
247
283
  return false;
248
284
  p.status = 'denied';
249
285
  return true;
@@ -33,9 +33,22 @@ function createServerContext(opts) {
33
33
  hookRunner,
34
34
  pluginRegistry: createPluginRegistry(hookRunner),
35
35
  agentManager: createAgentManager(),
36
- permissionStore: createPermissionStore(),
36
+ // P3:与生产 buildServerContext 同口径——配置的确认超时/宽限期生效。
37
+ permissionStore: createPermissionStore({
38
+ ...(config.permission.timeoutMs !== undefined && config.permission.timeoutMs > 0
39
+ ? { timeoutMs: config.permission.timeoutMs }
40
+ : {}),
41
+ ...(config.permission.expireGraceMs !== undefined && config.permission.expireGraceMs > 0
42
+ ? { expireGraceMs: config.permission.expireGraceMs }
43
+ : {}),
44
+ }),
37
45
  permissionMode: config.permission.defaultMode,
38
46
  sessionPermissionModes: new Map(),
47
+ sessionAlwaysAllow: new Map(),
48
+ workflowBusyBySession: new Map(),
49
+ workflowBusyByScope: new Map(),
50
+ // P1-1:项目级 LLM 注册表缓存(按项目合并配置构建,chat 路由按需创建)。
51
+ projectRegistries: new Map(),
39
52
  // 测试工厂不解析/生成 token(不触碰用户全局数据目录)。
40
53
  authToken: undefined,
41
54
  agentRegistry,
@@ -7,5 +7,5 @@ export { apiError, errorHandler } from './middleware/error.js';
7
7
  export type { InteractivePermissionChecker, InteractivePermissionCheckerOptions, PermissionRequest, } from './permission/interactive.js';
8
8
  export { createInteractivePermissionChecker } from './permission/interactive.js';
9
9
  export type { RunningServer, StartServerOptions } from './server.js';
10
- export { startServer } from './server.js';
10
+ export { DEFAULT_SERVE_PORT, startServer } from './server.js';
11
11
  export type { APIErrorBody, ChatRequest, ConfirmRequest, ControlRequest, ServerContext, SteerRequest, } from './types.js';
@@ -4,4 +4,4 @@ export { createApp } from './app.js';
4
4
  export { createServerContext } from './context.js';
5
5
  export { apiError, errorHandler } from './middleware/error.js';
6
6
  export { createInteractivePermissionChecker } from './permission/interactive.js';
7
- export { startServer } from './server.js';
7
+ export { DEFAULT_SERVE_PORT, startServer } from './server.js';
@@ -4,11 +4,21 @@ export type AuthOptions = {
4
4
  publicPaths?: string[];
5
5
  /** P2-16:自定义校验函数(authManager.verify)。提供时忽略 token 字符串比较。 */
6
6
  verify?: (token: string | undefined) => boolean;
7
+ /** 认证未启用时的跨域写防线(盲 CSRF):CORS 回显只拦「读」——恶意网页对本地
8
+ * 服务的 POST/DELETE 等请求副作用仍会执行,只是读不到响应。开启认证(token/verify
9
+ * 存在)时凭 token 校验已构成防线,此项不生效。 */
10
+ allowedOrigins?: string[];
11
+ /** 允许经 `?token=` 查询参数认证的路径判定(P1 媒体预览修复:浏览器
12
+ * <img>/<audio>/<video>/<embed> 元素无法携带 Authorization 头,与终端
13
+ * WebSocket 的 query token 同口径)。仅当 Authorization 头缺失/无效时才
14
+ * 检查 query token——头优先,不会因路径匹配而放行任意 query token。 */
15
+ queryTokenPath?: (path: string) => boolean;
7
16
  };
8
17
  /**
9
18
  * Bearer token 认证中间件(spec §24.2「认证」)。
10
19
  *
11
- * - 未配置 token(undefined/空)→ 放行,适配本地开发场景。
20
+ * - 未配置 token(undefined/空)→ 放行,适配本地开发场景;但对「写」请求校验
21
+ * Origin(仅本地回环 + allowedOrigins),补上 authEnabled=false 时 CORS 不设防的盲写面。
12
22
  * - 配置了 token → 校验 `Authorization: Bearer <token>`,不匹配返回 401。
13
23
  * - verify 提供时(P2-16 设备 token)→ 以 verify 结果为准。
14
24
  * - publicPaths 中的路径始终放行(如健康检查、认证/配对引导端点)。
@@ -1,8 +1,10 @@
1
+ import { isAllowedOrigin } from './cors.js';
1
2
  import { apiError } from './error.js';
2
3
  /**
3
4
  * Bearer token 认证中间件(spec §24.2「认证」)。
4
5
  *
5
- * - 未配置 token(undefined/空)→ 放行,适配本地开发场景。
6
+ * - 未配置 token(undefined/空)→ 放行,适配本地开发场景;但对「写」请求校验
7
+ * Origin(仅本地回环 + allowedOrigins),补上 authEnabled=false 时 CORS 不设防的盲写面。
6
8
  * - 配置了 token → 校验 `Authorization: Bearer <token>`,不匹配返回 401。
7
9
  * - verify 提供时(P2-16 设备 token)→ 以 verify 结果为准。
8
10
  * - publicPaths 中的路径始终放行(如健康检查、认证/配对引导端点)。
@@ -10,8 +12,19 @@ import { apiError } from './error.js';
10
12
  export function createAuthMiddleware(token, opts = {}) {
11
13
  const expected = token && token.length > 0 ? `Bearer ${token}` : '';
12
14
  const publicPaths = new Set(opts.publicPaths ?? ['/api/health']);
15
+ const queryTokenPath = opts.queryTokenPath;
13
16
  return async (c, next) => {
14
17
  if (!expected && !opts.verify) {
18
+ // 认证关闭:无 token 可验证。带 Origin 的非安全方法(浏览器跨域写)需显式放行
19
+ // 本地/已配置 origin;无 Origin 的请求(curl/CLI/脚本/服务间调用)不受影响。
20
+ const method = c.req.method;
21
+ const stateChanging = method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
22
+ if (stateChanging) {
23
+ const origin = c.req.header('Origin') ?? '';
24
+ if (origin.length > 0 && !isAllowedOrigin(origin, opts.allowedOrigins)) {
25
+ return apiError(c, 403, 'ORIGIN_FORBIDDEN', 'Cross-origin write rejected while authentication is disabled');
26
+ }
27
+ }
15
28
  await next();
16
29
  return;
17
30
  }
@@ -19,8 +32,17 @@ export function createAuthMiddleware(token, opts = {}) {
19
32
  await next();
20
33
  return;
21
34
  }
22
- const auth = c.req.header('Authorization') ?? '';
23
- const ok = opts.verify ? opts.verify(auth.replace(/^Bearer\s+/i, '')) : auth === expected;
35
+ const headerAuth = c.req.header('Authorization') ?? '';
36
+ let ok = opts.verify
37
+ ? opts.verify(headerAuth.replace(/^Bearer\s+/i, ''))
38
+ : headerAuth === expected;
39
+ // P1 媒体预览:浏览器媒体元素无 Authorization 头——对判定命中的路径尝试
40
+ // query token 认证。头已有效则不再检查(头优先);路径不匹配时 query token
41
+ // 不参与判定,仅在白名单路径上接受 query 认证。
42
+ if (!ok && queryTokenPath?.(c.req.path)) {
43
+ const queryToken = c.req.query('token') ?? '';
44
+ ok = opts.verify ? opts.verify(queryToken) : `Bearer ${queryToken}` === expected;
45
+ }
24
46
  if (!ok) {
25
47
  return apiError(c, 401, 'UNAUTHORIZED', 'Missing or invalid bearer token');
26
48
  }
@@ -3,16 +3,24 @@ import type { PermissionRequest, PermissionStore } from './store.js';
3
3
  export type { PermissionRequest };
4
4
  type PermissionMode = 'default' | 'auto';
5
5
  type InteractivePermissionCheckerOptions = {
6
- /** 始终允许的工具名(覆盖 permission 字段)。 */
7
- alwaysAllow?: string[];
6
+ /** 始终允许的工具名(覆盖 permission 字段)。可为 getter:会话级「始终允许」
7
+ * 白名单在 run 期间可被用户追加,getter 使每次 check 读取最新集合。 */
8
+ alwaysAllow?: string[] | (() => string[]);
8
9
  /** 始终拒绝的工具名(覆盖 permission 字段)。 */
9
10
  alwaysDeny?: string[];
10
11
  /** 读取当前授权模式:'auto' 时跳过 ask 交互确认(YOLO 自动授权)。 */
11
12
  getMode?: () => PermissionMode;
12
13
  /** 遇到 ask 权限时调用(用于通知前端)。 */
13
14
  onPermissionRequired?: (request: PermissionRequest) => void | Promise<void>;
14
- /** 权限确认超时被自动拒绝时调用(P1-6:SSE 通知前端)。 */
15
+ /** 首层确认超时(仅提示,pending 保持)时调用(P1-6:SSE 通知前端)。 */
15
16
  onPermissionTimeout?: (request: PermissionRequest) => void;
17
+ /** 兜底过期(提示后仍无响应,自动拒绝)时调用(P0 双层超时:前端清理弹窗状态)。 */
18
+ onPermissionExpired?: (request: PermissionRequest) => void;
19
+ /** 权限请求「报告归属」的会话 id。缺省取 tool context 的会话——子 agent 的 ask
20
+ * 请求会挂在各自子会话上,用户所在的主/工作流会话按 sessionId 重挂 pending
21
+ * 弹窗时找不到。传入发起 run 的用户可见会话 id,使 GET /api/permissions/:id/pending
22
+ * 能从该会话恢复全部子 agent 的挂起请求。 */
23
+ reportSessionId?: string;
16
24
  };
17
25
  /** 阻塞式权限检查器:ask 权限会阻塞等待用户确认。
18
26
  * pending 存在全局 PermissionStore 中,独立于 agent run 生命周期。 */
@@ -1,13 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  function createInteractivePermissionChecker(store, opts = {}) {
3
- const allowSet = new Set(opts.alwaysAllow ?? []);
3
+ const allowSet = opts.alwaysAllow
4
+ ? Array.isArray(opts.alwaysAllow)
5
+ ? new Set(opts.alwaysAllow)
6
+ : null
7
+ : null;
4
8
  const denySet = new Set(opts.alwaysDeny ?? []);
5
9
  return {
6
10
  check: async (tool, input, ctx) => {
7
11
  if (denySet.has(tool.name)) {
8
12
  return { _tag: 'deny', reason: `Tool "${tool.name}" is denied by configuration` };
9
13
  }
10
- if (allowSet.has(tool.name) || tool.permission === 'auto') {
14
+ // 静态集合或动态 getter(会话级白名单可在 run 期间追加)
15
+ const dynamicAllowed = allowSet === null && typeof opts.alwaysAllow === 'function'
16
+ ? opts.alwaysAllow().includes(tool.name)
17
+ : allowSet?.has(tool.name);
18
+ if (dynamicAllowed || tool.permission === 'auto') {
11
19
  return { _tag: 'allow' };
12
20
  }
13
21
  if (tool.permission === 'deny') {
@@ -21,15 +29,26 @@ function createInteractivePermissionChecker(store, opts = {}) {
21
29
  }
22
30
  // 交互式确认
23
31
  const toolCallId = randomUUID();
24
- const request = { toolCallId, tool: tool.name, input };
32
+ const reportedSessionId = opts.reportSessionId ?? ctx.session?.id;
33
+ const request = {
34
+ toolCallId,
35
+ tool: tool.name,
36
+ input,
37
+ // P1:绑定会话 id——挂起期间用户切换页面后,前端经
38
+ // GET /api/permissions/:sessionId/pending 重挂确认弹窗。
39
+ // 子 agent 请求按 reportSessionId 归属到发起 run 的用户可见会话,
40
+ // 而不是各自的子会话(用户不会停留在子会话页面上等确认)。
41
+ ...(reportedSessionId ? { sessionId: reportedSessionId } : {}),
42
+ };
25
43
  const promise = new Promise((resolve) => {
26
44
  // pending 注册到全局 store,confirm 端点按 toolCallId 直接寻址,
27
45
  // 不依赖当前 agent run 是否仍在 agentManager 中注册。
28
- // store 内置超时:到期自动 deny 并清理条目(防永久挂起 + Map 泄漏)。
46
+ // store 内置双层超时:首层提示(pending 保持),宽限期后兜底 deny。
29
47
  store.register(toolCallId, {
30
48
  request,
31
49
  resolve,
32
50
  ...(opts.onPermissionTimeout ? { onTimeout: opts.onPermissionTimeout } : {}),
51
+ ...(opts.onPermissionExpired ? { onExpired: opts.onPermissionExpired } : {}),
33
52
  });
34
53
  });
35
54
  await opts.onPermissionRequired?.(request);
@@ -2,23 +2,31 @@ import type { PermissionResult } from '../../tools/types.js';
2
2
  /** 默认 pending 超时时长:5 分钟(300_000ms)。
3
3
  * 超过后自动 resolve 为 deny 并清理 store 条目,避免 promise 永久挂起与 pending Map 泄漏。 */
4
4
  declare const DEFAULT_PERMISSION_TIMEOUT_MS: number;
5
+ /** 首层超时(仅提示)后到兜底自动拒绝的宽限期:25 分钟。双层超时总窗口 30 分钟。 */
6
+ declare const DEFAULT_PERMISSION_EXPIRE_GRACE_MS: number;
5
7
  /** 权限请求(需要用户确认)。 */
6
8
  type PermissionRequest = {
7
9
  toolCallId: string;
8
10
  tool: string;
9
11
  input: unknown;
12
+ /** 请求所属会话(P1:跨页面挂起恢复用;无会话上下文的调用方省略)。 */
13
+ sessionId?: string;
10
14
  };
11
15
  /** 待处理的权限确认。 */
12
16
  type PendingPermission = {
13
17
  request: PermissionRequest;
14
18
  resolve: (result: PermissionResult) => void;
15
- /** 该请求超时自动拒绝时的回调(P1-6:按请求绑定 SSE 流)。 */
19
+ /** 首层超时回调(P1-6:按请求绑定 SSE 流,仅提示不终结)。 */
16
20
  onTimeout?: (request: PermissionRequest) => void;
21
+ /** 兜底过期回调(P0 双层超时:提示后仍无响应 → 自动拒绝,通知前端清理弹窗状态)。 */
22
+ onExpired?: (request: PermissionRequest) => void;
17
23
  };
18
24
  /** createPermissionStore 的配置。 */
19
25
  type PermissionStoreOptions = {
20
26
  /** pending 超时毫秒数;省略时取 DEFAULT_PERMISSION_TIMEOUT_MS(5 分钟)。 */
21
27
  timeoutMs?: number;
28
+ /** 首层超时后到兜底自动拒绝的宽限毫秒数;省略时 25 分钟。 */
29
+ expireGraceMs?: number;
22
30
  /** 超时自动拒绝时回调(P1-6:通知前端「确认超时已拒绝」,提供重新询问入口)。 */
23
31
  onTimeout?: (request: PermissionRequest) => void;
24
32
  };
@@ -28,9 +36,13 @@ type PermissionStore = {
28
36
  resolve(toolCallId: string, approved: boolean): boolean;
29
37
  has(toolCallId: string): boolean;
30
38
  size(): number;
39
+ /** 查询某会话当前挂起的权限请求(P1:前端重挂弹窗;无则 null)。 */
40
+ pendingForSession(sessionId: string): PermissionRequest | null;
41
+ /** P3:统计指定会话集合内的挂起请求数(update 影响面按受影响会话过滤用)。 */
42
+ countForSessions(sessionIds: ReadonlySet<string>): number;
31
43
  /** settle 所有 pending 为 deny 并清空(dev 热重载重建前调用)。 */
32
44
  dispose(): void;
33
45
  };
34
46
  declare function createPermissionStore(opts?: PermissionStoreOptions): PermissionStore;
35
47
  export type { PendingPermission, PermissionRequest, PermissionStore, PermissionStoreOptions };
36
- export { createPermissionStore, DEFAULT_PERMISSION_TIMEOUT_MS };
48
+ export { createPermissionStore, DEFAULT_PERMISSION_EXPIRE_GRACE_MS, DEFAULT_PERMISSION_TIMEOUT_MS };
@@ -7,8 +7,11 @@
7
7
  /** 默认 pending 超时时长:5 分钟(300_000ms)。
8
8
  * 超过后自动 resolve 为 deny 并清理 store 条目,避免 promise 永久挂起与 pending Map 泄漏。 */
9
9
  const DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
10
+ /** 首层超时(仅提示)后到兜底自动拒绝的宽限期:25 分钟。双层超时总窗口 30 分钟。 */
11
+ const DEFAULT_PERMISSION_EXPIRE_GRACE_MS = 25 * 60 * 1000;
10
12
  function createPermissionStore(opts = {}) {
11
13
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS;
14
+ const expireGraceMs = opts.expireGraceMs ?? DEFAULT_PERMISSION_EXPIRE_GRACE_MS;
12
15
  const pending = new Map();
13
16
  // 统一终结路径:clearTimeout → 删除条目 → resolve。
14
17
  // 三处调用(confirm、超时回调、abort 联动)都汇聚于此:
@@ -28,9 +31,27 @@ function createPermissionStore(opts = {}) {
28
31
  const existing = pending.get(toolCallId);
29
32
  if (existing)
30
33
  clearTimeout(existing.timer);
34
+ // P0 双层超时:首层仅提示(pending 保持,用户可「重新询问」);提示后仍无响应
35
+ // 则宽限期后兜底自动拒绝——否则用户离开页面后 run 被永久阻塞、会话 409 锁死。
36
+ // 无 onTimeout(非交互式 store)保留旧的单层自动拒绝语义。
31
37
  const timer = setTimeout(() => {
32
- settle(toolCallId, { _tag: 'deny', reason: 'Permission request timed out' });
33
- entry.onTimeout?.(entry.request);
38
+ if (!entry.onTimeout) {
39
+ settle(toolCallId, { _tag: 'deny', reason: 'Permission request timed out' });
40
+ return;
41
+ }
42
+ entry.onTimeout(entry.request);
43
+ // 首层提示后:重新挂宽限定时器(句柄回写 entry,settle 才能清理它)
44
+ const expireTimer = setTimeout(() => {
45
+ const cur = pending.get(toolCallId);
46
+ if (!cur)
47
+ return;
48
+ pending.set(toolCallId, { ...cur, timer: expireTimer });
49
+ cur.onExpired?.(cur.request);
50
+ settle(toolCallId, { _tag: 'deny', reason: 'Permission request expired' });
51
+ }, expireGraceMs);
52
+ const cur = pending.get(toolCallId);
53
+ if (cur)
54
+ pending.set(toolCallId, { ...cur, timer: expireTimer });
34
55
  }, timeoutMs);
35
56
  pending.set(toolCallId, { ...entry, timer });
36
57
  },
@@ -43,6 +64,21 @@ function createPermissionStore(opts = {}) {
43
64
  size() {
44
65
  return pending.size;
45
66
  },
67
+ pendingForSession(sessionId) {
68
+ for (const p of pending.values()) {
69
+ if (p.request.sessionId === sessionId)
70
+ return p.request;
71
+ }
72
+ return null;
73
+ },
74
+ countForSessions(sessionIds) {
75
+ let n = 0;
76
+ for (const p of pending.values()) {
77
+ if (p.request.sessionId && sessionIds.has(p.request.sessionId))
78
+ n += 1;
79
+ }
80
+ return n;
81
+ },
46
82
  dispose() {
47
83
  // dev 热重载重建前调用:所有 pending settle 为 deny(避免悬空 Promise +
48
84
  // timer 泄漏),clearTimeout 后清空 Map。
@@ -54,4 +90,4 @@ function createPermissionStore(opts = {}) {
54
90
  },
55
91
  };
56
92
  }
57
- export { createPermissionStore, DEFAULT_PERMISSION_TIMEOUT_MS };
93
+ export { createPermissionStore, DEFAULT_PERMISSION_EXPIRE_GRACE_MS, DEFAULT_PERMISSION_TIMEOUT_MS };
@@ -0,0 +1,14 @@
1
+ import { type Registry } from '../llm/registry.js';
2
+ import type { Config } from '../shared/types/config.js';
3
+ import type { ProviderConfig } from '../shared/types/llm.js';
4
+ /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
5
+ declare function buildRegistryFromConfig(config: Config): Registry;
6
+ declare function registerProviderFromConfig(registry: Registry, p: ProviderConfig): void;
7
+ /**
8
+ * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
9
+ * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
10
+ * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
11
+ * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
12
+ */
13
+ declare function syncRegistryFromConfig(registry: Registry, config: Config): void;
14
+ export { buildRegistryFromConfig, registerProviderFromConfig, syncRegistryFromConfig };
@@ -0,0 +1,44 @@
1
+ // src/server/registry-config.ts
2
+ // config.providers ↔ LLM registry 的构建/同步逻辑(P1-1 从 server.ts 拆出,
3
+ // 供 chat 路由按项目配置构建注册表,避免 routes → server.ts 循环依赖)。
4
+ import { decryptSecret } from '../core/secret.js';
5
+ import { createRegistry, overrideToCapabilities, rebuildRegistry, registerProvider, } from '../llm/registry.js';
6
+ /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
7
+ function buildRegistryFromConfig(config) {
8
+ const registry = createRegistry();
9
+ for (const p of config.providers) {
10
+ registerProviderFromConfig(registry, p);
11
+ }
12
+ return registry;
13
+ }
14
+ function registerProviderFromConfig(registry, p) {
15
+ // 兼容 config.json 中以 _tag 标识 provider 的格式(name 缺失时回退到 _tag)
16
+ const name = p.name || p._tag;
17
+ if (!name || !p.baseURL)
18
+ return;
19
+ // baseURL 已含 /v1 时用 /chat/completions,避免 /v1/v1 双重前缀
20
+ const path = p.baseURL.replace(/\/+$/, '').endsWith('/v1') ? '/chat/completions' : undefined;
21
+ registerProvider(registry, {
22
+ name,
23
+ baseURL: p.baseURL,
24
+ apiKey: p.apiKey ? decryptSecret(p.apiKey) : p.apiKey,
25
+ ...(path ? { path } : {}),
26
+ // 传递用户配置的 per-model capabilities(contextWindow 等),
27
+ // 否则 resolveRoute 回退到 DEFAULT_MODEL_CAPABILITIES,可能导致预算过小。
28
+ ...(p.models ? { models: overrideToCapabilities(p.models) } : {}),
29
+ });
30
+ }
31
+ /**
32
+ * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
33
+ * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
34
+ * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
35
+ * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
36
+ */
37
+ function syncRegistryFromConfig(registry, config) {
38
+ rebuildRegistry(registry, (next) => {
39
+ for (const p of config.providers) {
40
+ registerProviderFromConfig(next, p);
41
+ }
42
+ });
43
+ }
44
+ export { buildRegistryFromConfig, registerProviderFromConfig, syncRegistryFromConfig };