opencode-claude-code-ua 0.0.0-stage → 0.3.6

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.
@@ -0,0 +1,465 @@
1
+ import { createServer } from 'node:http';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { createConnectionDiagnostics } from './diagnostics.mjs';
5
+ import { CLIENT_PRESETS, DEFAULT_METADATA_TEMPLATES, VARIABLE_DEFINITIONS, redactProfile } from './profile.mjs';
6
+
7
+ const MAX_BODY_BYTES = 256 * 1024;
8
+ // Fetch/browser bad-port list: a random listener must still be browser reachable.
9
+ const BLOCKED_PORTS = new Set([1,7,9,11,13,15,17,19,20,21,22,23,25,37,42,43,53,69,77,79,87,95,101,102,103,104,109,110,111,113,115,117,119,123,135,137,139,143,161,179,389,427,465,512,513,514,515,526,530,531,532,540,548,554,556,563,587,601,636,989,990,993,995,1719,1720,1723,2049,3659,4045,4190,5060,5061,6000,6566,6665,6666,6667,6668,6669,6697,10080]);
10
+ const MAX_SAVE_OPERATIONS = 128;
11
+ const OPERATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
12
+
13
+ function validOperationId(value) {
14
+ return typeof value === 'string' && OPERATION_ID.test(value);
15
+ }
16
+
17
+ function stableJson(value) {
18
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
19
+ if (value && typeof value === 'object') {
20
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
21
+ }
22
+ return JSON.stringify(value);
23
+ }
24
+
25
+ function saveDigest(data) {
26
+ return createHash('sha256').update(stableJson({ profile: data.profile, clearSecrets: data.clearSecrets ?? [] })).digest('hex');
27
+ }
28
+ const ASSETS = {
29
+ '': ['pro.html', 'text/html; charset=utf-8'],
30
+ 'theme.js': ['theme.js', 'text/javascript; charset=utf-8'],
31
+ 'icon.svg': ['../assets/icon.svg', 'image/svg+xml; charset=utf-8'],
32
+ accounts: ['accounts.html', 'text/html; charset=utf-8'],
33
+ 'accounts.js': ['accounts.js', 'text/javascript; charset=utf-8'],
34
+ 'accounts.css': ['accounts.css', 'text/css; charset=utf-8'],
35
+ pro: ['pro.html', 'text/html; charset=utf-8'],
36
+ 'pro.js': ['pro.js', 'text/javascript; charset=utf-8'],
37
+ 'ui/pro.js': ['pro.js', 'text/javascript; charset=utf-8'],
38
+ 'lib/ua-protocol.mjs': ['../lib/ua-protocol.mjs', 'text/javascript; charset=utf-8'],
39
+ 'lib/ua-test-plan.mjs': ['../lib/ua-test-plan.mjs', 'text/javascript; charset=utf-8'],
40
+ 'pro.css': ['pro.css', 'text/css; charset=utf-8'],
41
+ };
42
+
43
+ function securityHeaders(response) {
44
+ response.setHeader('Cache-Control', 'no-store');
45
+ response.setHeader('Referrer-Policy', 'no-referrer');
46
+ response.setHeader('X-Content-Type-Options', 'nosniff');
47
+ response.setHeader('X-Frame-Options', 'DENY');
48
+ response.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
49
+ response.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'");
50
+ }
51
+
52
+ function json(response, status, value) {
53
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
54
+ response.end(JSON.stringify(value));
55
+ }
56
+
57
+ async function readJson(request) {
58
+ const declared = Number(request.headers['content-length'] ?? 0);
59
+ if (!Number.isFinite(declared) || declared > MAX_BODY_BYTES) {
60
+ request.resume();
61
+ const error = new Error('请求内容过大');
62
+ error.status = 413;
63
+ throw error;
64
+ }
65
+ let size = 0;
66
+ const chunks = [];
67
+ for await (const chunk of request) {
68
+ size += chunk.length;
69
+ if (size > MAX_BODY_BYTES) {
70
+ const error = new Error('请求内容过大');
71
+ error.status = 413;
72
+ throw error;
73
+ }
74
+ chunks.push(chunk);
75
+ }
76
+ try {
77
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
78
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error();
79
+ return data;
80
+ } catch {
81
+ const error = new Error('请求必须是有效的 JSON 对象');
82
+ error.status = 400;
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ export async function startSetupServer({
88
+ store,
89
+ accountManager,
90
+ hostBinding,
91
+ uaService,
92
+ // Deprecated proDefault is accepted in the options object but has no effect.
93
+ checkConnection = createConnectionDiagnostics(),
94
+ host = '127.0.0.1',
95
+ port = 0,
96
+ timeoutMs = 15 * 60 * 1000,
97
+ operationGraceMs = 30000,
98
+ shutdownDelayMs = 2000,
99
+ saveRecoveryGraceMs = 30000,
100
+ finishOnSave = true,
101
+ } = {}) {
102
+ if (host !== '127.0.0.1' && host !== '::1') throw new Error('配置服务只能监听回环地址,请使用 SSH 隧道访问远程宿主');
103
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('配置服务端口无效');
104
+ if (BLOCKED_PORTS.has(port)) throw new Error('此端口被浏览器禁止访问,请选择其他端口或使用随机端口');
105
+ if (![timeoutMs, operationGraceMs, shutdownDelayMs, saveRecoveryGraceMs].every((value) => Number.isFinite(value) && value >= 0)) throw new Error('配置服务超时时间无效');
106
+ if (typeof finishOnSave !== 'boolean') throw new Error('配置服务会话模式无效');
107
+ const token = randomBytes(32).toString('base64url');
108
+ const prefix = `/${token}/`;
109
+ let origin;
110
+ let timer;
111
+ let shutdownTimer;
112
+ let saveRecoveryDeadline = 0;
113
+ let hardDeadline;
114
+ let settled = false;
115
+ let sessionState = 'active';
116
+ let savedAt = null;
117
+ let saving = false;
118
+ let closing;
119
+ let resolveFinished;
120
+ const operations = new Map();
121
+ const proOperations = new Map();
122
+ const proControllers = new Map();
123
+ const finished = new Promise((resolve) => { resolveFinished = resolve; });
124
+
125
+ function sessionInfo() {
126
+ return { mode: finishOnSave ? 'authorization' : 'editor', state: sessionState, savedAt, saveStatus: true };
127
+ }
128
+
129
+ function publicConfig(info) {
130
+ return { ...info, clientPresets: CLIENT_PRESETS, metadataTemplates: DEFAULT_METADATA_TEMPLATES, session: sessionInfo() };
131
+ }
132
+
133
+ function ended(response, status = 410) {
134
+ return json(response, status, { error: '配置会话已结束,请重新打开设置检查保存结果', code: 'session-ended', state: sessionState, session: sessionInfo() });
135
+ }
136
+
137
+ function busy(response) {
138
+ return json(response, 409, { error: '其他操作正在进行,请等待结果后重试', code: 'busy' });
139
+ }
140
+
141
+ function operationInfo(operation) {
142
+ return { ...publicConfig(operation.info), session: { ...sessionInfo(), savedAt: operation.savedAt } };
143
+ }
144
+
145
+ function failedOperation(operation, status, error) {
146
+ if (operation) Object.assign(operation, { state: 'failed', status, error });
147
+ }
148
+
149
+ function persistedInfo(profile) {
150
+ return {
151
+ profile: redactProfile(profile), configured: true,
152
+ secrets: {
153
+ apiKey: Boolean(profile?.upstream?.apiKey),
154
+ authorizationValue: Boolean(profile?.upstream?.authorizationValue),
155
+ actorAuthorization: false,
156
+ },
157
+ variables: VARIABLE_DEFINITIONS, storagePath: store.path,
158
+ };
159
+ }
160
+
161
+ function scheduleClose(delay) {
162
+ clearTimeout(shutdownTimer);
163
+ shutdownTimer = setTimeout(() => { void close(); }, delay);
164
+ shutdownTimer.unref?.();
165
+ }
166
+
167
+ function complete(result) {
168
+ if (settled) return;
169
+ settled = true;
170
+ sessionState = result.status === 'managed' ? 'cancelled' : result.status;
171
+ clearTimeout(timer);
172
+ if ([...operations.values(), ...proOperations.values()].some((operation) => operation.state === 'saved')) {
173
+ saveRecoveryDeadline = Date.now() + saveRecoveryGraceMs;
174
+ }
175
+ scheduleClose(Math.max(shutdownDelayMs, saveRecoveryDeadline - Date.now()));
176
+ resolveFinished(result);
177
+ }
178
+
179
+ function retire() {
180
+ if (closing) return closing;
181
+ const remaining = saveRecoveryDeadline - Date.now();
182
+ if (remaining <= 0) return close();
183
+ // Native auth may return now; the server owns the remaining, bounded recovery lifetime.
184
+ scheduleClose(remaining);
185
+ }
186
+
187
+ function expire() {
188
+ if (saving && Date.now() < hardDeadline) {
189
+ timer = setTimeout(expire, Math.min(1000, Math.max(1, hardDeadline - Date.now())));
190
+ timer.unref?.();
191
+ return;
192
+ }
193
+ complete({ status: 'timeout' });
194
+ }
195
+
196
+ const server = createServer(async (request, response) => {
197
+ securityHeaders(response);
198
+ try {
199
+ if (request.headers.host !== new URL(origin).host) return json(response, 403, { error: '配置服务拒绝此 Host' });
200
+ const url = new URL(request.url, origin);
201
+ if (url.origin === origin && url.pathname === '/favicon.ico' && (request.method === 'GET' || request.method === 'HEAD')) {
202
+ response.writeHead(204);
203
+ return response.end();
204
+ }
205
+ if (url.origin !== origin || !url.pathname.startsWith(prefix)) return json(response, 404, { error: '配置页面不存在或链接已失效' });
206
+ const route = url.pathname.slice(prefix.length);
207
+ const isApi = route.startsWith('api/');
208
+ if (isApi && (request.headers['sec-fetch-site'] === 'cross-site' || (request.headers.origin && request.headers.origin !== origin))) {
209
+ return json(response, 403, { error: '只允许从当前配置页面访问' });
210
+ }
211
+
212
+ if (route === 'legacy') {
213
+ if (request.method !== 'GET' && request.method !== 'HEAD') return json(response, 405, { error: '不支持此请求方法' });
214
+ response.writeHead(308, { Location: `${prefix}pro` });
215
+ return response.end();
216
+ }
217
+ if (Object.hasOwn(ASSETS, route)) {
218
+ if (request.method !== 'GET' && request.method !== 'HEAD') return json(response, 405, { error: '不支持此请求方法' });
219
+ const [name, type] = ASSETS[route];
220
+ const content = await readFile(new URL(`../ui/${name}`, import.meta.url));
221
+ response.writeHead(200, { 'Content-Type': type });
222
+ return response.end(request.method === 'HEAD' ? undefined : content);
223
+ }
224
+ if (route.startsWith('api/pro/')) {
225
+ if (!uaService) return json(response, 503, { error: 'UA PRO 管理尚未初始化' });
226
+ const action = route.slice('api/pro/'.length);
227
+ if (action === 'operations' && request.method === 'GET') {
228
+ const id = url.searchParams.get('id');
229
+ if (!validOperationId(id)) return json(response, 400, { error: '操作标识无效' });
230
+ const operation = proOperations.get(id);
231
+ return json(response, 200, operation ? { state: operation.state, result: operation.result, error: operation.error } : { state: 'unknown' });
232
+ }
233
+ if (settled) return ended(response);
234
+ if (action === 'state') {
235
+ if (request.method !== 'GET') return json(response, 405, { error: '不支持此请求方法' });
236
+ const state = await uaService.state();
237
+ if (settled) return ended(response);
238
+ return json(response, 200, { ...state, session: sessionInfo() });
239
+ }
240
+ if (request.method !== 'POST') return json(response, 405, { error: '不支持此请求方法' });
241
+ if (request.headers.origin !== origin) return json(response, 403, { error: '操作必须从当前配置页面发起' });
242
+ if (!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) return json(response, 415, { error: '请使用 JSON 提交' });
243
+ const data = await readJson(request);
244
+ if (action === 'cancel-test') {
245
+ proControllers.get(data.operationId)?.abort();
246
+ return json(response, 200, { cancelled: true });
247
+ }
248
+ if (saving) return busy(response);
249
+ if (!validOperationId(data.operationId)) return json(response, 400, { error: '请提供有效的操作标识' });
250
+ const signature = createHash('sha256').update(stableJson({ action, data })).digest('hex');
251
+ const existing = proOperations.get(data.operationId);
252
+ if (existing) {
253
+ if (existing.signature !== signature) return json(response, 409, { error: '操作标识已用于不同内容' });
254
+ if (existing.state === 'pending') return json(response, 409, { error: '操作仍在进行,请查询结果', code: 'save-pending' });
255
+ return json(response, existing.state === 'saved' ? 200 : existing.status, existing.state === 'saved' ? existing.result : existing.error);
256
+ }
257
+ if (proOperations.size >= MAX_SAVE_OPERATIONS) return json(response, 429, { error: '本次操作数量已达上限,请重新打开配置页' });
258
+ const operation = { state: 'pending', signature };
259
+ proOperations.set(data.operationId, operation);
260
+ const controller = new AbortController();
261
+ proControllers.set(data.operationId, controller);
262
+ const disconnected = () => { if (!response.writableEnded && ['test', 'connections/models'].includes(action)) controller.abort(); };
263
+ response.once('close', disconnected);
264
+ const blocksSave = action !== 'connections/models';
265
+ if (blocksSave) saving = true;
266
+ try {
267
+ const result = await uaService.execute(action, data, { signal: controller.signal });
268
+ operation.state = 'saved';
269
+ operation.result = result;
270
+ if (settled) return ended(response);
271
+ return json(response, 200, result);
272
+ } catch (error) {
273
+ operation.state = 'failed';
274
+ operation.status = [400, 401, 403, 404, 408, 409, 413, 422].includes(error.status) ? error.status : 500;
275
+ operation.error = { error: operation.status === 500 ? 'UA PRO 操作未完成,请核对配置与存储状态' : error.message, ...(error.fields ? { fields: error.fields } : {}), ...(['dns', 'connection-refused', 'connection-reset', 'timeout', 'tls', 'network', 'upstream-http', 'invalid-model-response', 'cancelled'].includes(error.code) ? { code: error.code } : {}) };
276
+ return json(response, operation.status, operation.error);
277
+ } finally {
278
+ response.removeListener('close', disconnected);
279
+ proControllers.delete(data.operationId);
280
+ if (blocksSave) saving = false;
281
+ }
282
+ }
283
+ if (route === 'api/save-status') {
284
+ if (request.method !== 'GET') return json(response, 405, { error: '不支持此请求方法' });
285
+ const ids = url.searchParams.getAll('operationId');
286
+ if (ids.length !== 1 || !validOperationId(ids[0])) return json(response, 400, { error: '保存操作标识无效', code: 'invalid-operation-id' });
287
+ const operation = operations.get(ids[0]);
288
+ return json(response, 200, {
289
+ state: operation?.state ?? 'unknown',
290
+ ...(operation?.state === 'saved' ? { info: operationInfo(operation) } : {}),
291
+ });
292
+ }
293
+ if (route === 'api/config') {
294
+ if (request.method !== 'GET') return json(response, 405, { error: '不支持此请求方法' });
295
+ if (settled) return ended(response);
296
+ const info = await store.publicInfo();
297
+ if (settled) return ended(response);
298
+ return json(response, 200, publicConfig(info));
299
+ }
300
+ if (route === 'api/accounts') {
301
+ if (request.method !== 'GET') return json(response, 405, { error: '不支持此请求方法' });
302
+ if (settled) return ended(response);
303
+ if (!accountManager) return json(response, 503, { error: '账号管理尚未初始化' });
304
+ const accounts = await accountManager.list();
305
+ if (settled) return ended(response);
306
+ return json(response, 200, { ...accounts, ...(hostBinding ? { binding: { account: hostBinding.account, selected: hostBinding.selected } } : {}) });
307
+ }
308
+ const accountAction = route.startsWith('api/accounts/') ? route.slice('api/accounts/'.length) : null;
309
+ if (accountAction && !['save', 'remove', 'refresh', 'checkin', 'bind', 'done'].includes(accountAction)) return json(response, 404, { error: '账号接口不存在' });
310
+ if (!accountAction && !['api/save', 'api/check', 'api/cancel'].includes(route)) return json(response, 404, { error: '配置接口不存在' });
311
+ if (request.method !== 'POST') return json(response, 405, { error: '不支持此请求方法' });
312
+ if (request.headers.origin !== origin) return json(response, 403, { error: '保存必须从当前配置页面发起' });
313
+ if (!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) return json(response, 415, { error: '请使用 JSON 提交配置' });
314
+ if (route !== 'api/save') {
315
+ if (settled) return ended(response, 409);
316
+ if (saving) return busy(response);
317
+ }
318
+ const data = await readJson(request);
319
+ let operation;
320
+ if (route === 'api/save' && Object.hasOwn(data, 'operationId')) {
321
+ if (!validOperationId(data.operationId)) return json(response, 400, { error: '保存操作标识无效', code: 'invalid-operation-id' });
322
+ const digest = saveDigest(data);
323
+ operation = operations.get(data.operationId);
324
+ if (operation) {
325
+ if (operation.digest !== digest) return json(response, 409, { error: '此保存操作标识已用于其他内容,请使用新的标识', code: 'operation-conflict' });
326
+ if (operation.state === 'pending') return json(response, 409, { error: '此保存操作仍在进行,请查询保存结果', code: 'save-pending' });
327
+ if (operation.state === 'saved') return json(response, 200, { saved: true, operationId: data.operationId, info: operationInfo(operation) });
328
+ return json(response, operation.status, operation.error);
329
+ }
330
+ operation = { digest, state: 'pending' };
331
+ }
332
+ if (settled) return ended(response, 409);
333
+ if (saving) return busy(response);
334
+ if (accountAction) {
335
+ if (!accountManager) return json(response, 503, { error: '账号管理尚未初始化' });
336
+ if (accountAction === 'done') {
337
+ json(response, 200, { done: true });
338
+ complete({ status: 'managed' });
339
+ return;
340
+ }
341
+ if ((data.id !== undefined && (typeof data.id !== 'string' || !data.id)) ||
342
+ (accountAction === 'remove' && !data.id) ||
343
+ (accountAction === 'save' && (!data.account || typeof data.account !== 'object' || Array.isArray(data.account))) ||
344
+ (data.clearSecrets !== undefined && (!Array.isArray(data.clearSecrets) || !data.clearSecrets.every((key) => typeof key === 'string')))) {
345
+ return json(response, 400, { error: '账号参数无效,请检查表单' });
346
+ }
347
+ saving = true;
348
+ try {
349
+ let result;
350
+ if (accountAction === 'bind') {
351
+ if (!hostBinding) return json(response, 409, { error: '请从 Magpie 当前账号的管理入口打开页面后绑定' });
352
+ if (data.id) await accountManager.get(data.id);
353
+ hostBinding.selected = data.id ?? null;
354
+ hostBinding.changed = true;
355
+ result = { bound: true, selected: hostBinding.selected };
356
+ }
357
+ else if (accountAction === 'save') result = { account: await accountManager.save(data.account, data.clearSecrets ?? []) };
358
+ else if (accountAction === 'remove') result = await accountManager.remove(data.id);
359
+ else if (data.id) result = { account: await accountManager[accountAction](data.id) };
360
+ else result = await accountManager[`${accountAction}All`]();
361
+ if (settled) return ended(response);
362
+ json(response, 200, result);
363
+ } catch (error) {
364
+ const status = [400, 404, 409].includes(error.status) ? error.status : 500;
365
+ json(response, status, { error: status === 404 ? '账号不存在' : status === 409 ? '账号状态已变更,请刷新后重试' : '账号操作未完成,请检查配置或网络后重试', ...(error.name === 'AccountValidationError' ? { fields: error.fields ?? {} } : {}) });
366
+ } finally { saving = false; }
367
+ return;
368
+ }
369
+ if (route === 'api/cancel') {
370
+ complete({ status: 'cancelled' });
371
+ json(response, 200, { cancelled: true, session: sessionInfo() });
372
+ return;
373
+ }
374
+ if (operation) {
375
+ // Keep every accepted ID until shutdown; evicting one could allow a repeated write.
376
+ if (operations.size >= MAX_SAVE_OPERATIONS) return json(response, 429, { error: '本次会话的保存操作已达上限,请重新打开设置', code: 'operation-limit' });
377
+ operations.set(data.operationId, operation);
378
+ }
379
+ if (!data.profile || typeof data.profile !== 'object' || Array.isArray(data.profile) ||
380
+ (data.clearSecrets !== undefined && (!Array.isArray(data.clearSecrets) || !data.clearSecrets.every((key) => typeof key === 'string')))) {
381
+ const error = { error: '配置格式无效,请检查表单' };
382
+ failedOperation(operation, 400, error);
383
+ return json(response, 400, error);
384
+ }
385
+ saving = true;
386
+ try {
387
+ if (route === 'api/check') {
388
+ const profile = await store.prepareFromUi(data.profile, data.clearSecrets ?? []);
389
+ if (settled) return ended(response);
390
+ const result = await checkConnection(profile);
391
+ if (settled) return ended(response);
392
+ json(response, 200, { result });
393
+ return;
394
+ }
395
+ const profile = await store.saveFromUi(data.profile, data.clearSecrets ?? []);
396
+ savedAt = new Date().toISOString();
397
+ if (operation) {
398
+ // Record success before reading or returning anything else: the client may lose its response.
399
+ Object.assign(operation, { state: 'saved', savedAt, info: persistedInfo(profile) });
400
+ if (settled && !closing) {
401
+ saveRecoveryDeadline = Date.now() + saveRecoveryGraceMs;
402
+ scheduleClose(Math.max(shutdownDelayMs, saveRecoveryGraceMs));
403
+ }
404
+ try { operation.info = await store.publicInfo(); }
405
+ catch { /* The safe persisted snapshot still confirms a successful write. */ }
406
+ }
407
+ if (settled) return ended(response);
408
+ if (finishOnSave) complete({ status: 'saved', profile });
409
+ json(response, 200, operation ? { saved: true, operationId: data.operationId, info: operationInfo(operation) } : { saved: true });
410
+ } catch (error) {
411
+ const status = error.name === 'ProfileValidationError' ? 400 : 500;
412
+ const body = status === 400
413
+ ? { error: '请检查标记的配置字段', fields: error.fields ?? {} }
414
+ : { error: route === 'api/check' ? '连接检查未完成,请检查配置与网络后重试;当前设置未保存' : '配置保存失败,请检查目录权限与可用空间;表单尚未关闭' };
415
+ failedOperation(operation, status, body);
416
+ json(response, status, body);
417
+ } finally {
418
+ saving = false;
419
+ }
420
+ } catch (error) {
421
+ if (!response.headersSent && !response.destroyed) json(response, error.status ?? 500, { error: error.status ? error.message : '无法读取配置,请检查配置文件与权限' });
422
+ }
423
+ });
424
+ server.requestTimeout = 15000;
425
+ server.headersTimeout = 10000;
426
+ server.maxHeadersCount = 40;
427
+
428
+ async function close() {
429
+ if (closing) return closing;
430
+ clearTimeout(timer);
431
+ clearTimeout(shutdownTimer);
432
+ for (const controller of proControllers.values()) controller.abort();
433
+ if (!settled) {
434
+ settled = true;
435
+ sessionState = 'cancelled';
436
+ resolveFinished({ status: 'cancelled' });
437
+ }
438
+ closing = new Promise((resolve) => {
439
+ if (!server.listening) return resolve();
440
+ server.close(() => resolve());
441
+ server.closeIdleConnections?.();
442
+ server.closeAllConnections?.();
443
+ });
444
+ return closing;
445
+ }
446
+
447
+ let bound;
448
+ for (let attempt = 0; attempt < 16; attempt++) {
449
+ await new Promise((resolve, reject) => {
450
+ const failed = error => reject(error);
451
+ server.once('error', failed);
452
+ server.listen(port, host, () => { server.removeListener('error', failed); resolve(); });
453
+ });
454
+ bound = server.address();
455
+ if (!BLOCKED_PORTS.has(bound.port)) break;
456
+ await new Promise(resolve => server.close(resolve));
457
+ bound = undefined;
458
+ }
459
+ if (!bound) throw new Error('无法分配浏览器可访问的本地端口');
460
+ origin = `http://${host === '::1' ? '[::1]' : host}:${bound.port}`;
461
+ hardDeadline = Date.now() + timeoutMs + operationGraceMs;
462
+ timer = setTimeout(expire, timeoutMs);
463
+ timer.unref?.();
464
+ return { url: `${origin}${prefix}`, origin, port: bound.port, finished, close, retire };
465
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { createDefaultProfile, normalizeProfile, redactProfile, mergeProfileSecrets, VARIABLE_DEFINITIONS } from './profile.mjs';
5
+ const MAX_CONFIG_BYTES = 256 * 1024;
6
+ const writes = new Map();
7
+ export function createProfileStore(path, { io = fs } = {}) {
8
+ if (!isAbsolute(path)) throw new Error('配置文件必须使用绝对路径');
9
+ const queueKey = process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path);
10
+ async function load() {
11
+ try {
12
+ const info = await io.lstat(path);
13
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('配置文件必须是普通文件,不能使用符号链接');
14
+ if (info.size > MAX_CONFIG_BYTES) throw new Error('配置文件过大');
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') return null;
17
+ if (error.message === '配置文件过大' || error.message.includes('普通文件')) throw error;
18
+ throw new Error('无法读取配置文件,请检查文件权限');
19
+ }
20
+ let content;
21
+ try { content = await io.readFile(path, 'utf8'); }
22
+ catch { throw new Error('无法读取配置文件,请检查文件权限'); }
23
+ try {
24
+ if (Buffer.byteLength(content) > MAX_CONFIG_BYTES) throw new Error();
25
+ return normalizeProfile(JSON.parse(content));
26
+ } catch { throw new Error('配置文件内容无效,请检查 JSON 和配置格式;原文件未被修改'); }
27
+ }
28
+ function enqueue(action) {
29
+ const result = (writes.get(queueKey) ?? Promise.resolve()).then(action);
30
+ const next = result.catch(() => {}); writes.set(queueKey, next);
31
+ next.finally(() => { if (writes.get(queueKey) === next) writes.delete(queueKey); });
32
+ return result;
33
+ }
34
+ async function persist(profile) {
35
+ const normalized = normalizeProfile(profile);
36
+ const content = `${JSON.stringify(normalized, null, 2)}\n`;
37
+ if (Buffer.byteLength(content) > MAX_CONFIG_BYTES) throw new Error('配置文件过大');
38
+ const folder = dirname(path); const temporary = join(folder, `.profile-${randomUUID()}.tmp`);
39
+ let handle;
40
+ try {
41
+ await io.mkdir(folder, { recursive: true, mode: 0o700 });
42
+ handle = await io.open(temporary, 'wx', 0o600);
43
+ await handle.writeFile(content, 'utf8'); await handle.sync(); await handle.close(); handle = undefined;
44
+ await io.rename(temporary, path); return normalized;
45
+ } catch { throw new Error('配置保存失败,请检查目录权限与可用空间;原配置保留'); }
46
+ finally { if (handle) await handle.close().catch(() => {}); await io.unlink(temporary).catch(() => {}); }
47
+ }
48
+ async function save(profile) { return enqueue(async () => { await load(); return persist(profile); }); }
49
+ async function prepareFromUi(raw, clearSecrets = []) { return mergeProfileSecrets(raw, await load() ?? createDefaultProfile(), clearSecrets); }
50
+ async function saveFromUi(raw, clearSecrets = []) { return enqueue(async () => persist(await prepareFromUi(raw, clearSecrets))); }
51
+ async function publicInfo() {
52
+ const saved = await load(); const profile = saved ?? createDefaultProfile();
53
+ return { profile: redactProfile(profile), configured: Boolean(saved),
54
+ secrets: { apiKey: Boolean(profile.upstream.apiKey), authorizationValue: Boolean(profile.upstream.authorizationValue) },
55
+ variables: VARIABLE_DEFINITIONS, storagePath: path };
56
+ }
57
+ return { path, load, save, prepareFromUi, saveFromUi, publicInfo };
58
+ }
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { buildHeaders } from './headers.mjs';
3
+ import { normalizeProfile } from './profile.mjs';
4
+ import { applyParameters, compileParameters } from '../lib/parameters.mjs';
5
+ const MAX_INSPECTION_BYTES = 2 * 1024 * 1024;
6
+ function objectJson(text) {
7
+ try { const value = JSON.parse(text); return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
8
+ catch { return {}; }
9
+ }
10
+ export async function inspectJsonBody(input, init = {}, limit = MAX_INSPECTION_BYTES) {
11
+ const body = init.body;
12
+ if (typeof body === 'string') return Buffer.byteLength(body) <= limit ? objectJson(body) : {};
13
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
14
+ const bytes = body instanceof ArrayBuffer ? new Uint8Array(body) : new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
15
+ return bytes.byteLength <= limit ? objectJson(new TextDecoder().decode(bytes)) : {};
16
+ }
17
+ // Never consume Request streams merely to inspect optional context.
18
+ return {};
19
+ }
20
+ const scalar = value => typeof value === 'string' && value.trim() ? value : undefined;
21
+ export function contextFromRequest({ headers = new Headers(), body = {}, host = {}, clientVersion, uuid = randomUUID, now = Date.now } = {}) {
22
+ const context = {
23
+ 'session.id': scalar(host.sessionID) ?? scalar(host.sessionId) ?? scalar(headers.get('x-claude-code-session-id')),
24
+ 'request.id': scalar(headers.get('x-client-request-id')) ?? scalar(headers.get('x-request-id')) ?? scalar(host.requestID) ?? uuid(),
25
+ 'client.version': scalar(clientVersion),
26
+ 'model.id': scalar(host.model?.api?.id) ?? scalar(host.model?.id) ?? scalar(body.model),
27
+ 'reasoning.effort': scalar(body.output_config?.effort),
28
+ timestamp: now(),
29
+ };
30
+ return Object.fromEntries(Object.entries(context).filter(([, value]) => value !== undefined));
31
+ }
32
+ function assertUpstream(input, baseUrl) {
33
+ let target, base;
34
+ try { target = new URL(input instanceof Request ? input.url : input); base = new URL(baseUrl); }
35
+ catch { throw new Error('请求地址无效,请检查上游配置'); }
36
+ const prefix = base.pathname.replace(/\/+$/, '');
37
+ if (target.origin !== base.origin || target.username || target.password || target.hash ||
38
+ (prefix && target.pathname !== prefix && !target.pathname.startsWith(`${prefix}/`))) throw new Error('拒绝发送到未配置的上游地址;修改 Base URL 后请重新加载 Provider');
39
+ return target;
40
+ }
41
+ async function requestBody(value) {
42
+ let text;
43
+ if (typeof value === 'string') text = value;
44
+ else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
45
+ const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
46
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
47
+ catch { throw new TypeError('Messages body must be UTF-8 JSON'); }
48
+ } else if (value instanceof ReadableStream || value instanceof Blob) return requestBody(await new Response(value).arrayBuffer());
49
+ else throw new TypeError('Messages body must contain JSON text or UTF-8 bytes');
50
+ try { return JSON.parse(text); } catch { throw new TypeError('Messages body must contain valid JSON'); }
51
+ }
52
+ export function createTransport({ getProfile, fetchImpl = globalThis.fetch, getContext = () => ({}), extractHostContext = () => ({}), uuid = randomUUID, now = Date.now } = {}) {
53
+ return async function transport(input, init = {}) {
54
+ const saved = await getProfile();
55
+ if (!saved) throw new Error('请先完成 Claude Code UA 配置,未配置时不会发送请求');
56
+ const profile = normalizeProfile(saved);
57
+ const url = assertUpstream(input, profile.upstream.baseUrl);
58
+ const request = input instanceof Request ? input : undefined;
59
+ const baseHeaders = new Headers(request?.headers);
60
+ new Headers(init.headers ?? {}).forEach((value, name) => baseHeaders.set(name, value));
61
+ const host = { ...(await getContext() ?? {}), ...extractHostContext(baseHeaders) };
62
+ const body = await inspectJsonBody(input, init);
63
+ const context = contextFromRequest({ headers: baseHeaders, body, host, clientVersion: profile.client.version, uuid, now });
64
+ const headers = buildHeaders({ profile, baseHeaders, context, stream: typeof body.stream === 'boolean' ? body.stream : undefined });
65
+ const method = (init.method ?? request?.method ?? 'GET').toUpperCase();
66
+ const sent = { ...init, headers, redirect: 'manual' };
67
+ const plan = compileParameters(profile.parameters);
68
+ const messagesPath = `${new URL(profile.upstream.baseUrl).pathname.replace(/\/+$/, '')}/messages`;
69
+ if (plan.active && method === 'POST' && url.pathname === messagesPath) {
70
+ // Read the incoming encoding before the safety filter removes framing headers.
71
+ const encoding = baseHeaders.get('content-encoding');
72
+ if (encoding && encoding.toLowerCase() !== 'identity') throw new TypeError('Cannot override a Messages body with content-encoding');
73
+ const content = init.body !== undefined ? init.body : request ? await request.clone().arrayBuffer() : undefined;
74
+ sent.body = JSON.stringify(applyParameters(await requestBody(content), plan));
75
+ for (const name of ['content-length', 'content-md5', 'digest', 'content-encoding']) headers.delete(name);
76
+ headers.set('content-type', 'application/json');
77
+ } else if (baseHeaders.has('content-encoding')) {
78
+ // Raw compressed bodies remain compressed when no edits are requested.
79
+ headers.set('content-encoding', baseHeaders.get('content-encoding'));
80
+ }
81
+ // Preserve input, original body, caller signal, Response, and fetch errors without retries or SSE rewriting.
82
+ return fetchImpl(input, sent);
83
+ };
84
+ }