orbit-codex-bridge 0.1.3 → 0.1.7
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 +23 -5
- package/cli.mjs +20 -3
- package/lib/approvals.mjs +97 -0
- package/lib/client-files.mjs +37 -0
- package/lib/connector-service.mjs +7 -3
- package/lib/desktop.mjs +28 -25
- package/lib/resume-manager.mjs +209 -0
- package/lib/rpc.mjs +68 -23
- package/lib/store.mjs +6 -0
- package/lib/updates.mjs +97 -0
- package/package.json +19 -18
- package/relay-agent.mjs +2 -4
- package/scripts/verify-client-package.mjs +34 -0
- package/server.mjs +186 -142
- package/lib/device-auth.mjs +0 -69
- package/public/admin.css +0 -67
- package/public/admin.html +0 -23
- package/public/admin.js +0 -186
- package/public/app.js +0 -1248
- package/public/connect.html +0 -56
- package/public/connect.js +0 -126
- package/public/icon.svg +0 -1
- package/public/index.html +0 -58
- package/public/layout.css +0 -73
- package/public/manifest.webmanifest +0 -1
- package/public/style.css +0 -70
- package/relay-server.mjs +0 -703
- package/relay-store.mjs +0 -368
package/server.mjs
CHANGED
|
@@ -5,13 +5,15 @@ import os from 'node:os';
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
8
|
-
import QRCode from 'qrcode';
|
|
9
8
|
import { NativeStore } from './lib/store.mjs';
|
|
10
9
|
import { CodexRpc } from './lib/rpc.mjs';
|
|
11
10
|
import { DesktopRpc } from './lib/desktop.mjs';
|
|
11
|
+
import { ResumeManager } from './lib/resume-manager.mjs';
|
|
12
|
+
import { createUpdateChecker } from './lib/updates.mjs';
|
|
12
13
|
|
|
13
14
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const
|
|
15
|
+
const packageManifest = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
|
|
16
|
+
const packageVersion = packageManifest.version;
|
|
15
17
|
const sessionLifetime = 12 * 60 * 60 * 1000;
|
|
16
18
|
const attachmentLifetime = 30 * 60 * 1000;
|
|
17
19
|
const attachmentRetention = 24 * 60 * 60 * 1000;
|
|
@@ -46,107 +48,9 @@ function cleanupStaleAttachments(store) {
|
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
const excludedProjectEntries=new Set([
|
|
50
|
-
'.git','.codex','.orbit-attachments','node_modules','.venv','venv','dist','build',
|
|
51
|
-
'.ssh','.aws','.azure','.config','.npm','.docker','secrets','credentials'
|
|
52
|
-
]);
|
|
53
|
-
const excludedProjectFiles=/^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.git-credentials|id_(?:rsa|ed25519)(?:\.pub)?|.*(?:secret|credential)(?:\..*)?|.*\.(?:pem|p12|pfx|key|keystore))$/i;
|
|
54
|
-
|
|
55
|
-
function checkedProjectEntry(row,relativePath=''){
|
|
56
|
-
if(!row?.cwd||typeof relativePath!=='string'||relativePath.length>1024||relativePath.includes('\0'))throw new Error('项目文件路径无效。');
|
|
57
|
-
const root=fs.realpathSync(row.cwd);
|
|
58
|
-
if(!fs.statSync(root).isDirectory())throw new Error('会话工作目录不可用。');
|
|
59
|
-
const parts=relativePath?relativePath.split(/[\\/]/):[];
|
|
60
|
-
if(parts.some(part=>!part||part==='.'||part==='..'))throw new Error('不允许访问项目目录之外的路径。');
|
|
61
|
-
let current=root;
|
|
62
|
-
for(const part of parts){
|
|
63
|
-
if(excludedProjectEntries.has(part.toLowerCase())||excludedProjectFiles.test(part))throw new Error('此系统目录或凭证文件不开放传输。');
|
|
64
|
-
current=path.join(current,part);
|
|
65
|
-
const stat=fs.lstatSync(current);
|
|
66
|
-
if(stat.isSymbolicLink())throw new Error('不支持通过符号链接传输文件。');
|
|
67
|
-
}
|
|
68
|
-
const resolved=path.resolve(current);
|
|
69
|
-
const relative=path.relative(root,resolved);
|
|
70
|
-
if(relative==='..'||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative))throw new Error('不允许访问项目目录之外的路径。');
|
|
71
|
-
return {root,path:resolved,stat:fs.statSync(resolved),relative:parts.join(path.sep)};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function projectFileList(row,relativePath=''){
|
|
75
|
-
const directory=checkedProjectEntry(row,relativePath);
|
|
76
|
-
if(!directory.stat.isDirectory())throw new Error('所选路径不是文件夹。');
|
|
77
|
-
const entries=[];
|
|
78
|
-
const visit=(current,relative,depth)=>{
|
|
79
|
-
if(depth>8||entries.length>=1000)return;
|
|
80
|
-
for(const name of fs.readdirSync(current)){
|
|
81
|
-
if(excludedProjectEntries.has(name.toLowerCase())||excludedProjectFiles.test(name))continue;
|
|
82
|
-
const target=path.join(current,name);
|
|
83
|
-
let stat;
|
|
84
|
-
try{stat=fs.lstatSync(target)}catch{continue}
|
|
85
|
-
if(stat.isSymbolicLink())continue;
|
|
86
|
-
const child=relative?`${relative}/${name}`:name;
|
|
87
|
-
if(stat.isDirectory())entries.push({name,path:child,type:'directory',size:null,updatedAt:stat.mtimeMs});
|
|
88
|
-
else if(stat.isFile())entries.push({name,path:child,type:'file',size:stat.size,updatedAt:stat.mtimeMs});
|
|
89
|
-
if(stat.isDirectory())visit(target,child,depth+1);
|
|
90
|
-
if(entries.length>=1000)break;
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
visit(directory.path,directory.relative,0);
|
|
94
|
-
entries.sort((left,right)=>left.type===right.type?left.name.localeCompare(right.name):left.type==='directory'?-1:1);
|
|
95
|
-
return {path:directory.relative,entries:entries.slice(0,1000),truncated:entries.length>=1000};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const projectMimeTypes=new Map([
|
|
99
|
-
['.txt','text/plain; charset=utf-8'],['.md','text/markdown; charset=utf-8'],['.json','application/json'],
|
|
100
|
-
['.csv','text/csv; charset=utf-8'],['.html','text/html; charset=utf-8'],['.css','text/css; charset=utf-8'],
|
|
101
|
-
['.js','text/javascript; charset=utf-8'],['.ts','text/plain; charset=utf-8'],['.xml','application/xml'],
|
|
102
|
-
['.pdf','application/pdf'],['.png','image/png'],['.jpg','image/jpeg'],['.jpeg','image/jpeg'],['.gif','image/gif'],
|
|
103
|
-
['.webp','image/webp'],['.svg','image/svg+xml'],['.zip','application/zip']
|
|
104
|
-
]);
|
|
105
|
-
|
|
106
|
-
function sendFileRange(request,response,row,relativePath){
|
|
107
|
-
const file=checkedProjectEntry(row,relativePath);
|
|
108
|
-
if(!file.stat.isFile())throw new Error('所选路径不是文件。');
|
|
109
|
-
if(file.stat.size>8*1024*1024)throw Object.assign(new Error('单个文件下载不得超过 8 MiB。'),{status:413});
|
|
110
|
-
const filename=path.basename(file.path);
|
|
111
|
-
const fallback=filename.replace(/[^\x20-\x7e]|["\\;]/g,'_');
|
|
112
|
-
const encoded=encodeURIComponent(filename).replace(/[!'()*]/g,character=>`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
113
|
-
const etag=`"orbit-${file.stat.size}-${Math.trunc(file.stat.mtimeMs)}"`;
|
|
114
|
-
const headers={
|
|
115
|
-
'Content-Type':projectMimeTypes.get(path.extname(filename).toLowerCase())||'application/octet-stream',
|
|
116
|
-
'Accept-Ranges':'bytes',
|
|
117
|
-
'ETag':etag,
|
|
118
|
-
'Content-Disposition':`attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
|
|
119
|
-
'X-Content-Type-Options':'nosniff'
|
|
120
|
-
};
|
|
121
|
-
if(request.headers['if-none-match']===etag){response.writeHead(304,headers);return response.end()}
|
|
122
|
-
const ifRange=request.headers['if-range'];
|
|
123
|
-
const range=ifRange&&ifRange!==etag?null:request.headers.range;
|
|
124
|
-
if(!range){
|
|
125
|
-
response.writeHead(200,{...headers,'Content-Length':file.stat.size});
|
|
126
|
-
if(request.method==='HEAD')return response.end();
|
|
127
|
-
return fs.createReadStream(file.path).pipe(response);
|
|
128
|
-
}
|
|
129
|
-
const match=String(range).match(/^bytes=(\d*)-(\d*)$/);
|
|
130
|
-
if(!match||(!match[1]&&!match[2])){response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end()}
|
|
131
|
-
let start,end;
|
|
132
|
-
if(!match[1]){
|
|
133
|
-
const suffix=Number(match[2]);
|
|
134
|
-
if(!Number.isSafeInteger(suffix)||suffix<=0)return response.writeHead(416,{'Content-Range':`bytes */${file.stat.size}`}).end();
|
|
135
|
-
start=Math.max(0,file.stat.size-suffix);end=file.stat.size-1;
|
|
136
|
-
}else{
|
|
137
|
-
start=Number(match[1]);end=match[2]?Number(match[2]):file.stat.size-1;
|
|
138
|
-
}
|
|
139
|
-
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||start>=file.stat.size||end<start){
|
|
140
|
-
response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end();
|
|
141
|
-
}
|
|
142
|
-
end=Math.min(end,file.stat.size-1);
|
|
143
|
-
const length=end-start+1;
|
|
144
|
-
response.writeHead(206,{...headers,'Content-Length':length,'Content-Range':`bytes ${start}-${end}/${file.stat.size}`});
|
|
145
|
-
if(request.method==='HEAD')return response.end();
|
|
146
|
-
fs.createReadStream(file.path,{start,end}).pipe(response);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
51
|
export function createOrbitServer(options = {}) {
|
|
52
|
+
const clientOnly=options.clientOnly ?? packageManifest.orbitDistribution==='client';
|
|
53
|
+
const updateChecker=options.updateChecker || createUpdateChecker(packageVersion);
|
|
150
54
|
const home = options.home || process.env.ORBIT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
151
55
|
const dataDir = options.dataDir || path.join(root, '.runtime');
|
|
152
56
|
fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
@@ -156,8 +60,30 @@ export function createOrbitServer(options = {}) {
|
|
|
156
60
|
const store = options.store || new NativeStore(home);
|
|
157
61
|
cleanupStaleAttachments(store);
|
|
158
62
|
const endpoint = options.endpoint ?? process.env.ORBIT_APP_SERVER_URL ?? '';
|
|
159
|
-
const
|
|
160
|
-
const
|
|
63
|
+
const injectedRpc = options.rpc || options.desktopRpc || null;
|
|
64
|
+
const rpc = injectedRpc || new DesktopRpc();
|
|
65
|
+
const executionMode = rpc.mode || (injectedRpc ? endpoint ? 'endpoint' : 'cold-resume' : 'desktop');
|
|
66
|
+
const resumeManager = options.resumeManager || (executionMode === 'desktop' && (!injectedRpc || options.enableResumeFallback === true)
|
|
67
|
+
? new ResumeManager({
|
|
68
|
+
home,
|
|
69
|
+
createRpc:options.createResumeRpc || (({ home:resumeHome }) => new CodexRpc({
|
|
70
|
+
home:resumeHome, endpoint, tokenFile:process.env.ORBIT_APP_SERVER_TOKEN_FILE
|
|
71
|
+
}))
|
|
72
|
+
})
|
|
73
|
+
: null);
|
|
74
|
+
function runtimeForThread(id) {
|
|
75
|
+
return resumeManager?.runtime(id) || rpc.runtime?.(id) || null;
|
|
76
|
+
}
|
|
77
|
+
async function attachThread(id) {
|
|
78
|
+
if (executionMode !== 'desktop') return null;
|
|
79
|
+
if (resumeManager?.has(id)) return resumeManager.resume(id);
|
|
80
|
+
try { return await rpc.attach(id); }
|
|
81
|
+
catch (error) {
|
|
82
|
+
const row = store.row(id);
|
|
83
|
+
if (!resumeManager || !row || row.archived || String(row.source).startsWith('{')) throw error;
|
|
84
|
+
return resumeManager.resume(id, { model:row.model || null });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
161
87
|
const listModels = options.listModels || (async () => {
|
|
162
88
|
const catalogRpc = new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE });
|
|
163
89
|
try {
|
|
@@ -192,6 +118,21 @@ export function createOrbitServer(options = {}) {
|
|
|
192
118
|
}).finally(() => { modelLoading = null; });
|
|
193
119
|
return modelLoading;
|
|
194
120
|
}
|
|
121
|
+
function workspaceCatalog() {
|
|
122
|
+
const workspaces=new Map();
|
|
123
|
+
workspaces.set('personal',{id:'personal',cwd:path.join(dataDir,'workspaces','personal'),projectId:null,label:'个人会话 · 不绑定项目',isDefault:true});
|
|
124
|
+
const add=(directory,label,projectId=null)=>{
|
|
125
|
+
try{
|
|
126
|
+
const resolved=fs.realpathSync(directory);
|
|
127
|
+
if(!fs.statSync(resolved).isDirectory())return;
|
|
128
|
+
const key=`${projectId||''}:${process.platform==='win32'?resolved.toLowerCase():resolved}`;
|
|
129
|
+
if(!workspaces.has(key))workspaces.set(key,{id:key,cwd:resolved,label,projectId,isDefault:false});
|
|
130
|
+
}catch{}
|
|
131
|
+
};
|
|
132
|
+
for(const workspace of store.projectWorkspaces?.()||[])add(workspace.cwd,workspace.label,workspace.projectId);
|
|
133
|
+
for(const thread of store.list())if(thread.cwd)add(thread.cwd,thread.projectName||path.basename(thread.cwd),thread.nativeProjectId||null);
|
|
134
|
+
return [...workspaces.values()].sort((left,right)=>Number(right.isDefault)-Number(left.isDefault)||left.label.localeCompare(right.label));
|
|
135
|
+
}
|
|
195
136
|
const publicUrl = options.publicUrl || process.env.ORBIT_PUBLIC_URL || '';
|
|
196
137
|
if (publicUrl && new URL(publicUrl).protocol !== 'https:') throw new Error('ORBIT_PUBLIC_URL 必须使用 HTTPS。');
|
|
197
138
|
const host = options.host || process.env.ORBIT_HOST || '127.0.0.1';
|
|
@@ -206,6 +147,7 @@ export function createOrbitServer(options = {}) {
|
|
|
206
147
|
let port = Number(options.port ?? process.env.ORBIT_PORT ?? 4318);
|
|
207
148
|
let revision = store.revision();
|
|
208
149
|
let timer;
|
|
150
|
+
let updateTimer;
|
|
209
151
|
let lastDesktopConnect = 0;
|
|
210
152
|
let lastAttachmentCleanup=Date.now();
|
|
211
153
|
|
|
@@ -266,7 +208,8 @@ export function createOrbitServer(options = {}) {
|
|
|
266
208
|
if(!row||row.archived||String(row.source).startsWith('{'))return json(response,400,{error:'归档会话或子代理不能接收文件。'});
|
|
267
209
|
if(executionMode!=='desktop'||!rpc.connected)return json(response,409,{error:'文件传输仅支持已连接的 Codex 桌面原会话。'});
|
|
268
210
|
if(!controlFor(session,threadId))return json(response,403,{error:'请先连接并接管这条原会话。'});
|
|
269
|
-
|
|
211
|
+
const runtime=runtimeForThread(threadId);
|
|
212
|
+
if(!runtime?.attached||runtime.mode==='same-id-resume')return json(response,409,{error:'该会话当前通过原 ID 接续运行;文件传输需要桌面热接管。'});
|
|
270
213
|
for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
|
|
271
214
|
if([...session.attachments.values()].filter(item=>item.threadId===threadId&&!item.consumed).length>=maxPendingAttachments)return json(response,429,{error:`这条会话最多可暂存 ${maxPendingAttachments} 个待发送文件。`});
|
|
272
215
|
const rawName=url.searchParams.get('name')||'';
|
|
@@ -306,15 +249,16 @@ export function createOrbitServer(options = {}) {
|
|
|
306
249
|
|
|
307
250
|
function status(session) {
|
|
308
251
|
const threads = store.list();
|
|
252
|
+
const resumedIds = resumeManager?.ids() || [];
|
|
309
253
|
for (const id of session.enabled.keys()) controlFor(session, id);
|
|
310
254
|
return {
|
|
311
|
-
product: 'Orbit', version: packageVersion, device: os.hostname(), platform: process.platform, home,
|
|
255
|
+
product: 'Orbit', version: packageVersion, updates:updateChecker.snapshot(), device: os.hostname(), platform: process.platform, home,
|
|
312
256
|
threadCount: threads.length, projectCount: new Set(threads.map(thread => thread.projectId)).size,
|
|
313
257
|
archivedCount: threads.filter(thread => thread.archived).length, lockedCount: threads.filter(thread => thread.locked).length,
|
|
314
258
|
bridge: 'connected', transport: 'websocket', readOnlyMirror: true,
|
|
315
259
|
desktopHotAttachReady: executionMode === 'desktop' && rpc.connected,
|
|
316
260
|
desktopHotAttachVerified: executionMode === 'desktop' && [...rpc.threads.values()].some(thread => thread.state),
|
|
317
|
-
execution: { connected: rpc.connected, mode: executionMode, enabled: [...session.enabled.keys()], grants: Object.fromEntries(session.enabled), owned: [...rpc.threads.keys()] },
|
|
261
|
+
execution: { connected: Boolean(rpc.connected || resumedIds.length), desktopConnected:Boolean(rpc.connected), resumedConnected:Boolean(resumedIds.length), mode: executionMode, enabled: [...session.enabled.keys()], grants: Object.fromEntries(session.enabled), owned: [...new Set([...rpc.threads.keys(), ...resumedIds])] },
|
|
318
262
|
connectionUrls: [publicUrl || `http://127.0.0.1:${port}`, ...(host === '0.0.0.0' ? lanAddresses().map(address => `http://${address}:${port}`) : [])],
|
|
319
263
|
secureTransport: Boolean(publicUrl), lanEnabled: host === '0.0.0.0'
|
|
320
264
|
};
|
|
@@ -333,6 +277,7 @@ export function createOrbitServer(options = {}) {
|
|
|
333
277
|
}
|
|
334
278
|
|
|
335
279
|
async function applyQueuedDesktopSettings(id, runtime) {
|
|
280
|
+
if(executionMode!=='desktop'||resumeManager?.has(id))return;
|
|
336
281
|
if (!runtime?.attached || runtime.turnId || queuedSettingsInFlight.has(id)) return;
|
|
337
282
|
queuedSettingsInFlight.add(id);
|
|
338
283
|
try {
|
|
@@ -371,7 +316,8 @@ export function createOrbitServer(options = {}) {
|
|
|
371
316
|
if (count > 1) desktopWatchers.set(id, count - 1);
|
|
372
317
|
else {
|
|
373
318
|
desktopWatchers.delete(id);
|
|
374
|
-
|
|
319
|
+
if (resumeManager?.has(id)) void resumeManager.release(id).catch(() => {});
|
|
320
|
+
else rpc.detach(id, '已取消实时跟随。');
|
|
375
321
|
}
|
|
376
322
|
}
|
|
377
323
|
|
|
@@ -420,7 +366,9 @@ export function createOrbitServer(options = {}) {
|
|
|
420
366
|
const session = sessionFor(request);
|
|
421
367
|
if (!session) return json(response, 401, { error: '请先配对这台电脑。' });
|
|
422
368
|
if (request.method === 'GET' && url.pathname === '/api/status') return json(response, 200, status(session));
|
|
369
|
+
if (request.method === 'GET' && url.pathname === '/api/updates') return json(response, 200, await updateChecker.check({force:true}));
|
|
423
370
|
if (request.method === 'GET' && url.pathname === '/api/threads') return json(response, 200, { threads: store.list(), revision });
|
|
371
|
+
if (request.method === 'GET' && url.pathname === '/api/workspaces') return json(response, 200, { workspaces:workspaceCatalog() });
|
|
424
372
|
if (request.method === 'GET' && url.pathname === '/api/models') return json(response, 200, { models: await modelCatalog() });
|
|
425
373
|
if (request.method === 'POST' && url.pathname === '/api/logout') {
|
|
426
374
|
sessions.delete(session.key);
|
|
@@ -429,9 +377,11 @@ export function createOrbitServer(options = {}) {
|
|
|
429
377
|
return json(response, 200, { ok: true }, { 'Set-Cookie': 'orbit_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
|
|
430
378
|
}
|
|
431
379
|
if (request.method === 'POST' && url.pathname === '/api/pair/qr') {
|
|
380
|
+
if(clientOnly)return json(response,404,{error:'电脑连接器不提供网页配对,请在工作台登录账号。'});
|
|
432
381
|
const body = await readBody(request);
|
|
433
382
|
const connection = status(session).connectionUrls.find(value => value === body.url);
|
|
434
383
|
if (!connection) return json(response, 400, { error: '请选择桥接服务提供的连接地址。' });
|
|
384
|
+
const {default:QRCode}=await import('qrcode');
|
|
435
385
|
return json(response, 200, { image: await QRCode.toDataURL(`${connection}/#pair=${encodeURIComponent(pairingKey)}`, { width: 280, margin: 2 }), warning: '扫码者将能查看本机会话。仅向可信设备展示。' });
|
|
436
386
|
}
|
|
437
387
|
const uploadMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments$/);
|
|
@@ -445,18 +395,6 @@ export function createOrbitServer(options = {}) {
|
|
|
445
395
|
removeAttachment(session,body.attachmentId);
|
|
446
396
|
return json(response,200,{removed:true});
|
|
447
397
|
}
|
|
448
|
-
const filesMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/files$/);
|
|
449
|
-
if(request.method==='GET'&&filesMatch){
|
|
450
|
-
const row=store.row(filesMatch[1]);
|
|
451
|
-
if(!row)return json(response,404,{error:'会话不存在。'});
|
|
452
|
-
return json(response,200,projectFileList(row,url.searchParams.get('path')||''));
|
|
453
|
-
}
|
|
454
|
-
const fileMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/file$/);
|
|
455
|
-
if(request.method==='GET'&&fileMatch){
|
|
456
|
-
const row=store.row(fileMatch[1]);
|
|
457
|
-
if(!row)return json(response,404,{error:'会话不存在。'});
|
|
458
|
-
return sendFileRange(request,response,row,url.searchParams.get('path')||'');
|
|
459
|
-
}
|
|
460
398
|
const match = url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/messages$/);
|
|
461
399
|
if (request.method === 'GET' && match) {
|
|
462
400
|
const history = store.historyFor(match[1], url.searchParams.get('before'), url.searchParams.get('limit') || 60);
|
|
@@ -464,6 +402,7 @@ export function createOrbitServer(options = {}) {
|
|
|
464
402
|
}
|
|
465
403
|
return json(response, 404, { error: '接口不存在。' });
|
|
466
404
|
}
|
|
405
|
+
if(clientOnly)return json(response,404,{error:'这是 Orbit 电脑连接器。请在 Orbit 工作台登录并选择这台电脑。'});
|
|
467
406
|
if (request.method !== 'GET' && request.method !== 'HEAD') return json(response, 405, { error: '不支持的方法。' });
|
|
468
407
|
let file;
|
|
469
408
|
let type;
|
|
@@ -472,7 +411,7 @@ export function createOrbitServer(options = {}) {
|
|
|
472
411
|
file = path.join(root, relative);
|
|
473
412
|
type = mime;
|
|
474
413
|
} else {
|
|
475
|
-
const files = { '/': ['index.html','text/html'], '/app.js': ['app.js','text/javascript'], '/style.css': ['style.css','text/css'], '/layout.css': ['layout.css','text/css'], '/icon.svg': ['icon.svg','image/svg+xml'], '/manifest.webmanifest': ['manifest.webmanifest','application/manifest+json'] };
|
|
414
|
+
const files = { '/': ['index.html','text/html'], '/app.js': ['app.js','text/javascript'], '/style.css': ['style.css','text/css'], '/layout.css': ['layout.css','text/css'], '/goal.css':['goal.css','text/css'], '/icon.svg': ['icon.svg','image/svg+xml'], '/manifest.webmanifest': ['manifest.webmanifest','application/manifest+json'] };
|
|
476
415
|
if (!files[url.pathname]) return json(response, 404, { error: '页面不存在。' });
|
|
477
416
|
const [name, mime] = files[url.pathname]; file = path.join(root, 'public', name); type = mime;
|
|
478
417
|
}
|
|
@@ -521,9 +460,34 @@ export function createOrbitServer(options = {}) {
|
|
|
521
460
|
async function command(socket, session, message) {
|
|
522
461
|
const body = message.params || {};
|
|
523
462
|
const id = body.threadId;
|
|
463
|
+
if (message.method === 'thread.create') {
|
|
464
|
+
if (executionMode !== 'desktop' || !resumeManager) throw new Error('当前执行通道不支持在本机 Codex 会话库创建会话。');
|
|
465
|
+
if (typeof body.text !== 'string' || !body.text.trim() || body.text.length > 20000) throw new Error('新会话的首条消息不能为空,且不得超过 20000 个字符。');
|
|
466
|
+
if (!['default','plan'].includes(body.mode || 'default')) throw new Error('新会话模式无效。');
|
|
467
|
+
const workspace=workspaceCatalog().find(entry=>entry.id===body.workspaceId);
|
|
468
|
+
if(!workspace)throw new Error('项目空间不可用,请刷新后重新选择。');
|
|
469
|
+
if(workspace.isDefault)fs.mkdirSync(workspace.cwd,{recursive:true,mode:0o700});
|
|
470
|
+
let model=null;
|
|
471
|
+
if(body.model!==undefined&&body.model!==null){
|
|
472
|
+
if(typeof body.model!=='string')throw new Error('模型选择无效。');
|
|
473
|
+
model=(await modelCatalog()).find(entry=>entry.model===body.model)?.model||null;
|
|
474
|
+
if(!model)throw new Error('所选模型不可用,请刷新模型列表。');
|
|
475
|
+
}
|
|
476
|
+
const created=await resumeManager.startNew({cwd:workspace.cwd,projectId:workspace.projectId,model,text:body.text.trim(),writable:true,mode:body.mode||'default',onCreated:threadId=>{
|
|
477
|
+
unwatchDesktop(socket);
|
|
478
|
+
session.enabled.set(threadId,{sharedExecution:false,desktop:false,writable:true,expires:Date.now()+1800000});
|
|
479
|
+
if(socket.readyState!==WebSocket.OPEN)return;
|
|
480
|
+
socket.subscription=threadId;
|
|
481
|
+
socket.desktopSubscription=threadId;
|
|
482
|
+
desktopWatchers.set(threadId,(desktopWatchers.get(threadId)||0)+1);
|
|
483
|
+
}});
|
|
484
|
+
if(!(desktopWatchers.get(created.threadId)>0))void resumeManager.release(created.threadId).catch(()=>{});
|
|
485
|
+
if(created.startError)created.startError=safeError(new Error(created.startError));
|
|
486
|
+
return created;
|
|
487
|
+
}
|
|
524
488
|
if (message.method === 'thread.subscribe') {
|
|
525
489
|
const row = id ? store.row(id) : null;
|
|
526
|
-
if (id && !row) throw new Error('会话不存在。');
|
|
490
|
+
if (id && !row && !resumeManager?.has(id)) throw new Error('会话不存在。');
|
|
527
491
|
if (socket.desktopSubscription && socket.desktopSubscription !== (id || null)) unwatchDesktop(socket);
|
|
528
492
|
socket.subscription = id || null;
|
|
529
493
|
let runtime = null;
|
|
@@ -532,8 +496,10 @@ export function createOrbitServer(options = {}) {
|
|
|
532
496
|
socket.desktopSubscription = id;
|
|
533
497
|
desktopWatchers.set(id, (desktopWatchers.get(id) || 0) + 1);
|
|
534
498
|
}
|
|
535
|
-
try {
|
|
536
|
-
|
|
499
|
+
try {
|
|
500
|
+
runtime = await attachThread(id);
|
|
501
|
+
if (!runtime?.attached) throw new Error('原会话没有可用的 Codex 执行器。');
|
|
502
|
+
} catch (error) {
|
|
537
503
|
unwatchDesktop(socket);
|
|
538
504
|
lostListener(id,error.message,error.code);
|
|
539
505
|
runtime = {
|
|
@@ -541,14 +507,17 @@ export function createOrbitServer(options = {}) {
|
|
|
541
507
|
reason:safeError(error),reasonCode:error.code || 'DESKTOP_NOT_READY'
|
|
542
508
|
};
|
|
543
509
|
}
|
|
510
|
+
if (resumeManager?.has(id) && (desktopWatchers.get(id) || 0) === 0) void resumeManager.release(id).catch(() => {});
|
|
544
511
|
}
|
|
545
512
|
if (executionMode === 'desktop' && runtime?.attached && body.autoControl === true
|
|
546
|
-
&& !row
|
|
547
|
-
|
|
513
|
+
&& !row?.archived && !String(row?.source||'').startsWith('{')) {
|
|
514
|
+
const desktop = runtime.mode !== 'same-id-resume';
|
|
515
|
+
session.enabled.set(id, {sharedExecution:desktop,desktop,writable:!desktop&&runtime.permissionMode!=='read-only',expires:Date.now() + 1800000});
|
|
548
516
|
}
|
|
549
517
|
const control = controlFor(session, id);
|
|
550
|
-
const
|
|
551
|
-
|
|
518
|
+
const activeRpc = resumeManager?.rpcFor(id) || rpc;
|
|
519
|
+
const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...activeRpc.approvals.entries()].filter(([,request]) => request.params.threadId === id).map(([requestId,request]) => ({ ...request.params, requestId, method: request.method }))) : [];
|
|
520
|
+
return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || activeRpc.threads.get(id)?.turnId || null, approvals, attachments:[...session.attachments.values()].filter(item=>item.threadId===id&&!item.consumed&&item.expires>Date.now()).map(({id,label,size,mimeType})=>({id,label,size,mimeType})) };
|
|
552
521
|
}
|
|
553
522
|
if (message.method === 'thread.enable') {
|
|
554
523
|
const row = store.row(id);
|
|
@@ -557,12 +526,13 @@ export function createOrbitServer(options = {}) {
|
|
|
557
526
|
if (body.confirm !== true) throw new Error('必须明确确认接续方式。');
|
|
558
527
|
if (executionMode === 'desktop') {
|
|
559
528
|
let runtime;
|
|
560
|
-
try { runtime = await
|
|
529
|
+
try { runtime = await attachThread(id); }
|
|
561
530
|
catch (error) { lostListener(id,error.message,error.code); throw error; }
|
|
562
531
|
if (!runtime.attached) throw new Error('桌面实时快照未就绪,未授权控制。');
|
|
563
|
-
const
|
|
532
|
+
const desktop = runtime.mode !== 'same-id-resume';
|
|
533
|
+
const control = { sharedExecution:desktop, desktop, writable:!desktop&&runtime.permissionMode!=='read-only', expires: Date.now() + 1800000 };
|
|
564
534
|
session.enabled.set(id, control);
|
|
565
|
-
return { threadId: id, ...control, mode: 'desktop', turnId: runtime.turnId };
|
|
535
|
+
return { threadId: id, ...control, mode:runtime.mode || 'desktop', turnId: runtime.turnId };
|
|
566
536
|
}
|
|
567
537
|
let sharedExecution = false;
|
|
568
538
|
if (endpoint) {
|
|
@@ -575,11 +545,52 @@ export function createOrbitServer(options = {}) {
|
|
|
575
545
|
session.enabled.set(id, control);
|
|
576
546
|
return { threadId: id, ...control, mode: endpoint ? 'endpoint' : 'cold-resume' };
|
|
577
547
|
}
|
|
548
|
+
if (message.method === 'thread.goal.get') {
|
|
549
|
+
if (!store.row(id)) throw new Error('会话不存在。');
|
|
550
|
+
if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
|
|
551
|
+
const runtime = runtimeForThread(id);
|
|
552
|
+
if (runtime?.mode === 'same-id-resume') return { goal:runtime.goal || null };
|
|
553
|
+
if (executionMode === 'desktop' && runtime?.attached) return { goal:runtime.goal || null };
|
|
554
|
+
if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/get', { threadId:id });
|
|
555
|
+
return { goal:null };
|
|
556
|
+
}
|
|
557
|
+
if (message.method === 'thread.goal.set') {
|
|
558
|
+
const row = store.row(id);
|
|
559
|
+
if (!row || row.archived || String(row.source).startsWith('{')) throw new Error('归档会话或子代理不能设置持续目标。');
|
|
560
|
+
if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
|
|
561
|
+
if (typeof body.objective !== 'string' || !body.objective.trim() || body.objective.length > 5000) throw new Error('目标内容不能为空,且不得超过 5000 个字符。');
|
|
562
|
+
const tokenBudget = body.tokenBudget === undefined || body.tokenBudget === null ? null : body.tokenBudget;
|
|
563
|
+
if (tokenBudget !== null && (!Number.isSafeInteger(tokenBudget) || tokenBudget < 1 || tokenBudget > 10000000)) throw new Error('目标 token 预算必须在 1 至 10000000 之间。');
|
|
564
|
+
const runtime = runtimeForThread(id);
|
|
565
|
+
if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoal(id, { objective:body.objective.trim(), tokenBudget }) };
|
|
566
|
+
if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标修改需要桌面 owner 支持。草稿可以继续编辑,当前未保存。');
|
|
567
|
+
if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', {
|
|
568
|
+
threadId:id, origin:'user', objective:body.objective.trim(), status:'active', tokenBudget
|
|
569
|
+
});
|
|
570
|
+
throw new Error('当前执行通道不支持原生 Goal 写入。');
|
|
571
|
+
}
|
|
572
|
+
if (message.method === 'thread.goal.status') {
|
|
573
|
+
if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
|
|
574
|
+
if (!['active','paused','blocked','complete'].includes(body.status)) throw new Error('无效的目标状态。');
|
|
575
|
+
const runtime = runtimeForThread(id);
|
|
576
|
+
if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoalStatus(id, body.status) };
|
|
577
|
+
if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标状态修改需要桌面 owner 支持。');
|
|
578
|
+
if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', { threadId:id, origin:'user', status:body.status });
|
|
579
|
+
throw new Error('当前执行通道不支持原生 Goal 状态更新。');
|
|
580
|
+
}
|
|
581
|
+
if (message.method === 'thread.goal.clear') {
|
|
582
|
+
if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
|
|
583
|
+
const runtime = runtimeForThread(id);
|
|
584
|
+
if (runtime?.mode === 'same-id-resume') return resumeManager.clearGoal(id);
|
|
585
|
+
if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标清除需要桌面 owner 支持。');
|
|
586
|
+
if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/clear', { threadId:id, origin:'user' });
|
|
587
|
+
throw new Error('当前执行通道不支持原生 Goal 清除。');
|
|
588
|
+
}
|
|
578
589
|
if (message.method === 'turn.start') {
|
|
579
590
|
const approval = controlFor(session, id);
|
|
580
591
|
if (!approval) throw new Error('请先确认这条会话的接续方式。');
|
|
581
592
|
const mode=body.mode??'default';
|
|
582
|
-
if(!['default','plan'
|
|
593
|
+
if(!['default','plan'].includes(mode))throw new Error('目标已改为会话级原生 Goal,请使用“目标”管理;不再用提示词模拟。');
|
|
583
594
|
const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
|
|
584
595
|
if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
|
|
585
596
|
||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
|
|
@@ -590,16 +601,23 @@ export function createOrbitServer(options = {}) {
|
|
|
590
601
|
return attachment;
|
|
591
602
|
});
|
|
592
603
|
let text=body.text.trim()||(attachments.length?`请查看并处理以下附件:${attachments.map(file=>file.label).join('、')}`:'');
|
|
593
|
-
if(mode==='goal')text=`请将本轮请求作为需要完成的目标,主动推进直到完成,并汇报结果;如果受阻,请明确说明原因。\n\n本轮目标:\n${text}`;
|
|
594
|
-
if(body.mode==='plan')text=`请先分析任务并制定清晰的分步计划,说明后等待我确认;在我确认前不要执行任何修改。\n\n任务:\n${text}`;
|
|
595
604
|
if (executionMode === 'desktop') {
|
|
605
|
+
const runtime=runtimeForThread(id);
|
|
606
|
+
if(runtime?.mode==='same-id-resume'){
|
|
607
|
+
if(attachments.length)throw new Error('文件传输目前需要桌面热接管;请在电脑上打开这条原会话后重新连接。');
|
|
608
|
+
const resumedRpc=resumeManager?.rpcFor(id);
|
|
609
|
+
if(!resumedRpc)throw new Error('原会话接续连接已失效,请重新打开这条会话。');
|
|
610
|
+
const settings=session.preferences.get(id);
|
|
611
|
+
return resumedRpc.startTurn(id,text,approval.writable,settings?.pending?settings:settings||null,{mode});
|
|
612
|
+
}
|
|
596
613
|
const result=await rpc.startTurn(id,text,{mode,attachments});
|
|
597
614
|
for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
|
|
598
615
|
return result;
|
|
599
616
|
}
|
|
600
|
-
if(
|
|
617
|
+
if(attachments.length)throw new Error('文件附件仅支持 Codex 桌面热接管。');
|
|
601
618
|
if (!rpc.threads.has(id) && !endpoint && store.locked(id)) throw new Error('会话已被其他进程占用,未发送消息。');
|
|
602
|
-
|
|
619
|
+
if(mode==='plan'&&typeof rpc.startTurn!=='function')throw new Error('当前执行通道不支持原生协作模式。');
|
|
620
|
+
return rpc.startTurn(id,text,approval.writable,session.preferences.get(id)||null,{mode});
|
|
603
621
|
}
|
|
604
622
|
if (message.method === 'thread.settings') {
|
|
605
623
|
if (!controlFor(session, id) || !store.row(id)) throw new Error('没有此会话的控制授权。');
|
|
@@ -608,8 +626,16 @@ export function createOrbitServer(options = {}) {
|
|
|
608
626
|
if (!model || (body.effort !== null && !model.supportedReasoningEfforts.includes(body.effort))) throw new Error('所选模型或推理级别不可用,请重新加载目录。');
|
|
609
627
|
if (executionMode === 'desktop') {
|
|
610
628
|
if (typeof body.expectedModel !== 'string' || !Object.hasOwn(body, 'expectedEffort')) throw new Error('缺少当前模型版本,请刷新会话。');
|
|
611
|
-
const runtime =
|
|
629
|
+
const runtime = runtimeForThread(id);
|
|
612
630
|
if (!runtime.attached) throw new Error('桌面实时快照未就绪,请重新连接会话。');
|
|
631
|
+
if(runtime.mode==='same-id-resume'){
|
|
632
|
+
if(runtime.turnId)throw new Error('当前任务正在执行,请等待完成后再切换模型参数。');
|
|
633
|
+
const settings={model:body.model,effort:body.effort};
|
|
634
|
+
session.preferences.set(id,settings);
|
|
635
|
+
const resumedSession=resumeManager.sessions.get(id);
|
|
636
|
+
if(resumedSession)resumedSession.model=body.model;
|
|
637
|
+
return {...settings,applied:true,mode:'next-turn'};
|
|
638
|
+
}
|
|
613
639
|
if (runtime.model !== body.expectedModel || runtime.effort !== body.expectedEffort) throw new Error('模型设置已变化,请刷新后重试。');
|
|
614
640
|
if (runtime.turnId) {
|
|
615
641
|
session.preferences.set(id,{
|
|
@@ -630,6 +656,7 @@ export function createOrbitServer(options = {}) {
|
|
|
630
656
|
if (message.method === 'turn.steer') {
|
|
631
657
|
if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
|
|
632
658
|
if (executionMode !== 'desktop') throw new Error('当前执行通道不支持向桌面任务追加指令。');
|
|
659
|
+
if(runtimeForThread(id)?.mode==='same-id-resume')throw new Error('当前原会话通过 App Server 接续;请等待本轮完成后再发送下一条消息。');
|
|
633
660
|
const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
|
|
634
661
|
if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
|
|
635
662
|
||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
|
|
@@ -647,19 +674,26 @@ export function createOrbitServer(options = {}) {
|
|
|
647
674
|
}
|
|
648
675
|
if (message.method === 'turn.interrupt') {
|
|
649
676
|
if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
|
|
650
|
-
if (executionMode === 'desktop')
|
|
677
|
+
if (executionMode === 'desktop') {
|
|
678
|
+
if(resumeManager?.has(id))return resumeManager.rpcFor(id).interrupt(id);
|
|
679
|
+
return rpc.interrupt(id, body.turnId);
|
|
680
|
+
}
|
|
651
681
|
return rpc.interrupt(id);
|
|
652
682
|
}
|
|
653
683
|
if (message.method === 'approval.reply') {
|
|
654
|
-
const
|
|
684
|
+
const approvalRpc=resumeManager?.rpcForApproval(body.requestId)||rpc;
|
|
685
|
+
const approval = approvalRpc.approvals.get(String(body.requestId));
|
|
655
686
|
if (!approval || !controlFor(session, approval.params.threadId)) throw new Error('没有此审批的控制授权。');
|
|
656
|
-
return
|
|
687
|
+
return approvalRpc.replyApproval(body.requestId, body);
|
|
657
688
|
}
|
|
658
689
|
throw new Error('不支持的操作。');
|
|
659
690
|
}
|
|
660
691
|
|
|
661
692
|
const eventListener = event => {
|
|
662
|
-
if (!/^(item\/|turn\/|thread\/status|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
|
|
693
|
+
if (!/^(item\/|turn\/|thread\/(status|goal\/)|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
|
|
694
|
+
if(event.method==='orbit/engine'&&event.params?.connected===false&&resumeManager?.ids().length){
|
|
695
|
+
event={...event,params:{...event.params,connected:true,desktopConnected:false,resumedConnected:true}};
|
|
696
|
+
}
|
|
663
697
|
if(event.method==='turn/completed'&&event.params?.threadId){
|
|
664
698
|
for(const session of sessions.values())for(const [id,attachment]of session.attachments){
|
|
665
699
|
if(attachment.threadId===event.params.threadId&&attachment.consumed)removeAttachment(session,id);
|
|
@@ -688,9 +722,16 @@ export function createOrbitServer(options = {}) {
|
|
|
688
722
|
};
|
|
689
723
|
rpc.on('event', eventListener);
|
|
690
724
|
rpc.on('lost', lostListener);
|
|
725
|
+
resumeManager?.on('event', eventListener);
|
|
726
|
+
resumeManager?.on('lost', lostListener);
|
|
691
727
|
async function listen() {
|
|
692
728
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
|
|
693
729
|
port = server.address().port;
|
|
730
|
+
if(clientOnly){
|
|
731
|
+
void updateChecker.check();
|
|
732
|
+
updateTimer=setInterval(()=>{void updateChecker.check();},6*60*60*1000);
|
|
733
|
+
updateTimer.unref();
|
|
734
|
+
}
|
|
694
735
|
timer = setInterval(() => {
|
|
695
736
|
try {
|
|
696
737
|
tryDesktopConnect();
|
|
@@ -720,11 +761,14 @@ export function createOrbitServer(options = {}) {
|
|
|
720
761
|
}, 30000);
|
|
721
762
|
heartbeat.unref();
|
|
722
763
|
async function close() {
|
|
723
|
-
clearInterval(timer); clearInterval(heartbeat);
|
|
764
|
+
clearInterval(timer); clearInterval(heartbeat); clearInterval(updateTimer);
|
|
724
765
|
for (const socket of sockets) unwatchDesktop(socket);
|
|
725
766
|
for(const session of sessions.values())removeSessionAttachments(session);
|
|
726
767
|
for (const socket of sockets) socket.terminate();
|
|
727
768
|
rpc.off('event', eventListener); rpc.off('lost', lostListener); rpc.close();
|
|
769
|
+
resumeManager?.off('event', eventListener);
|
|
770
|
+
resumeManager?.off('lost', lostListener);
|
|
771
|
+
if(resumeManager)await resumeManager.close();
|
|
728
772
|
await new Promise(resolve => wss.close(resolve));
|
|
729
773
|
server.closeAllConnections();
|
|
730
774
|
await new Promise(resolve => server.close(resolve));
|
package/lib/device-auth.mjs
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
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
|
-
}
|