orbit-codex-bridge 0.0.0-stage → 0.1.1

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,404 @@
1
+ import net from 'node:net';
2
+ import crypto from 'node:crypto';
3
+ import { EventEmitter } from 'node:events';
4
+ import { messageFromItem } from './store.mjs';
5
+
6
+ const maxFrame = 32 * 1024 * 1024;
7
+ const versions = { initialize: 0, 'thread-owner-discovery': 1, 'thread-follower-read-model-settings': 1, 'thread-follower-update-thread-settings': 2, 'thread-follower-start-turn': 2, 'thread-follower-steer-turn': 1, 'thread-follower-interrupt-turn': 4, 'thread-follower-command-approval-decision': 1, 'thread-follower-file-approval-decision': 1, 'thread-follower-submit-user-input': 1, 'thread-follower-permissions-request-approval-response': 1 };
8
+ const approvalMethods = new Map([
9
+ ['item/commandExecution/requestApproval', 'thread-follower-command-approval-decision'],
10
+ ['item/fileChange/requestApproval', 'thread-follower-file-approval-decision'],
11
+ ['item/tool/requestUserInput', 'thread-follower-submit-user-input'],
12
+ ['item/permissions/requestApproval', 'thread-follower-permissions-request-approval-response']
13
+ ]);
14
+
15
+ export class DesktopUnavailableError extends Error {
16
+ constructor(message, code = 'DESKTOP_THREAD_UNAVAILABLE') {
17
+ super(message);
18
+ this.code = code;
19
+ }
20
+ }
21
+
22
+ export function applyDesktopPatches(state, patches) {
23
+ if (!Array.isArray(patches) || patches.length > 10000) throw new Error('桌面补丁格式不兼容。');
24
+ state = structuredClone(state);
25
+ for (const patch of patches) {
26
+ if (!['add', 'replace', 'remove'].includes(patch.op) || !Array.isArray(patch.path) || patch.path.length > 64) throw new Error('桌面补丁格式不兼容。');
27
+ if (patch.path.some(key => !['string', 'number'].includes(typeof key) || ['__proto__', 'prototype', 'constructor'].includes(String(key)))) throw new Error('已拒绝不安全的补丁路径。');
28
+ if (!patch.path.length) {
29
+ if (patch.op === 'remove') throw new Error('不可移除桌面状态根节点。');
30
+ state = patch.value;
31
+ continue;
32
+ }
33
+ let target = state;
34
+ for (const key of patch.path.slice(0, -1)) {
35
+ if (!target || typeof target !== 'object' || !Object.hasOwn(target, key)) throw new Error('桌面补丁基线不匹配。');
36
+ target = target[key];
37
+ }
38
+ const key = patch.path.at(-1);
39
+ if (!target || typeof target !== 'object') throw new Error('桌面补丁基线不匹配。');
40
+ if (Array.isArray(target)) {
41
+ if (!/^(0|[1-9]\d*)$/.test(String(key)) || !Number.isSafeInteger(Number(key)) || Number(key) > target.length || (patch.op !== 'add' && Number(key) >= target.length)) throw new Error('桌面补丁索引不兼容。');
42
+ if (patch.op === 'add') target.splice(Number(key), 0, patch.value);
43
+ else if (patch.op === 'remove') target.splice(Number(key), 1);
44
+ else target[key] = patch.value;
45
+ } else {
46
+ if (patch.op !== 'add' && !Object.hasOwn(target, key)) throw new Error('桌面补丁路径不存在。');
47
+ if (patch.op === 'remove') delete target[key]; else target[key] = patch.value;
48
+ }
49
+ }
50
+ return state;
51
+ }
52
+
53
+ export function desktopTurns(state) {
54
+ const history = state?.turnHistory?.history;
55
+ if (state?.turnHistory?.kind === 'canonical' && history) return (history.islands || []).flatMap(island => (island.entries || []).map(entry => history.entitiesByKey?.[entry.value]).filter(Boolean));
56
+ return state?.turns || [];
57
+ }
58
+
59
+ export function desktopMessages(state) {
60
+ return desktopTurns(state).slice(-8).flatMap(turn => (turn.items || []).flatMap(item => {
61
+ const message = messageFromItem(item, turn.turnStartedAtMs || null);
62
+ return message?.id && (message.text || message.role === 'activity') ? [{ ...message, live: turn.status === 'inProgress' }] : [];
63
+ })).slice(-160);
64
+ }
65
+
66
+ export function desktopContext(state) {
67
+ const info = state?.latestTokenUsageInfo;
68
+ const usedTokens = info?.last?.totalTokens;
69
+ const windowTokens = info?.modelContextWindow;
70
+ if (!Number.isSafeInteger(usedTokens) || usedTokens < 0) return null;
71
+ return {
72
+ usedTokens,
73
+ windowTokens: Number.isSafeInteger(windowTokens) && windowTokens > 0 ? windowTokens : null,
74
+ cachedTokens: Number.isSafeInteger(info.last.cachedInputTokens) && info.last.cachedInputTokens >= 0 ? info.last.cachedInputTokens : null
75
+ };
76
+ }
77
+
78
+ export class DesktopRpc extends EventEmitter {
79
+ constructor({ pipePath = process.env.ORBIT_DESKTOP_PIPE || (process.platform === 'win32' ? '\\\\.\\pipe\\codex-ipc' : ''), timeout = 6000 } = {}) {
80
+ super();
81
+ this.mode = 'desktop';
82
+ this.pipePath = pipePath;
83
+ this.timeout = timeout;
84
+ this.connected = false;
85
+ this.clientId = 'initializing-client';
86
+ this.threads = new Map();
87
+ this.approvals = new Map();
88
+ this.pending = new Map();
89
+ this.attaching = new Map();
90
+ this.starting = new Set();
91
+ this.updatingSettings = new Set();
92
+ this.buffer = Buffer.alloc(0);
93
+ }
94
+
95
+ async connect() {
96
+ if (this.connected) return;
97
+ if (this.connecting) return this.connecting;
98
+ if (!this.pipePath) throw new Error('未配置受支持的桌面 IPC 路径,未启动独立执行器。');
99
+ this.connecting = (async () => {
100
+ const socket = net.createConnection(this.pipePath);
101
+ this.socket = socket;
102
+ socket.on('error', () => this.disconnect(new Error('桌面 IPC 不可用,请保持 Codex 桌面打开。'), socket));
103
+ socket.on('close', () => this.disconnect(new Error('桌面连接断开;未重发任何指令,请重新授权。'), socket));
104
+ socket.on('data', chunk => {
105
+ if (this.socket !== socket) return;
106
+ try { this.receive(chunk); } catch { this.disconnect(new Error('桌面协议不兼容,已停止接管。'), socket); }
107
+ });
108
+ await new Promise((resolve, reject) => {
109
+ const timer = setTimeout(() => { socket.destroy(); reject(new Error('连接桌面超时。')); }, this.timeout);
110
+ socket.once('connect', () => { clearTimeout(timer); resolve(); });
111
+ socket.once('error', () => { clearTimeout(timer); reject(new Error('找不到 Codex 桌面 IPC;不会回退到独立接续。')); });
112
+ });
113
+ const result = await this.request('initialize', { clientType: 'orbit' });
114
+ if (typeof result.clientId !== 'string') throw new Error('桌面 IPC 初始化响应不兼容。');
115
+ this.clientId = result.clientId;
116
+ this.connected = true;
117
+ this.emit('event', { method: 'orbit/engine', params: { connected: true, mode: 'desktop' } });
118
+ })();
119
+ try { await this.connecting; } catch (error) { this.disconnect(error); throw error; }
120
+ finally { this.connecting = null; }
121
+ }
122
+
123
+ send(message) {
124
+ if (!this.socket?.writable) throw new Error('桌面 IPC 未连接。');
125
+ const payload = Buffer.from(JSON.stringify(message));
126
+ if (payload.length > maxFrame) throw new Error('桌面请求过大。');
127
+ const frame = Buffer.alloc(payload.length + 4);
128
+ frame.writeUInt32LE(payload.length);
129
+ payload.copy(frame, 4);
130
+ this.socket.write(frame);
131
+ }
132
+
133
+ request(method, params, targetClientId) {
134
+ if (!Object.hasOwn(versions, method)) return Promise.reject(new Error('此桌面操作未在白名单中。'));
135
+ const requestId = crypto.randomUUID();
136
+ return new Promise((resolve, reject) => {
137
+ const timer = setTimeout(() => { this.pending.delete(requestId); reject(new Error('桌面响应超时;操作结果未知,绝不自动重发。请检查桌面记录。')); }, this.timeout);
138
+ this.pending.set(requestId, { resolve, reject, timer, method, targetClientId, conversationId: params?.conversationId, thread: this.threads.get(params?.conversationId) });
139
+ try { this.send({ type: 'request', requestId, sourceClientId: this.clientId, method, version: versions[method], params, targetClientId, timeoutMs: this.timeout - 100 }); }
140
+ catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); }
141
+ });
142
+ }
143
+
144
+ receive(chunk) {
145
+ this.buffer = Buffer.concat([this.buffer, chunk]);
146
+ while (this.buffer.length >= 4) {
147
+ const length = this.buffer.readUInt32LE();
148
+ if (!length || length > maxFrame) throw new Error('无效桌面帧。');
149
+ if (this.buffer.length < length + 4) return;
150
+ const message = JSON.parse(this.buffer.subarray(4, length + 4).toString('utf8'));
151
+ this.buffer = this.buffer.subarray(length + 4);
152
+ this.dispatch(message);
153
+ }
154
+ }
155
+
156
+ dispatch(message) {
157
+ if (message.type === 'response') {
158
+ const pending = this.pending.get(message.requestId);
159
+ if (!pending) return;
160
+ clearTimeout(pending.timer); this.pending.delete(message.requestId);
161
+ if (message.resultType !== 'success') {
162
+ const reason = typeof message.error === 'string' ? message.error : message.error?.code || 'unknown error';
163
+ if (/^no-client-found(?::|$)/.test(reason)) {
164
+ const error = new DesktopUnavailableError('这条原会话尚未在 Codex 桌面加载,或原连接已失效。请在电脑上打开这条原会话,再点击“重新连接”。');
165
+ if (pending.conversationId && this.threads.get(pending.conversationId) === pending.thread) this.detach(pending.conversationId, error.message, error.code);
166
+ pending.reject(error);
167
+ } else pending.reject(new Error(`桌面拒绝操作:${reason}`));
168
+ }
169
+ else if (message.method !== pending.method || (pending.targetClientId && message.handledByClientId !== pending.targetClientId)) {
170
+ const error = new DesktopUnavailableError('桌面执行者或协议发生变化,已拒绝响应。请重新连接并授权。', 'DESKTOP_OWNER_CHANGED');
171
+ if (pending.conversationId && this.threads.get(pending.conversationId) === pending.thread) this.detach(pending.conversationId, error.message, error.code);
172
+ pending.reject(error);
173
+ }
174
+ else pending.resolve({ ...message.result, ownerClientId: message.handledByClientId });
175
+ return;
176
+ }
177
+ if (message.type === 'client-discovery-request') {
178
+ this.send({ type: 'client-discovery-response', requestId: message.requestId, response: { canHandle: false } });
179
+ return;
180
+ }
181
+ if (message.type === 'request') {
182
+ this.send({ type: 'response', requestId: message.requestId, resultType: 'error', error: 'no-handler-for-request' });
183
+ return;
184
+ }
185
+ if (message.type !== 'broadcast' || (message.targetClientIds && !message.targetClientIds.includes(this.clientId))) return;
186
+ if (message.method === 'client-status-changed' && message.params?.status === 'disconnected') {
187
+ for (const [id, thread] of this.threads) if (thread.ownerClientId === message.params.clientId) this.detach(id, '桌面执行者已离线,请重新打开会话并授权。');
188
+ }
189
+ if (message.method !== 'thread-stream-state-changed' || message.version !== 11 || message.params?.hostId !== 'local') return;
190
+ const { conversationId: id, change } = message.params;
191
+ const thread = this.threads.get(id);
192
+ if (!thread || thread.ownerClientId !== message.sourceClientId) return;
193
+ try {
194
+ if (change.type === 'snapshot') {
195
+ if (change.conversationState?.id !== id || !Number.isSafeInteger(change.revision)) throw new Error('桌面快照不匹配。');
196
+ if (thread.state && change.revision < thread.revision) return;
197
+ thread.state = change.conversationState;
198
+ } else if (change.type === 'patches') {
199
+ if (!thread.state || thread.revision !== change.baseRevision || change.revision <= change.baseRevision) throw new Error('桌面状态版本缺口。');
200
+ thread.state = applyDesktopPatches(thread.state, change.patches);
201
+ } else throw new Error('未知桌面状态类型。');
202
+ thread.revision = change.revision;
203
+ thread.turnId = desktopTurns(thread.state).findLast(turn => turn.status === 'inProgress')?.turnId || null;
204
+ this.syncApprovals(id, thread);
205
+ this.emit('state', id);
206
+ this.publish(id);
207
+ } catch {
208
+ thread.state = null;
209
+ thread.turnId = null;
210
+ this.emit('lost', id);
211
+ this.publish(id);
212
+ if (!thread.resyncing) {
213
+ thread.resyncing = true;
214
+ this.follow(id, thread.ownerClientId, false);
215
+ this.follow(id, thread.ownerClientId, true);
216
+ setTimeout(() => { if (this.threads.get(id) === thread) { thread.resyncing = false; if (!thread.state) this.detach(id, '桌面状态恢复失败,请重新连接。'); } }, this.timeout).unref();
217
+ }
218
+ }
219
+ }
220
+
221
+ follow(id, ownerClientId, following) {
222
+ this.send({ type: 'broadcast', method: 'thread-stream-following-changed', sourceClientId: this.clientId, version: 1, params: { conversationId: id, hostId: 'local', following }, targetClientIds: [ownerClientId] });
223
+ }
224
+
225
+ async attach(id) {
226
+ await this.connect();
227
+ const current = this.threads.get(id);
228
+ if (current?.state) {
229
+ try { await this.assertOwner(id); return this.runtime(id); }
230
+ catch (error) { if (this.threads.get(id) === current) this.detach(id, error.message); throw error; }
231
+ }
232
+ if (this.attaching.has(id)) return this.attaching.get(id);
233
+ const operation = (async () => {
234
+ const owner = await this.request('thread-owner-discovery', { conversationId: id, hostId: 'local' });
235
+ if (!owner.ownerClientId) throw new DesktopUnavailableError('这条原会话尚未在 Codex 桌面加载。请在电脑上打开它,再点击“重新连接”。');
236
+ const result = await this.request('thread-follower-read-model-settings', { conversationId: id }, owner.ownerClientId);
237
+ if (result.settings?.resumeState !== 'resumed') throw new Error('桌面会话尚未就绪,请先在 Codex 中打开。');
238
+ this.threads.set(id, { ownerClientId: owner.ownerClientId, state: null, revision: null, turnId: null });
239
+ await new Promise((resolve, reject) => {
240
+ const cleanup = () => { clearTimeout(timer); this.off('state', listener); this.off('lost', failed); };
241
+ const listener = updated => { if (updated === id && this.threads.get(id)?.state) { cleanup(); resolve(); } };
242
+ const failed = lost => { if (lost === id) { cleanup(); reject(new Error('桌面会话连接已失效。')); } };
243
+ const timer = setTimeout(() => { cleanup(); reject(new Error('未收到桌面实时快照,不开放控制。')); }, this.timeout);
244
+ this.on('state', listener); this.on('lost', failed);
245
+ try { this.follow(id, owner.ownerClientId, true); } catch (error) { cleanup(); reject(error); }
246
+ });
247
+ return this.runtime(id);
248
+ })();
249
+ this.attaching.set(id, operation);
250
+ try { return await operation; } catch (error) { this.detach(id, error.message); throw error; }
251
+ finally { this.attaching.delete(id); }
252
+ }
253
+
254
+ runtime(id) {
255
+ const thread = this.threads.get(id);
256
+ const state = thread?.state;
257
+ return {
258
+ threadId: id, attached: Boolean(this.connected && state), mode: 'desktop',
259
+ ownerClientId: thread?.ownerClientId || null, connectionId: this.connected ? this.clientId : null,
260
+ turnId: thread?.turnId || null, revision: thread?.revision ?? null,
261
+ permissionMode: state?.currentPermissions?.sandboxPolicy?.type || state?.latestThreadSettings?.sandboxPolicy?.type || 'desktop-inherited',
262
+ model: state?.latestThreadSettings?.model || state?.latestModel || null,
263
+ effort: state?.latestThreadSettings?.effort ?? state?.latestReasoningEffort ?? null,
264
+ context: desktopContext(state),
265
+ messages: state ? desktopMessages(state) : [],
266
+ approvals: [...this.approvals.values()].filter(request => request.params.threadId === id).map(request => ({ ...request.params, requestId: String(request.id), method: request.method }))
267
+ };
268
+ }
269
+
270
+ publish(id) { this.emit('event', { method: 'orbit/desktop', params: this.runtime(id) }); }
271
+
272
+ syncApprovals(id, thread) {
273
+ const previous = new Set([...this.approvals.values()].filter(request => request.params.threadId === id).map(request => request.id));
274
+ for (const request of Object.values(thread.state.requests || {})) {
275
+ if (!approvalMethods.has(request.method) || request.id === undefined || request.params?.threadId !== id) continue;
276
+ const key = `${id}:${request.id}`;
277
+ previous.delete(key);
278
+ this.approvals.set(key, { ...request, originalId: request.id, id: key });
279
+ }
280
+ for (const key of previous) this.approvals.delete(key);
281
+ }
282
+
283
+ async assertOwner(id) {
284
+ const thread = this.threads.get(id);
285
+ if (!this.connected || !thread?.state) throw new Error('桌面实时状态未就绪,请重新连接并授权。');
286
+ let owner;
287
+ try { owner = await this.request('thread-owner-discovery', { conversationId: id, hostId: 'local' }); }
288
+ catch (error) {
289
+ if (this.threads.get(id) === thread) this.detach(id, error.message, error.code);
290
+ throw error;
291
+ }
292
+ if (owner.ownerClientId !== thread.ownerClientId || this.threads.get(id) !== thread || !thread.state) {
293
+ const error = new DesktopUnavailableError('会话执行者发生变化,未发送指令。请重新连接并授权。', 'DESKTOP_OWNER_CHANGED');
294
+ this.detach(id, error.message, error.code);
295
+ throw error;
296
+ }
297
+ return thread;
298
+ }
299
+
300
+ async startTurn(id, text) { return this.writeMessage(id, text, false); }
301
+ async steer(id, text, expectedTurnId) { return this.writeMessage(id, text, true, expectedTurnId); }
302
+
303
+ async updateThreadSettings(id, model, effort, expectedModel, expectedEffort) {
304
+ if (this.starting.has(id) || this.updatingSettings.has(id)) throw new Error('会话正在处理另一项操作,请稍后重试。');
305
+ this.updatingSettings.add(id);
306
+ try {
307
+ const thread = await this.assertOwner(id);
308
+ if (thread.turnId) throw new Error('当前任务正在执行,请将参数排到下一轮。');
309
+ const currentModel = thread.state.latestThreadSettings?.model || thread.state.latestModel || null;
310
+ const currentEffort = thread.state.latestThreadSettings?.effort ?? thread.state.latestReasoningEffort ?? null;
311
+ if (currentModel !== expectedModel || currentEffort !== expectedEffort) throw new Error('模型设置已变化,请刷新后重试。');
312
+ const result = await this.request('thread-follower-update-thread-settings', {
313
+ conversationId: id, threadSettings: { model, effort }, activeTurnId: null,
314
+ condition: { ifModelEquals: thread.state.latestModel || currentModel, ifEffortEquals: thread.state.latestReasoningEffort ?? null }
315
+ }, thread.ownerClientId);
316
+ if (result.applied !== true) throw new Error('桌面模型设置已变化,未应用选择,请刷新后重试。');
317
+ const confirmed = await this.request('thread-follower-read-model-settings', { conversationId: id }, thread.ownerClientId);
318
+ if (confirmed.settings?.model !== model || confirmed.settings?.reasoningEffort !== effort) {
319
+ throw new Error('桌面尚未确认模型设置,请刷新会话核对后再发送。');
320
+ }
321
+ return { model, effort, applied: true };
322
+ } finally { this.updatingSettings.delete(id); }
323
+ }
324
+
325
+ async writeMessage(id, text, steering, expectedTurnId) {
326
+ if (this.starting.has(id) || this.updatingSettings.has(id)) throw new Error('此会话正在处理其他操作,请勿并发提交。');
327
+ this.starting.add(id);
328
+ try {
329
+ const thread = await this.assertOwner(id);
330
+ if (steering ? !expectedTurnId || thread.turnId !== expectedTurnId : Boolean(thread.turnId)) throw new Error('任务状态已变化,请刷新后选择续聊或追加指令。');
331
+ const messageId = crypto.randomUUID();
332
+ const input = [{ type: 'text', text, text_elements: [] }];
333
+ const restoreMessage = { id: messageId, text, cwd: thread.state.cwd, createdAt: Date.now(), context: { prompt: text, addedFiles: [], fileAttachments: [], imageAttachments: [], ideContext: null, workspaceRoots: [thread.state.cwd] } };
334
+ let result;
335
+ if (steering) result = await this.request('thread-follower-steer-turn', { conversationId: id, input, restoreMessage, clientUserMessageId: messageId, attachments: [] }, thread.ownerClientId);
336
+ else result = await this.request('thread-follower-start-turn', { conversationId: id, turnStart: { request: { threadId: id, input, clientUserMessageId: messageId, turnTrigger: 'composer' }, context: { restoreMessage } } }, thread.ownerClientId);
337
+ const response = result.result || result;
338
+ const turnId = response.turnId || response.turn?.id || this.threads.get(id)?.turnId || null;
339
+ return { threadId: id, turnId, completed: typeof response.completed === 'boolean' ? response.completed : Boolean(!steering && !turnId), steering, sharedExecution: true };
340
+ } finally { this.starting.delete(id); }
341
+ }
342
+
343
+ async interrupt(id, expectedTurnId) {
344
+ const thread = await this.assertOwner(id);
345
+ if (!expectedTurnId || thread.turnId !== expectedTurnId) throw new Error('活动任务已变化,未停止其他任务。');
346
+ const result = await this.request('thread-follower-interrupt-turn', { conversationId: id, mode: 'user-stop', expectedTurnId }, thread.ownerClientId);
347
+ return result;
348
+ }
349
+
350
+ async replyApproval(key, body) {
351
+ const request = this.approvals.get(String(key));
352
+ if (!request) throw new Error('审批已被桌面处理或已失效。');
353
+ const thread = await this.assertOwner(request.params.threadId);
354
+ if (this.approvals.get(String(key)) !== request) throw new Error('审批状态已变化,请刷新。');
355
+ let payload;
356
+ if (request.method === 'item/tool/requestUserInput') {
357
+ const answers = {};
358
+ for (const question of request.params.questions || []) {
359
+ const answer = body.answers?.[question.id];
360
+ if (typeof answer !== 'string' || !answer.trim() || answer.length > 4000) throw new Error('请回答所有问题。');
361
+ answers[question.id] = { answers: [answer] };
362
+ }
363
+ payload = { response: { answers } };
364
+ } else if (request.method === 'item/permissions/requestApproval') {
365
+ if (body.decision !== 'decline') throw new Error('扩大环境权限仍需在桌面处理。');
366
+ payload = { response: { permissions: {}, scope: 'turn' } };
367
+ } else {
368
+ if (!['accept', 'decline'].includes(body.decision)) throw new Error('无效审批决定。');
369
+ payload = { decision: body.decision };
370
+ }
371
+ await this.request(approvalMethods.get(request.method), { conversationId: request.params.threadId, requestId: request.originalId, ...payload }, thread.ownerClientId);
372
+ this.approvals.delete(String(key));
373
+ this.publish(request.params.threadId);
374
+ return { resolved: true };
375
+ }
376
+
377
+ detach(id, reason, reasonCode = 'DESKTOP_THREAD_UNAVAILABLE') {
378
+ const thread = this.threads.get(id);
379
+ if (!thread) return;
380
+ if (this.socket?.writable) {
381
+ try { this.follow(id, thread.ownerClientId, false); } catch {}
382
+ }
383
+ this.threads.delete(id);
384
+ for (const [key, request] of this.approvals) if (request.params.threadId === id) this.approvals.delete(key);
385
+ this.emit('lost', id, reason, reasonCode);
386
+ this.emit('event', { method: 'orbit/desktop', params: { threadId: id, mode: 'desktop', attached: false, turnId: null, reason, reasonCode } });
387
+ }
388
+
389
+ disconnect(error, socket = this.socket) {
390
+ if (socket && socket !== this.socket) return;
391
+ this.connected = false;
392
+ this.socket = null;
393
+ socket?.destroy();
394
+ for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
395
+ this.pending.clear();
396
+ for (const id of this.threads.keys()) this.detach(id, error.message);
397
+ this.approvals.clear();
398
+ this.buffer = Buffer.alloc(0);
399
+ this.clientId = 'initializing-client';
400
+ this.emit('event', { method: 'orbit/engine', params: { connected: false, mode: 'desktop' } });
401
+ }
402
+
403
+ close() { this.disconnect(new Error('Orbit 已断开;桌面任务继续执行。')); }
404
+ }
@@ -0,0 +1,69 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ const digest = value => crypto.createHash('sha256').update(value).digest('hex');
4
+
5
+ export class DeviceAuthorization {
6
+ constructor({ lifetime = 5 * 60 * 1000, now = Date.now } = {}) {
7
+ this.lifetime = lifetime;
8
+ this.now = now;
9
+ this.pending = new Map();
10
+ this.codes = new Map();
11
+ }
12
+
13
+ cleanup() {
14
+ for (const [key, value] of this.pending) {
15
+ if (value.expiresAt > this.now()) continue;
16
+ this.pending.delete(key);
17
+ this.codes.delete(value.userCode);
18
+ }
19
+ }
20
+
21
+ start(name) {
22
+ this.cleanup();
23
+ if (typeof name !== 'string' || !name.trim() || name.length > 120 || /[\u0000-\u001f\u007f]/.test(name)) {
24
+ throw new Error('电脑名称格式无效。');
25
+ }
26
+ if (this.pending.size >= 1000) throw Object.assign(new Error('连接请求过多,请稍后再试。'), {status:429});
27
+ const deviceCode = crypto.randomBytes(32).toString('base64url');
28
+ let userCode;
29
+ do { userCode = crypto.randomBytes(5).toString('hex').toUpperCase(); } while (this.codes.has(userCode));
30
+ const key = digest(deviceCode);
31
+ const entry = {name:name.trim(), userCode, expiresAt:this.now() + this.lifetime, result:null};
32
+ this.pending.set(key, entry);
33
+ this.codes.set(userCode, key);
34
+ return {deviceCode, userCode, expiresIn:Math.floor(this.lifetime / 1000), interval:2};
35
+ }
36
+
37
+ preview(userCode) {
38
+ this.cleanup();
39
+ if (typeof userCode !== 'string') return null;
40
+ const entry = this.pending.get(this.codes.get(userCode));
41
+ return entry && !entry.result ? {name:entry.name, userCode:entry.userCode, expiresAt:entry.expiresAt} : null;
42
+ }
43
+
44
+ approve(userCode, issue) {
45
+ if (!this.preview(userCode)) throw Object.assign(new Error('连接请求已失效,请在电脑重新运行 orbit login。'), {status:410});
46
+ const entry = this.pending.get(this.codes.get(userCode));
47
+ entry.result = issue(entry.name);
48
+ }
49
+
50
+ poll(deviceCode) {
51
+ this.cleanup();
52
+ if (typeof deviceCode !== 'string' || !/^[a-zA-Z0-9_-]{43}$/.test(deviceCode)) return {status:410};
53
+ const key = digest(deviceCode);
54
+ const entry = this.pending.get(key);
55
+ if (!entry) return {status:410};
56
+ if (!entry.result) return {status:202};
57
+ return {status:200, result:structuredClone(entry.result)};
58
+ }
59
+
60
+ acknowledge(deviceCode) {
61
+ if (typeof deviceCode !== 'string' || !/^[a-zA-Z0-9_-]{43}$/.test(deviceCode)) return false;
62
+ const key = digest(deviceCode);
63
+ const entry = this.pending.get(key);
64
+ if (!entry?.result) return false;
65
+ this.pending.delete(key);
66
+ this.codes.delete(entry.userCode);
67
+ return true;
68
+ }
69
+ }