c0de-agent 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -6
- package/dist/cli/commands/acp.js +3 -3
- package/dist/cli/commands/auth.d.ts +17 -0
- package/dist/cli/commands/auth.js +73 -0
- package/dist/cli/commands/chat.js +95 -1
- package/dist/cli/commands/config.d.ts +2 -9
- package/dist/cli/commands/config.js +25 -54
- package/dist/cli/commands/init.js +4 -2
- package/dist/cli/commands/plugin.js +8 -4
- package/dist/cli/commands/sessions.d.ts +10 -0
- package/dist/cli/commands/sessions.js +94 -0
- package/dist/cli/commands/update.js +18 -10
- package/dist/cli/index.js +51 -4
- package/dist/cli/modes/print.js +19 -2
- package/dist/core/compact.d.ts +2 -0
- package/dist/core/compact.js +1 -0
- package/dist/core/config-path.d.ts +9 -0
- package/dist/core/config-path.js +48 -0
- package/dist/core/config.d.ts +20 -3
- package/dist/core/config.js +103 -14
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/loop/compaction.d.ts +2 -1
- package/dist/core/loop/compaction.js +19 -6
- package/dist/core/loop/stream-collect.js +9 -1
- package/dist/core/loop/subagent.js +23 -5
- package/dist/core/loop.js +13 -3
- package/dist/core/redact.d.ts +11 -0
- package/dist/core/redact.js +49 -0
- package/dist/core/slash.js +167 -63
- package/dist/core/title.js +22 -1
- package/dist/core/types.d.ts +4 -0
- package/dist/db/client.d.ts +4 -0
- package/dist/db/client.js +5 -0
- package/dist/db/schema.d.ts +51 -0
- package/dist/db/schema.js +6 -0
- package/dist/kanban/index.d.ts +1 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +5 -1
- package/dist/kanban/store.js +76 -2
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/provider.js +1 -1
- package/dist/llm/retry.d.ts +5 -2
- package/dist/llm/retry.js +6 -5
- package/dist/llm/routing.d.ts +24 -1
- package/dist/llm/routing.js +29 -1
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +14 -0
- package/dist/project/project.js +77 -1
- package/dist/server/agent-manager.d.ts +14 -0
- package/dist/server/agent-manager.js +41 -0
- package/dist/server/app.js +12 -2
- package/dist/server/auth-manager.d.ts +85 -0
- package/dist/server/auth-manager.js +289 -0
- package/dist/server/context.js +4 -0
- package/dist/server/middleware/auth.d.ts +4 -3
- package/dist/server/middleware/auth.js +5 -5
- package/dist/server/permission/interactive.d.ts +7 -2
- package/dist/server/permission/interactive.js +25 -5
- package/dist/server/permission/store.d.ts +15 -1
- package/dist/server/permission/store.js +31 -2
- package/dist/server/registry-config.d.ts +14 -0
- package/dist/server/registry-config.js +44 -0
- package/dist/server/routes/auth.d.ts +4 -0
- package/dist/server/routes/auth.js +124 -0
- package/dist/server/routes/chat.d.ts +18 -1
- package/dist/server/routes/chat.js +295 -37
- package/dist/server/routes/commands.d.ts +4 -2
- package/dist/server/routes/commands.js +10 -3
- package/dist/server/routes/config.js +143 -15
- package/dist/server/routes/files.js +22 -4
- package/dist/server/routes/kanban.js +68 -6
- package/dist/server/routes/permissions.d.ts +0 -1
- package/dist/server/routes/permissions.js +153 -4
- package/dist/server/routes/project.js +106 -3
- package/dist/server/routes/provider.js +48 -3
- package/dist/server/routes/session.js +319 -19
- package/dist/server/routes/terminal.js +17 -2
- package/dist/server/routes/update.d.ts +11 -5
- package/dist/server/routes/update.js +98 -16
- package/dist/server/server.d.ts +4 -12
- package/dist/server/server.js +235 -100
- package/dist/server/types.d.ts +15 -1
- package/dist/session/archive.d.ts +4 -2
- package/dist/session/archive.js +13 -1
- package/dist/session/branch.d.ts +1 -0
- package/dist/session/branch.js +32 -7
- package/dist/session/import.d.ts +44 -0
- package/dist/session/import.js +83 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/jobs.d.ts +12 -0
- package/dist/session/jobs.js +53 -0
- package/dist/session/message.d.ts +7 -1
- package/dist/session/message.js +21 -1
- package/dist/session/session.d.ts +143 -6
- package/dist/session/session.js +561 -11
- package/dist/session/snapshot.d.ts +5 -2
- package/dist/session/snapshot.js +10 -2
- package/dist/session/types.d.ts +12 -2
- package/dist/shared/types/agent.d.ts +22 -0
- package/dist/shared/types/config.d.ts +10 -1
- package/dist/shared/types/kanban.d.ts +13 -0
- package/dist/shared/types/message.d.ts +15 -2
- package/dist/tools/index.js +2 -3
- package/dist/tools/websearch/index.js +6 -3
- package/dist/update/index.d.ts +3 -3
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +19 -2
- package/dist/update/ipc.js +43 -6
- package/dist/update/snapshot.d.ts +5 -0
- package/dist/update/snapshot.js +10 -0
- package/dist/update/updater.d.ts +69 -8
- package/dist/update/updater.js +263 -32
- package/drizzle/0006_sour_susan_delgado.sql +2 -0
- package/drizzle/0007_same_wilson_fisk.sql +2 -0
- package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
- package/drizzle/meta/0006_snapshot.json +867 -0
- package/drizzle/meta/0007_snapshot.json +869 -0
- package/drizzle/meta/0008_snapshot.json +875 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +41 -42
|
@@ -0,0 +1,289 @@
|
|
|
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, statSync, watch, writeFileSync, } from 'node:fs';
|
|
13
|
+
import { basename, dirname, join } from 'node:path';
|
|
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;
|
|
18
|
+
function hashToken(token) {
|
|
19
|
+
return createHash('sha256').update(token).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
function safeEqual(a, b) {
|
|
22
|
+
const ba = Buffer.from(a);
|
|
23
|
+
const bb = Buffer.from(b);
|
|
24
|
+
if (ba.length !== bb.length)
|
|
25
|
+
return false;
|
|
26
|
+
return timingSafeEqual(ba, bb);
|
|
27
|
+
}
|
|
28
|
+
export function createAuthManager(opts) {
|
|
29
|
+
const { dataDir, staticToken, tokenFilePath, now = Date.now } = opts;
|
|
30
|
+
const pairingTtlMs = opts.pairingTtlMs ?? 10 * 60 * 1000;
|
|
31
|
+
const firstDeviceTtlMs = opts.firstDeviceTtlMs ?? DEFAULT_FIRST_DEVICE_TTL_MS;
|
|
32
|
+
const tokenPath = tokenFilePath ?? join(dataDir, 'auth-token');
|
|
33
|
+
const devicesPath = join(dataDir, DEVICES_FILENAME);
|
|
34
|
+
// ── 状态加载 ──
|
|
35
|
+
let bootstrap = staticToken && staticToken.length > 0 ? staticToken : readBootstrapToken(tokenPath);
|
|
36
|
+
const devices = new Map();
|
|
37
|
+
loadDevices();
|
|
38
|
+
const pending = new Map();
|
|
39
|
+
function readBootstrapToken(path) {
|
|
40
|
+
try {
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return undefined;
|
|
43
|
+
const t = readFileSync(path, 'utf-8').trim();
|
|
44
|
+
return t.length > 0 ? t : undefined;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function loadDevices() {
|
|
51
|
+
devices.clear();
|
|
52
|
+
try {
|
|
53
|
+
if (!existsSync(devicesPath))
|
|
54
|
+
return;
|
|
55
|
+
const raw = JSON.parse(readFileSync(devicesPath, 'utf-8'));
|
|
56
|
+
for (const d of raw.devices ?? []) {
|
|
57
|
+
if (d && typeof d.id === 'string' && typeof d.tokenHash === 'string') {
|
|
58
|
+
devices.set(d.id, d);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// 注册表损坏:视为空(设备需重新配对/注册)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// 热重载:CLI `c0de auth revoke/reset` 或手工编辑 devices.json 后立即生效。
|
|
67
|
+
// 监听目录而非文件——文件可能尚不存在(首启),persist/revoke 会创建它。
|
|
68
|
+
// 自身 persist 写入也会触发一次无伤重载。
|
|
69
|
+
let watcher;
|
|
70
|
+
try {
|
|
71
|
+
watcher = watch(dirname(devicesPath), (_event, filename) => {
|
|
72
|
+
if (filename === basename(devicesPath))
|
|
73
|
+
loadDevices();
|
|
74
|
+
});
|
|
75
|
+
// 不让 watcher 独占事件循环(测试/短生命周期进程可正常退出)
|
|
76
|
+
watcher.unref();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// 目录不可 watch(极端环境):降级为仅启动时加载
|
|
80
|
+
}
|
|
81
|
+
function persist() {
|
|
82
|
+
try {
|
|
83
|
+
if (!existsSync(dataDir))
|
|
84
|
+
mkdirSync(dataDir, { recursive: true });
|
|
85
|
+
const file = {
|
|
86
|
+
version: 1,
|
|
87
|
+
devices: Array.from(devices.values()),
|
|
88
|
+
};
|
|
89
|
+
writeFileSync(devicesPath, JSON.stringify(file, null, 2), 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// 持久化失败不致命(内存态继续有效,重启后需重新注册)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** 轮换 bootstrap:写入新 token 文件并保留历史(handoff 用)。 */
|
|
96
|
+
function rotateBootstrap() {
|
|
97
|
+
if (staticToken && staticToken.length > 0)
|
|
98
|
+
return; // 静态模式不轮换
|
|
99
|
+
bootstrap = randomBytes(32).toString('hex');
|
|
100
|
+
try {
|
|
101
|
+
if (!existsSync(dataDir))
|
|
102
|
+
mkdirSync(dataDir, { recursive: true });
|
|
103
|
+
writeFileSync(tokenPath, bootstrap, 'utf-8');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// 写文件失败:内存 token 仍已轮换,进程内一致
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function verifyDeviceToken(token) {
|
|
110
|
+
const h = hashToken(token);
|
|
111
|
+
for (const d of devices.values()) {
|
|
112
|
+
if (safeEqual(d.tokenHash, h))
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
function verifyBootstrap(token) {
|
|
118
|
+
if (!token || !bootstrap)
|
|
119
|
+
return false;
|
|
120
|
+
return safeEqual(token, bootstrap);
|
|
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
|
+
}
|
|
133
|
+
function cleanExpiredPairings() {
|
|
134
|
+
const cutoff = now() - pairingTtlMs;
|
|
135
|
+
for (const [id, p] of pending) {
|
|
136
|
+
if (p.status === 'pending' && p.createdAt <= cutoff) {
|
|
137
|
+
pending.delete(id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
get bootstrap() {
|
|
143
|
+
return bootstrap;
|
|
144
|
+
},
|
|
145
|
+
verify(token) {
|
|
146
|
+
if (!token)
|
|
147
|
+
return false;
|
|
148
|
+
if (staticToken && staticToken.length > 0)
|
|
149
|
+
return safeEqual(token, staticToken);
|
|
150
|
+
return verifyDeviceToken(token);
|
|
151
|
+
},
|
|
152
|
+
verifyHandoff(token) {
|
|
153
|
+
if (!token)
|
|
154
|
+
return false;
|
|
155
|
+
if (verifyDeviceToken(token))
|
|
156
|
+
return true;
|
|
157
|
+
if (verifyBootstrap(token))
|
|
158
|
+
return true;
|
|
159
|
+
return false;
|
|
160
|
+
},
|
|
161
|
+
async registerFirstDevice(bootstrapToken, deviceName) {
|
|
162
|
+
if (staticToken && staticToken.length > 0)
|
|
163
|
+
return null; // 静态模式无设备注册
|
|
164
|
+
if (!verifyBootstrap(bootstrapToken))
|
|
165
|
+
return null;
|
|
166
|
+
// 已有设备 → bootstrap 已失效,不得再凭它注册(需配对审批)
|
|
167
|
+
if (devices.size > 0)
|
|
168
|
+
return null;
|
|
169
|
+
// 首设备注册窗口:bootstrap 超过 firstDeviceTtlMs 后拒绝(缩短先到先得竞态窗口)。
|
|
170
|
+
if (firstDeviceTtlMs > 0) {
|
|
171
|
+
const age = bootstrapAge();
|
|
172
|
+
if (age != null && age > firstDeviceTtlMs)
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const deviceToken = randomBytes(32).toString('hex');
|
|
176
|
+
const record = {
|
|
177
|
+
id: randomBytes(16).toString('hex'),
|
|
178
|
+
name: deviceName || '设备',
|
|
179
|
+
tokenHash: hashToken(deviceToken),
|
|
180
|
+
createdAt: now(),
|
|
181
|
+
};
|
|
182
|
+
devices.set(record.id, record);
|
|
183
|
+
persist();
|
|
184
|
+
rotateBootstrap(); // 注册成功即轮换,旧 bootstrap(URL/shell history 中)立即失效
|
|
185
|
+
return deviceToken;
|
|
186
|
+
},
|
|
187
|
+
requestPairing(deviceName, source) {
|
|
188
|
+
if (staticToken && staticToken.length > 0)
|
|
189
|
+
return null;
|
|
190
|
+
cleanExpiredPairings();
|
|
191
|
+
// 限流:最多 10 个待审批;同一来源最多 3 个(防本地/远程进程刷满队列,
|
|
192
|
+
// 使合法设备 10 分钟内无法配对)。source 尽力而为,缺省视为 'unknown'。
|
|
193
|
+
const pendingCount = Array.from(pending.values()).filter((p) => p.status === 'pending').length;
|
|
194
|
+
if (pendingCount >= 10)
|
|
195
|
+
return null;
|
|
196
|
+
const src = source ?? 'unknown';
|
|
197
|
+
const perSource = Array.from(pending.values()).filter((p) => p.status === 'pending' && p.source === src).length;
|
|
198
|
+
if (perSource >= 3)
|
|
199
|
+
return null;
|
|
200
|
+
const pairingId = randomBytes(16).toString('hex');
|
|
201
|
+
const code = String(100000 + Math.floor(Math.random() * 900000)); // 6 位数字
|
|
202
|
+
pending.set(pairingId, {
|
|
203
|
+
pairingId,
|
|
204
|
+
deviceName: deviceName || '新设备',
|
|
205
|
+
code,
|
|
206
|
+
createdAt: now(),
|
|
207
|
+
status: 'pending',
|
|
208
|
+
source: src,
|
|
209
|
+
});
|
|
210
|
+
return { pairingId, code };
|
|
211
|
+
},
|
|
212
|
+
pairingStatus(pairingId) {
|
|
213
|
+
const p = pending.get(pairingId);
|
|
214
|
+
if (!p)
|
|
215
|
+
return { status: 'not_found' };
|
|
216
|
+
if (p.status === 'approved')
|
|
217
|
+
return { status: 'approved', deviceToken: p.deviceToken ?? '' };
|
|
218
|
+
if (p.status === 'denied')
|
|
219
|
+
return { status: 'denied' };
|
|
220
|
+
if (p.createdAt <= now() - pairingTtlMs) {
|
|
221
|
+
pending.delete(pairingId);
|
|
222
|
+
return { status: 'not_found' };
|
|
223
|
+
}
|
|
224
|
+
return { status: 'pending' };
|
|
225
|
+
},
|
|
226
|
+
listPairings() {
|
|
227
|
+
cleanExpiredPairings();
|
|
228
|
+
return Array.from(pending.values())
|
|
229
|
+
.filter((p) => p.status === 'pending')
|
|
230
|
+
.map((p) => ({
|
|
231
|
+
pairingId: p.pairingId,
|
|
232
|
+
deviceName: p.deviceName,
|
|
233
|
+
code: p.code,
|
|
234
|
+
createdAt: p.createdAt,
|
|
235
|
+
source: p.source,
|
|
236
|
+
}));
|
|
237
|
+
},
|
|
238
|
+
approvePairing(pairingId) {
|
|
239
|
+
const p = pending.get(pairingId);
|
|
240
|
+
if (!p)
|
|
241
|
+
return false;
|
|
242
|
+
if (p.status !== 'pending')
|
|
243
|
+
return false;
|
|
244
|
+
if (p.createdAt <= now() - pairingTtlMs) {
|
|
245
|
+
pending.delete(pairingId);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
const deviceToken = randomBytes(32).toString('hex');
|
|
249
|
+
const record = {
|
|
250
|
+
id: randomBytes(16).toString('hex'),
|
|
251
|
+
name: p.deviceName,
|
|
252
|
+
tokenHash: hashToken(deviceToken),
|
|
253
|
+
createdAt: now(),
|
|
254
|
+
};
|
|
255
|
+
devices.set(record.id, record);
|
|
256
|
+
p.status = 'approved';
|
|
257
|
+
p.deviceToken = deviceToken;
|
|
258
|
+
persist();
|
|
259
|
+
return true;
|
|
260
|
+
},
|
|
261
|
+
denyPairing(pairingId) {
|
|
262
|
+
const p = pending.get(pairingId);
|
|
263
|
+
if (!p)
|
|
264
|
+
return false;
|
|
265
|
+
if (p.status !== 'pending')
|
|
266
|
+
return false;
|
|
267
|
+
p.status = 'denied';
|
|
268
|
+
return true;
|
|
269
|
+
},
|
|
270
|
+
listDevices() {
|
|
271
|
+
return Array.from(devices.values()).map((d) => ({
|
|
272
|
+
id: d.id,
|
|
273
|
+
name: d.name,
|
|
274
|
+
createdAt: d.createdAt,
|
|
275
|
+
}));
|
|
276
|
+
},
|
|
277
|
+
revokeDevice(id) {
|
|
278
|
+
const ok = devices.delete(id);
|
|
279
|
+
if (ok)
|
|
280
|
+
persist();
|
|
281
|
+
return ok;
|
|
282
|
+
},
|
|
283
|
+
dispose() {
|
|
284
|
+
watcher?.close();
|
|
285
|
+
watcher = undefined;
|
|
286
|
+
},
|
|
287
|
+
persist,
|
|
288
|
+
};
|
|
289
|
+
}
|
package/dist/server/context.js
CHANGED
|
@@ -35,6 +35,10 @@ function createServerContext(opts) {
|
|
|
35
35
|
agentManager: createAgentManager(),
|
|
36
36
|
permissionStore: createPermissionStore(),
|
|
37
37
|
permissionMode: config.permission.defaultMode,
|
|
38
|
+
sessionPermissionModes: new Map(),
|
|
39
|
+
sessionAlwaysAllow: new Map(),
|
|
40
|
+
// P1-1:项目级 LLM 注册表缓存(按项目合并配置构建,chat 路由按需创建)。
|
|
41
|
+
projectRegistries: new Map(),
|
|
38
42
|
// 测试工厂不解析/生成 token(不触碰用户全局数据目录)。
|
|
39
43
|
authToken: undefined,
|
|
40
44
|
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
|
-
* -
|
|
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
|
-
* -
|
|
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
|
-
|
|
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();
|
|
@@ -3,14 +3,19 @@ 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
|
-
|
|
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>;
|
|
15
|
+
/** 首层确认超时(仅提示,pending 保持)时调用(P1-6:SSE 通知前端)。 */
|
|
16
|
+
onPermissionTimeout?: (request: PermissionRequest) => void;
|
|
17
|
+
/** 兜底过期(提示后仍无响应,自动拒绝)时调用(P0 双层超时:前端清理弹窗状态)。 */
|
|
18
|
+
onPermissionExpired?: (request: PermissionRequest) => void;
|
|
14
19
|
};
|
|
15
20
|
/** 阻塞式权限检查器:ask 权限会阻塞等待用户确认。
|
|
16
21
|
* pending 存在全局 PermissionStore 中,独立于 agent run 生命周期。 */
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
function createInteractivePermissionChecker(store, opts = {}) {
|
|
3
|
-
const allowSet =
|
|
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
|
-
|
|
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,12 +29,24 @@ function createInteractivePermissionChecker(store, opts = {}) {
|
|
|
21
29
|
}
|
|
22
30
|
// 交互式确认
|
|
23
31
|
const toolCallId = randomUUID();
|
|
24
|
-
const request = {
|
|
32
|
+
const request = {
|
|
33
|
+
toolCallId,
|
|
34
|
+
tool: tool.name,
|
|
35
|
+
input,
|
|
36
|
+
// P1:绑定会话 id——挂起期间用户切换页面后,前端经
|
|
37
|
+
// GET /api/permissions/:sessionId/pending 重挂确认弹窗。
|
|
38
|
+
...(ctx.session?.id ? { sessionId: ctx.session.id } : {}),
|
|
39
|
+
};
|
|
25
40
|
const promise = new Promise((resolve) => {
|
|
26
41
|
// pending 注册到全局 store,confirm 端点按 toolCallId 直接寻址,
|
|
27
42
|
// 不依赖当前 agent run 是否仍在 agentManager 中注册。
|
|
28
|
-
// store
|
|
29
|
-
store.register(toolCallId, {
|
|
43
|
+
// store 内置双层超时:首层提示(pending 保持),宽限期后兜底 deny。
|
|
44
|
+
store.register(toolCallId, {
|
|
45
|
+
request,
|
|
46
|
+
resolve,
|
|
47
|
+
...(opts.onPermissionTimeout ? { onTimeout: opts.onPermissionTimeout } : {}),
|
|
48
|
+
...(opts.onPermissionExpired ? { onExpired: opts.onPermissionExpired } : {}),
|
|
49
|
+
});
|
|
30
50
|
});
|
|
31
51
|
await opts.onPermissionRequired?.(request);
|
|
32
52
|
// agent abort 联动:abort 时主动 deny 并清理 store 条目,
|
|
@@ -2,21 +2,33 @@ 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;
|
|
19
|
+
/** 首层超时回调(P1-6:按请求绑定 SSE 流,仅提示不终结)。 */
|
|
20
|
+
onTimeout?: (request: PermissionRequest) => void;
|
|
21
|
+
/** 兜底过期回调(P0 双层超时:提示后仍无响应 → 自动拒绝,通知前端清理弹窗状态)。 */
|
|
22
|
+
onExpired?: (request: PermissionRequest) => void;
|
|
15
23
|
};
|
|
16
24
|
/** createPermissionStore 的配置。 */
|
|
17
25
|
type PermissionStoreOptions = {
|
|
18
26
|
/** pending 超时毫秒数;省略时取 DEFAULT_PERMISSION_TIMEOUT_MS(5 分钟)。 */
|
|
19
27
|
timeoutMs?: number;
|
|
28
|
+
/** 首层超时后到兜底自动拒绝的宽限毫秒数;省略时 25 分钟。 */
|
|
29
|
+
expireGraceMs?: number;
|
|
30
|
+
/** 超时自动拒绝时回调(P1-6:通知前端「确认超时已拒绝」,提供重新询问入口)。 */
|
|
31
|
+
onTimeout?: (request: PermissionRequest) => void;
|
|
20
32
|
};
|
|
21
33
|
/** 全局权限确认 store:注册/解析 pending permission。 */
|
|
22
34
|
type PermissionStore = {
|
|
@@ -24,9 +36,11 @@ type PermissionStore = {
|
|
|
24
36
|
resolve(toolCallId: string, approved: boolean): boolean;
|
|
25
37
|
has(toolCallId: string): boolean;
|
|
26
38
|
size(): number;
|
|
39
|
+
/** 查询某会话当前挂起的权限请求(P1:前端重挂弹窗;无则 null)。 */
|
|
40
|
+
pendingForSession(sessionId: string): PermissionRequest | null;
|
|
27
41
|
/** settle 所有 pending 为 deny 并清空(dev 热重载重建前调用)。 */
|
|
28
42
|
dispose(): void;
|
|
29
43
|
};
|
|
30
44
|
declare function createPermissionStore(opts?: PermissionStoreOptions): PermissionStore;
|
|
31
45
|
export type { PendingPermission, PermissionRequest, PermissionStore, PermissionStoreOptions };
|
|
32
|
-
export { createPermissionStore, DEFAULT_PERMISSION_TIMEOUT_MS };
|
|
46
|
+
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,8 +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
|
-
|
|
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 });
|
|
33
55
|
}, timeoutMs);
|
|
34
56
|
pending.set(toolCallId, { ...entry, timer });
|
|
35
57
|
},
|
|
@@ -42,6 +64,13 @@ function createPermissionStore(opts = {}) {
|
|
|
42
64
|
size() {
|
|
43
65
|
return pending.size;
|
|
44
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
|
+
},
|
|
45
74
|
dispose() {
|
|
46
75
|
// dev 热重载重建前调用:所有 pending settle 为 deny(避免悬空 Promise +
|
|
47
76
|
// timer 泄漏),clearTimeout 后清空 Map。
|
|
@@ -53,4 +82,4 @@ function createPermissionStore(opts = {}) {
|
|
|
53
82
|
},
|
|
54
83
|
};
|
|
55
84
|
}
|
|
56
|
-
export { createPermissionStore, DEFAULT_PERMISSION_TIMEOUT_MS };
|
|
85
|
+
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 };
|