orbit-codex-bridge 0.1.2 → 0.1.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.
- package/README.md +11 -2
- package/lib/desktop.mjs +55 -8
- package/lib/resume-manager.mjs +177 -0
- package/lib/rpc.mjs +28 -2
- package/package.json +2 -2
- package/public/app.js +304 -28
- package/public/goal.css +7 -0
- package/public/index.html +4 -1
- package/public/style.css +30 -0
- package/relay-agent.mjs +4 -2
- package/relay-server.mjs +15 -4
- package/server.mjs +380 -25
package/server.mjs
CHANGED
|
@@ -9,16 +9,144 @@ import QRCode from 'qrcode';
|
|
|
9
9
|
import { NativeStore } from './lib/store.mjs';
|
|
10
10
|
import { CodexRpc } from './lib/rpc.mjs';
|
|
11
11
|
import { DesktopRpc } from './lib/desktop.mjs';
|
|
12
|
+
import { ResumeManager } from './lib/resume-manager.mjs';
|
|
12
13
|
|
|
13
14
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const packageVersion = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).version;
|
|
15
16
|
const sessionLifetime = 12 * 60 * 60 * 1000;
|
|
17
|
+
const attachmentLifetime = 30 * 60 * 1000;
|
|
18
|
+
const attachmentRetention = 24 * 60 * 60 * 1000;
|
|
19
|
+
const maxAttachmentBytes = 8 * 1024 * 1024;
|
|
20
|
+
const maxPendingAttachments = 5;
|
|
16
21
|
const safeError = error => String(error.message || error).replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]').replace(/(?:sk-|sk_)[\w-]{12,}/g, '[REDACTED]').slice(0, 700);
|
|
17
22
|
|
|
18
23
|
export function lanAddresses() {
|
|
19
24
|
return Object.values(os.networkInterfaces()).flat().filter(address => address && address.family === 'IPv4' && !address.internal).map(address => address.address);
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
function cleanupStaleAttachments(store) {
|
|
28
|
+
const now=Date.now();
|
|
29
|
+
for(const cwd of new Set(store.list().map(thread=>thread.cwd).filter(Boolean))){
|
|
30
|
+
let projectRoot;
|
|
31
|
+
try{projectRoot=fs.realpathSync(cwd)}catch{continue}
|
|
32
|
+
const directory=path.join(projectRoot,'.orbit-attachments');
|
|
33
|
+
let entries;
|
|
34
|
+
try{
|
|
35
|
+
const stat=fs.lstatSync(directory);
|
|
36
|
+
if(stat.isSymbolicLink()||!stat.isDirectory())continue;
|
|
37
|
+
entries=fs.readdirSync(directory);
|
|
38
|
+
}catch{continue}
|
|
39
|
+
for(const name of entries){
|
|
40
|
+
if(!/^[a-f0-9]{32}(?:\.[a-zA-Z0-9]{1,12})?$/.test(name))continue;
|
|
41
|
+
const filename=path.join(directory,name);
|
|
42
|
+
try{
|
|
43
|
+
const stat=fs.lstatSync(filename);
|
|
44
|
+
if(!stat.isSymbolicLink()&&stat.isFile()&&now-stat.mtimeMs>attachmentRetention)fs.unlinkSync(filename);
|
|
45
|
+
}catch{}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const excludedProjectEntries=new Set([
|
|
51
|
+
'.git','.codex','.orbit-attachments','node_modules','.venv','venv','dist','build',
|
|
52
|
+
'.ssh','.aws','.azure','.config','.npm','.docker','secrets','credentials'
|
|
53
|
+
]);
|
|
54
|
+
const excludedProjectFiles=/^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.git-credentials|id_(?:rsa|ed25519)(?:\.pub)?|.*(?:secret|credential)(?:\..*)?|.*\.(?:pem|p12|pfx|key|keystore))$/i;
|
|
55
|
+
|
|
56
|
+
function checkedProjectEntry(row,relativePath=''){
|
|
57
|
+
if(!row?.cwd||typeof relativePath!=='string'||relativePath.length>1024||relativePath.includes('\0'))throw new Error('项目文件路径无效。');
|
|
58
|
+
const root=fs.realpathSync(row.cwd);
|
|
59
|
+
if(!fs.statSync(root).isDirectory())throw new Error('会话工作目录不可用。');
|
|
60
|
+
const parts=relativePath?relativePath.split(/[\\/]/):[];
|
|
61
|
+
if(parts.some(part=>!part||part==='.'||part==='..'))throw new Error('不允许访问项目目录之外的路径。');
|
|
62
|
+
let current=root;
|
|
63
|
+
for(const part of parts){
|
|
64
|
+
if(excludedProjectEntries.has(part.toLowerCase())||excludedProjectFiles.test(part))throw new Error('此系统目录或凭证文件不开放传输。');
|
|
65
|
+
current=path.join(current,part);
|
|
66
|
+
const stat=fs.lstatSync(current);
|
|
67
|
+
if(stat.isSymbolicLink())throw new Error('不支持通过符号链接传输文件。');
|
|
68
|
+
}
|
|
69
|
+
const resolved=path.resolve(current);
|
|
70
|
+
const relative=path.relative(root,resolved);
|
|
71
|
+
if(relative==='..'||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative))throw new Error('不允许访问项目目录之外的路径。');
|
|
72
|
+
return {root,path:resolved,stat:fs.statSync(resolved),relative:parts.join(path.sep)};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function projectFileList(row,relativePath=''){
|
|
76
|
+
const directory=checkedProjectEntry(row,relativePath);
|
|
77
|
+
if(!directory.stat.isDirectory())throw new Error('所选路径不是文件夹。');
|
|
78
|
+
const entries=[];
|
|
79
|
+
const visit=(current,relative,depth)=>{
|
|
80
|
+
if(depth>8||entries.length>=1000)return;
|
|
81
|
+
for(const name of fs.readdirSync(current)){
|
|
82
|
+
if(excludedProjectEntries.has(name.toLowerCase())||excludedProjectFiles.test(name))continue;
|
|
83
|
+
const target=path.join(current,name);
|
|
84
|
+
let stat;
|
|
85
|
+
try{stat=fs.lstatSync(target)}catch{continue}
|
|
86
|
+
if(stat.isSymbolicLink())continue;
|
|
87
|
+
const child=relative?`${relative}/${name}`:name;
|
|
88
|
+
if(stat.isDirectory())entries.push({name,path:child,type:'directory',size:null,updatedAt:stat.mtimeMs});
|
|
89
|
+
else if(stat.isFile())entries.push({name,path:child,type:'file',size:stat.size,updatedAt:stat.mtimeMs});
|
|
90
|
+
if(stat.isDirectory())visit(target,child,depth+1);
|
|
91
|
+
if(entries.length>=1000)break;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
visit(directory.path,directory.relative,0);
|
|
95
|
+
entries.sort((left,right)=>left.type===right.type?left.name.localeCompare(right.name):left.type==='directory'?-1:1);
|
|
96
|
+
return {path:directory.relative,entries:entries.slice(0,1000),truncated:entries.length>=1000};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const projectMimeTypes=new Map([
|
|
100
|
+
['.txt','text/plain; charset=utf-8'],['.md','text/markdown; charset=utf-8'],['.json','application/json'],
|
|
101
|
+
['.csv','text/csv; charset=utf-8'],['.html','text/html; charset=utf-8'],['.css','text/css; charset=utf-8'],
|
|
102
|
+
['.js','text/javascript; charset=utf-8'],['.ts','text/plain; charset=utf-8'],['.xml','application/xml'],
|
|
103
|
+
['.pdf','application/pdf'],['.png','image/png'],['.jpg','image/jpeg'],['.jpeg','image/jpeg'],['.gif','image/gif'],
|
|
104
|
+
['.webp','image/webp'],['.svg','image/svg+xml'],['.zip','application/zip']
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
function sendFileRange(request,response,row,relativePath){
|
|
108
|
+
const file=checkedProjectEntry(row,relativePath);
|
|
109
|
+
if(!file.stat.isFile())throw new Error('所选路径不是文件。');
|
|
110
|
+
if(file.stat.size>8*1024*1024)throw Object.assign(new Error('单个文件下载不得超过 8 MiB。'),{status:413});
|
|
111
|
+
const filename=path.basename(file.path);
|
|
112
|
+
const fallback=filename.replace(/[^\x20-\x7e]|["\\;]/g,'_');
|
|
113
|
+
const encoded=encodeURIComponent(filename).replace(/[!'()*]/g,character=>`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
114
|
+
const etag=`"orbit-${file.stat.size}-${Math.trunc(file.stat.mtimeMs)}"`;
|
|
115
|
+
const headers={
|
|
116
|
+
'Content-Type':projectMimeTypes.get(path.extname(filename).toLowerCase())||'application/octet-stream',
|
|
117
|
+
'Accept-Ranges':'bytes',
|
|
118
|
+
'ETag':etag,
|
|
119
|
+
'Content-Disposition':`attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
|
|
120
|
+
'X-Content-Type-Options':'nosniff'
|
|
121
|
+
};
|
|
122
|
+
if(request.headers['if-none-match']===etag){response.writeHead(304,headers);return response.end()}
|
|
123
|
+
const ifRange=request.headers['if-range'];
|
|
124
|
+
const range=ifRange&&ifRange!==etag?null:request.headers.range;
|
|
125
|
+
if(!range){
|
|
126
|
+
response.writeHead(200,{...headers,'Content-Length':file.stat.size});
|
|
127
|
+
if(request.method==='HEAD')return response.end();
|
|
128
|
+
return fs.createReadStream(file.path).pipe(response);
|
|
129
|
+
}
|
|
130
|
+
const match=String(range).match(/^bytes=(\d*)-(\d*)$/);
|
|
131
|
+
if(!match||(!match[1]&&!match[2])){response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end()}
|
|
132
|
+
let start,end;
|
|
133
|
+
if(!match[1]){
|
|
134
|
+
const suffix=Number(match[2]);
|
|
135
|
+
if(!Number.isSafeInteger(suffix)||suffix<=0)return response.writeHead(416,{'Content-Range':`bytes */${file.stat.size}`}).end();
|
|
136
|
+
start=Math.max(0,file.stat.size-suffix);end=file.stat.size-1;
|
|
137
|
+
}else{
|
|
138
|
+
start=Number(match[1]);end=match[2]?Number(match[2]):file.stat.size-1;
|
|
139
|
+
}
|
|
140
|
+
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||start>=file.stat.size||end<start){
|
|
141
|
+
response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end();
|
|
142
|
+
}
|
|
143
|
+
end=Math.min(end,file.stat.size-1);
|
|
144
|
+
const length=end-start+1;
|
|
145
|
+
response.writeHead(206,{...headers,'Content-Length':length,'Content-Range':`bytes ${start}-${end}/${file.stat.size}`});
|
|
146
|
+
if(request.method==='HEAD')return response.end();
|
|
147
|
+
fs.createReadStream(file.path,{start,end}).pipe(response);
|
|
148
|
+
}
|
|
149
|
+
|
|
22
150
|
export function createOrbitServer(options = {}) {
|
|
23
151
|
const home = options.home || process.env.ORBIT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
24
152
|
const dataDir = options.dataDir || path.join(root, '.runtime');
|
|
@@ -27,9 +155,32 @@ export function createOrbitServer(options = {}) {
|
|
|
27
155
|
let pairingKey = fs.existsSync(credentialPath) ? fs.readFileSync(credentialPath, 'utf8').trim() : crypto.randomBytes(24).toString('base64url');
|
|
28
156
|
fs.writeFileSync(credentialPath, pairingKey, { mode: 0o600 });
|
|
29
157
|
const store = options.store || new NativeStore(home);
|
|
158
|
+
cleanupStaleAttachments(store);
|
|
30
159
|
const endpoint = options.endpoint ?? process.env.ORBIT_APP_SERVER_URL ?? '';
|
|
31
|
-
const
|
|
32
|
-
const
|
|
160
|
+
const injectedRpc = options.rpc || options.desktopRpc || null;
|
|
161
|
+
const rpc = injectedRpc || new DesktopRpc();
|
|
162
|
+
const executionMode = rpc.mode || (injectedRpc ? endpoint ? 'endpoint' : 'cold-resume' : 'desktop');
|
|
163
|
+
const resumeManager = options.resumeManager || (executionMode === 'desktop' && (!injectedRpc || options.enableResumeFallback === true)
|
|
164
|
+
? new ResumeManager({
|
|
165
|
+
home,
|
|
166
|
+
createRpc:options.createResumeRpc || (({ home:resumeHome }) => new CodexRpc({
|
|
167
|
+
home:resumeHome, endpoint, tokenFile:process.env.ORBIT_APP_SERVER_TOKEN_FILE
|
|
168
|
+
}))
|
|
169
|
+
})
|
|
170
|
+
: null);
|
|
171
|
+
function runtimeForThread(id) {
|
|
172
|
+
return resumeManager?.runtime(id) || rpc.runtime?.(id) || null;
|
|
173
|
+
}
|
|
174
|
+
async function attachThread(id) {
|
|
175
|
+
if (executionMode !== 'desktop') return null;
|
|
176
|
+
if (resumeManager?.has(id)) return resumeManager.runtime(id);
|
|
177
|
+
try { return await rpc.attach(id); }
|
|
178
|
+
catch (error) {
|
|
179
|
+
const row = store.row(id);
|
|
180
|
+
if (!resumeManager || !row || row.archived || String(row.source).startsWith('{')) throw error;
|
|
181
|
+
return resumeManager.resume(id, { model:row.model || null });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
33
184
|
const listModels = options.listModels || (async () => {
|
|
34
185
|
const catalogRpc = new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE });
|
|
35
186
|
try {
|
|
@@ -70,6 +221,7 @@ export function createOrbitServer(options = {}) {
|
|
|
70
221
|
const sessions = new Map();
|
|
71
222
|
const attempts = new Map();
|
|
72
223
|
const dedup = new Map();
|
|
224
|
+
const consumedAttachments = new Map();
|
|
73
225
|
const sockets = new Set();
|
|
74
226
|
const desktopWatchers = new Map();
|
|
75
227
|
const queuedSettingsInFlight = new Set();
|
|
@@ -78,6 +230,7 @@ export function createOrbitServer(options = {}) {
|
|
|
78
230
|
let revision = store.revision();
|
|
79
231
|
let timer;
|
|
80
232
|
let lastDesktopConnect = 0;
|
|
233
|
+
let lastAttachmentCleanup=Date.now();
|
|
81
234
|
|
|
82
235
|
function requestOrigin(request) {
|
|
83
236
|
const hostname = new URL(`http://${request.headers.host || 'invalid'}`).hostname;
|
|
@@ -99,6 +252,66 @@ export function createOrbitServer(options = {}) {
|
|
|
99
252
|
return { ...session, key };
|
|
100
253
|
}
|
|
101
254
|
|
|
255
|
+
function removeAttachment(session,id){
|
|
256
|
+
const attachment=session?.attachments?.get(id)||consumedAttachments.get(id);
|
|
257
|
+
if(!attachment)return false;
|
|
258
|
+
session?.attachments?.delete(id);
|
|
259
|
+
consumedAttachments.delete(id);
|
|
260
|
+
const filename=path.resolve(attachment.path);
|
|
261
|
+
if(path.dirname(filename)===path.join(path.resolve(attachment.projectRoot),'.orbit-attachments')
|
|
262
|
+
&&/^[a-f0-9]{32}(?:\.[a-zA-Z0-9]{1,12})?$/.test(path.basename(filename))){
|
|
263
|
+
try{if(fs.lstatSync(filename).isFile())fs.unlinkSync(filename)}catch{}
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function removeSessionAttachments(session){
|
|
269
|
+
for(const [id,attachment] of session.attachments||[]){
|
|
270
|
+
if(attachment.consumed){consumedAttachments.set(id,attachment);session.attachments.delete(id);}
|
|
271
|
+
else removeAttachment(session,id);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function attachmentPath(row,id,label){
|
|
276
|
+
const extension=path.extname(label).slice(1).toLowerCase();
|
|
277
|
+
const projectRoot=fs.realpathSync(row.cwd);
|
|
278
|
+
if(!fs.statSync(projectRoot).isDirectory())throw new Error('会话工作目录不可用。');
|
|
279
|
+
const directory=path.join(projectRoot,'.orbit-attachments');
|
|
280
|
+
try{
|
|
281
|
+
const existing=fs.lstatSync(directory);
|
|
282
|
+
if(existing.isSymbolicLink()||!existing.isDirectory())throw new Error('附件暂存目录不安全。');
|
|
283
|
+
}catch(error){if(error.code!=='ENOENT')throw error;fs.mkdirSync(directory,{mode:0o700});}
|
|
284
|
+
return path.join(directory,`${id}${/^[a-z0-9]{1,12}$/.test(extension)?`.${extension}`:''}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function uploadAttachment(request,response,session,threadId,url){
|
|
288
|
+
const row=store.row(threadId);
|
|
289
|
+
if(!row||row.archived||String(row.source).startsWith('{'))return json(response,400,{error:'归档会话或子代理不能接收文件。'});
|
|
290
|
+
if(executionMode!=='desktop'||!rpc.connected)return json(response,409,{error:'文件传输仅支持已连接的 Codex 桌面原会话。'});
|
|
291
|
+
if(!controlFor(session,threadId))return json(response,403,{error:'请先连接并接管这条原会话。'});
|
|
292
|
+
const runtime=runtimeForThread(threadId);
|
|
293
|
+
if(!runtime?.attached||runtime.mode==='same-id-resume')return json(response,409,{error:'该会话当前通过原 ID 接续运行;文件传输需要桌面热接管。'});
|
|
294
|
+
for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
|
|
295
|
+
if([...session.attachments.values()].filter(item=>item.threadId===threadId&&!item.consumed).length>=maxPendingAttachments)return json(response,429,{error:`这条会话最多可暂存 ${maxPendingAttachments} 个待发送文件。`});
|
|
296
|
+
const rawName=url.searchParams.get('name')||'';
|
|
297
|
+
const label=rawName.replace(/[\\/]/g,'_').replace(/[\u0000-\u001f\u007f<>:"|?*]/g,'_').trim().slice(0,160);
|
|
298
|
+
if(!label||label==='.'||label==='..')return json(response,400,{error:'文件名无效。'});
|
|
299
|
+
const chunks=[];
|
|
300
|
+
let size=0;
|
|
301
|
+
for await(const chunk of request){
|
|
302
|
+
size+=chunk.length;
|
|
303
|
+
if(size>maxAttachmentBytes)return json(response,413,{error:'单个文件不得超过 8 MiB。'});
|
|
304
|
+
chunks.push(chunk);
|
|
305
|
+
}
|
|
306
|
+
if(!size)return json(response,400,{error:'不能发送空文件。'});
|
|
307
|
+
const id=crypto.randomBytes(16).toString('hex');
|
|
308
|
+
const filename=attachmentPath(row,id,label);
|
|
309
|
+
fs.writeFileSync(filename,Buffer.concat(chunks),{flag:'wx',mode:0o600});
|
|
310
|
+
const projectRoot=fs.realpathSync(row.cwd);
|
|
311
|
+
session.attachments.set(id,{id,threadId,label,size,projectRoot,mimeType:String(request.headers['content-type']||'application/octet-stream').split(';')[0],path:filename,expires:Date.now()+attachmentLifetime});
|
|
312
|
+
return json(response,201,{id,threadId,label,size,mimeType:session.attachments.get(id).mimeType});
|
|
313
|
+
}
|
|
314
|
+
|
|
102
315
|
function json(response, status, body, extra = {}) {
|
|
103
316
|
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra });
|
|
104
317
|
response.end(JSON.stringify(body));
|
|
@@ -117,6 +330,7 @@ export function createOrbitServer(options = {}) {
|
|
|
117
330
|
|
|
118
331
|
function status(session) {
|
|
119
332
|
const threads = store.list();
|
|
333
|
+
const resumedIds = resumeManager?.ids() || [];
|
|
120
334
|
for (const id of session.enabled.keys()) controlFor(session, id);
|
|
121
335
|
return {
|
|
122
336
|
product: 'Orbit', version: packageVersion, device: os.hostname(), platform: process.platform, home,
|
|
@@ -125,7 +339,7 @@ export function createOrbitServer(options = {}) {
|
|
|
125
339
|
bridge: 'connected', transport: 'websocket', readOnlyMirror: true,
|
|
126
340
|
desktopHotAttachReady: executionMode === 'desktop' && rpc.connected,
|
|
127
341
|
desktopHotAttachVerified: executionMode === 'desktop' && [...rpc.threads.values()].some(thread => thread.state),
|
|
128
|
-
execution: { connected: rpc.connected, mode: executionMode, enabled: [...session.enabled.keys()], grants: Object.fromEntries(session.enabled), owned: [...rpc.threads.keys()] },
|
|
342
|
+
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])] },
|
|
129
343
|
connectionUrls: [publicUrl || `http://127.0.0.1:${port}`, ...(host === '0.0.0.0' ? lanAddresses().map(address => `http://${address}:${port}`) : [])],
|
|
130
344
|
secureTransport: Boolean(publicUrl), lanEnabled: host === '0.0.0.0'
|
|
131
345
|
};
|
|
@@ -144,6 +358,7 @@ export function createOrbitServer(options = {}) {
|
|
|
144
358
|
}
|
|
145
359
|
|
|
146
360
|
async function applyQueuedDesktopSettings(id, runtime) {
|
|
361
|
+
if(executionMode!=='desktop'||resumeManager?.has(id))return;
|
|
147
362
|
if (!runtime?.attached || runtime.turnId || queuedSettingsInFlight.has(id)) return;
|
|
148
363
|
queuedSettingsInFlight.add(id);
|
|
149
364
|
try {
|
|
@@ -182,7 +397,8 @@ export function createOrbitServer(options = {}) {
|
|
|
182
397
|
if (count > 1) desktopWatchers.set(id, count - 1);
|
|
183
398
|
else {
|
|
184
399
|
desktopWatchers.delete(id);
|
|
185
|
-
|
|
400
|
+
if (resumeManager?.has(id)) void resumeManager.release(id).catch(() => {});
|
|
401
|
+
else rpc.detach(id, '已取消实时跟随。');
|
|
186
402
|
}
|
|
187
403
|
}
|
|
188
404
|
|
|
@@ -223,7 +439,7 @@ export function createOrbitServer(options = {}) {
|
|
|
223
439
|
previous.count = 0;
|
|
224
440
|
if (sessions.size >= 64) return json(response, 429, { error: '已连接设备过多,请重启桥接服务后重新配对。' });
|
|
225
441
|
const key = crypto.randomBytes(32).toString('base64url');
|
|
226
|
-
sessions.set(key, { expires: Date.now() + sessionLifetime, enabled: new Map(), preferences: new Map() });
|
|
442
|
+
sessions.set(key, { expires: Date.now() + sessionLifetime, enabled: new Map(), preferences: new Map(), attachments:new Map() });
|
|
227
443
|
const secure = publicUrl ? '; Secure' : '';
|
|
228
444
|
return json(response, 200, { ok: true }, { 'Set-Cookie': `orbit_session=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200${secure}` });
|
|
229
445
|
}
|
|
@@ -235,6 +451,7 @@ export function createOrbitServer(options = {}) {
|
|
|
235
451
|
if (request.method === 'GET' && url.pathname === '/api/models') return json(response, 200, { models: await modelCatalog() });
|
|
236
452
|
if (request.method === 'POST' && url.pathname === '/api/logout') {
|
|
237
453
|
sessions.delete(session.key);
|
|
454
|
+
removeSessionAttachments(session);
|
|
238
455
|
for (const socket of sockets) if (socket.sessionKey === session.key) socket.close(1008, 'Logged out');
|
|
239
456
|
return json(response, 200, { ok: true }, { 'Set-Cookie': 'orbit_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
|
|
240
457
|
}
|
|
@@ -244,6 +461,29 @@ export function createOrbitServer(options = {}) {
|
|
|
244
461
|
if (!connection) return json(response, 400, { error: '请选择桥接服务提供的连接地址。' });
|
|
245
462
|
return json(response, 200, { image: await QRCode.toDataURL(`${connection}/#pair=${encodeURIComponent(pairingKey)}`, { width: 280, margin: 2 }), warning: '扫码者将能查看本机会话。仅向可信设备展示。' });
|
|
246
463
|
}
|
|
464
|
+
const uploadMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments$/);
|
|
465
|
+
if(request.method==='POST'&&uploadMatch)return await uploadAttachment(request,response,session,uploadMatch[1],url);
|
|
466
|
+
const removeAttachmentMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments\/remove$/);
|
|
467
|
+
if(request.method==='POST'&&removeAttachmentMatch){
|
|
468
|
+
const body=await readBody(request);
|
|
469
|
+
if(Object.keys(body).some(key=>key!=='attachmentId')||typeof body.attachmentId!=='string')return json(response,400,{error:'附件移除请求无效。'});
|
|
470
|
+
const attachment=session.attachments.get(body.attachmentId);
|
|
471
|
+
if(!attachment||attachment.threadId!==removeAttachmentMatch[1])return json(response,404,{error:'待发送附件不存在或已过期。'});
|
|
472
|
+
removeAttachment(session,body.attachmentId);
|
|
473
|
+
return json(response,200,{removed:true});
|
|
474
|
+
}
|
|
475
|
+
const filesMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/files$/);
|
|
476
|
+
if(request.method==='GET'&&filesMatch){
|
|
477
|
+
const row=store.row(filesMatch[1]);
|
|
478
|
+
if(!row)return json(response,404,{error:'会话不存在。'});
|
|
479
|
+
return json(response,200,projectFileList(row,url.searchParams.get('path')||''));
|
|
480
|
+
}
|
|
481
|
+
const fileMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/file$/);
|
|
482
|
+
if(request.method==='GET'&&fileMatch){
|
|
483
|
+
const row=store.row(fileMatch[1]);
|
|
484
|
+
if(!row)return json(response,404,{error:'会话不存在。'});
|
|
485
|
+
return sendFileRange(request,response,row,url.searchParams.get('path')||'');
|
|
486
|
+
}
|
|
247
487
|
const match = url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/messages$/);
|
|
248
488
|
if (request.method === 'GET' && match) {
|
|
249
489
|
const history = store.historyFor(match[1], url.searchParams.get('before'), url.searchParams.get('limit') || 60);
|
|
@@ -259,7 +499,7 @@ export function createOrbitServer(options = {}) {
|
|
|
259
499
|
file = path.join(root, relative);
|
|
260
500
|
type = mime;
|
|
261
501
|
} else {
|
|
262
|
-
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'] };
|
|
502
|
+
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'] };
|
|
263
503
|
if (!files[url.pathname]) return json(response, 404, { error: '页面不存在。' });
|
|
264
504
|
const [name, mime] = files[url.pathname]; file = path.join(root, 'public', name); type = mime;
|
|
265
505
|
}
|
|
@@ -319,8 +559,10 @@ export function createOrbitServer(options = {}) {
|
|
|
319
559
|
socket.desktopSubscription = id;
|
|
320
560
|
desktopWatchers.set(id, (desktopWatchers.get(id) || 0) + 1);
|
|
321
561
|
}
|
|
322
|
-
try {
|
|
323
|
-
|
|
562
|
+
try {
|
|
563
|
+
runtime = await attachThread(id);
|
|
564
|
+
if (!runtime?.attached) throw new Error('原会话没有可用的 Codex 执行器。');
|
|
565
|
+
} catch (error) {
|
|
324
566
|
unwatchDesktop(socket);
|
|
325
567
|
lostListener(id,error.message,error.code);
|
|
326
568
|
runtime = {
|
|
@@ -328,14 +570,17 @@ export function createOrbitServer(options = {}) {
|
|
|
328
570
|
reason:safeError(error),reasonCode:error.code || 'DESKTOP_NOT_READY'
|
|
329
571
|
};
|
|
330
572
|
}
|
|
573
|
+
if (resumeManager?.has(id) && (desktopWatchers.get(id) || 0) === 0) void resumeManager.release(id).catch(() => {});
|
|
331
574
|
}
|
|
332
575
|
if (executionMode === 'desktop' && runtime?.attached && body.autoControl === true
|
|
333
576
|
&& !row.archived && !String(row.source).startsWith('{')) {
|
|
334
|
-
|
|
577
|
+
const desktop = runtime.mode !== 'same-id-resume';
|
|
578
|
+
session.enabled.set(id, {sharedExecution:desktop,desktop,writable:!desktop,expires:Date.now() + 1800000});
|
|
335
579
|
}
|
|
336
580
|
const control = controlFor(session, id);
|
|
337
|
-
const
|
|
338
|
-
|
|
581
|
+
const activeRpc = resumeManager?.rpcFor(id) || rpc;
|
|
582
|
+
const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...activeRpc.approvals.values()].filter(request => request.params.threadId === id).map(request => ({ ...request.params, requestId: String(request.id), method: request.method }))) : [];
|
|
583
|
+
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})) };
|
|
339
584
|
}
|
|
340
585
|
if (message.method === 'thread.enable') {
|
|
341
586
|
const row = store.row(id);
|
|
@@ -344,12 +589,13 @@ export function createOrbitServer(options = {}) {
|
|
|
344
589
|
if (body.confirm !== true) throw new Error('必须明确确认接续方式。');
|
|
345
590
|
if (executionMode === 'desktop') {
|
|
346
591
|
let runtime;
|
|
347
|
-
try { runtime = await
|
|
592
|
+
try { runtime = await attachThread(id); }
|
|
348
593
|
catch (error) { lostListener(id,error.message,error.code); throw error; }
|
|
349
594
|
if (!runtime.attached) throw new Error('桌面实时快照未就绪,未授权控制。');
|
|
350
|
-
const
|
|
595
|
+
const desktop = runtime.mode !== 'same-id-resume';
|
|
596
|
+
const control = { sharedExecution:desktop, desktop, writable:!desktop, expires: Date.now() + 1800000 };
|
|
351
597
|
session.enabled.set(id, control);
|
|
352
|
-
return { threadId: id, ...control, mode: 'desktop', turnId: runtime.turnId };
|
|
598
|
+
return { threadId: id, ...control, mode:runtime.mode || 'desktop', turnId: runtime.turnId };
|
|
353
599
|
}
|
|
354
600
|
let sharedExecution = false;
|
|
355
601
|
if (endpoint) {
|
|
@@ -362,13 +608,78 @@ export function createOrbitServer(options = {}) {
|
|
|
362
608
|
session.enabled.set(id, control);
|
|
363
609
|
return { threadId: id, ...control, mode: endpoint ? 'endpoint' : 'cold-resume' };
|
|
364
610
|
}
|
|
611
|
+
if (message.method === 'thread.goal.get') {
|
|
612
|
+
if (!store.row(id)) throw new Error('会话不存在。');
|
|
613
|
+
if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
|
|
614
|
+
const runtime = runtimeForThread(id);
|
|
615
|
+
if (runtime?.mode === 'same-id-resume' || executionMode === 'desktop' && runtime?.attached) return { goal:runtime.goal || null };
|
|
616
|
+
if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/get', { threadId:id });
|
|
617
|
+
return { goal:null };
|
|
618
|
+
}
|
|
619
|
+
if (message.method === 'thread.goal.set') {
|
|
620
|
+
const row = store.row(id);
|
|
621
|
+
if (!row || row.archived || String(row.source).startsWith('{')) throw new Error('归档会话或子代理不能设置持续目标。');
|
|
622
|
+
if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
|
|
623
|
+
if (typeof body.objective !== 'string' || !body.objective.trim() || body.objective.length > 5000) throw new Error('目标内容不能为空,且不得超过 5000 个字符。');
|
|
624
|
+
const tokenBudget = body.tokenBudget === undefined || body.tokenBudget === null ? null : body.tokenBudget;
|
|
625
|
+
if (tokenBudget !== null && (!Number.isSafeInteger(tokenBudget) || tokenBudget < 1 || tokenBudget > 10000000)) throw new Error('目标 token 预算必须在 1 至 10000000 之间。');
|
|
626
|
+
const runtime = runtimeForThread(id);
|
|
627
|
+
if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoal(id, { objective:body.objective.trim(), tokenBudget }) };
|
|
628
|
+
if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 写入接口;请在电脑端设置目标。不会用提示词模拟原生 Goal。');
|
|
629
|
+
if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', {
|
|
630
|
+
threadId:id, origin:'user', objective:body.objective.trim(), status:'active', tokenBudget
|
|
631
|
+
});
|
|
632
|
+
throw new Error('当前执行通道不支持原生 Goal 写入。');
|
|
633
|
+
}
|
|
634
|
+
if (message.method === 'thread.goal.status') {
|
|
635
|
+
if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
|
|
636
|
+
if (!['active','paused','blocked','complete'].includes(body.status)) throw new Error('无效的目标状态。');
|
|
637
|
+
const runtime = runtimeForThread(id);
|
|
638
|
+
if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoalStatus(id, body.status) };
|
|
639
|
+
if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 状态接口;不会伪造目标状态。');
|
|
640
|
+
if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', { threadId:id, origin:'user', status:body.status });
|
|
641
|
+
throw new Error('当前执行通道不支持原生 Goal 状态更新。');
|
|
642
|
+
}
|
|
643
|
+
if (message.method === 'thread.goal.clear') {
|
|
644
|
+
if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
|
|
645
|
+
const runtime = runtimeForThread(id);
|
|
646
|
+
if (runtime?.mode === 'same-id-resume') return resumeManager.clearGoal(id);
|
|
647
|
+
if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 清除接口;请在电脑端清除目标。');
|
|
648
|
+
if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/clear', { threadId:id, origin:'user' });
|
|
649
|
+
throw new Error('当前执行通道不支持原生 Goal 清除。');
|
|
650
|
+
}
|
|
365
651
|
if (message.method === 'turn.start') {
|
|
366
652
|
const approval = controlFor(session, id);
|
|
367
653
|
if (!approval) throw new Error('请先确认这条会话的接续方式。');
|
|
368
|
-
|
|
369
|
-
if
|
|
654
|
+
const mode=body.mode??'default';
|
|
655
|
+
if(!['default','plan'].includes(mode))throw new Error('目标已改为会话级原生 Goal,请使用“目标”管理;不再用提示词模拟。');
|
|
656
|
+
const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
|
|
657
|
+
if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
|
|
658
|
+
||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
|
|
659
|
+
if(typeof body.text!=='string'||(!body.text.trim()&&!attachmentIds.length)||body.text.length>20000)throw new Error('消息或附件必须至少提供一项;文本不得超过 20000 个字符。');
|
|
660
|
+
const attachments=attachmentIds.map(attachmentId=>{
|
|
661
|
+
const attachment=session.attachments.get(attachmentId);
|
|
662
|
+
if(!attachment||attachment.threadId!==id||attachment.consumed||attachment.expires<Date.now())throw new Error('文件附件不存在、属于其他会话或已过期。请重新添加。');
|
|
663
|
+
return attachment;
|
|
664
|
+
});
|
|
665
|
+
let text=body.text.trim()||(attachments.length?`请查看并处理以下附件:${attachments.map(file=>file.label).join('、')}`:'');
|
|
666
|
+
if (executionMode === 'desktop') {
|
|
667
|
+
const runtime=runtimeForThread(id);
|
|
668
|
+
if(runtime?.mode==='same-id-resume'){
|
|
669
|
+
if(attachments.length)throw new Error('文件传输目前需要桌面热接管;请在电脑上打开这条原会话后重新连接。');
|
|
670
|
+
const resumedRpc=resumeManager?.rpcFor(id);
|
|
671
|
+
if(!resumedRpc)throw new Error('原会话接续连接已失效,请重新打开这条会话。');
|
|
672
|
+
const settings=session.preferences.get(id);
|
|
673
|
+
return resumedRpc.startTurn(id,text,approval.writable,settings?.pending?settings:settings||null,{mode});
|
|
674
|
+
}
|
|
675
|
+
const result=await rpc.startTurn(id,text,{mode,attachments});
|
|
676
|
+
for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
|
|
677
|
+
return result;
|
|
678
|
+
}
|
|
679
|
+
if(attachments.length)throw new Error('文件附件仅支持 Codex 桌面热接管。');
|
|
370
680
|
if (!rpc.threads.has(id) && !endpoint && store.locked(id)) throw new Error('会话已被其他进程占用,未发送消息。');
|
|
371
|
-
|
|
681
|
+
if(mode==='plan'&&typeof rpc.startTurn!=='function')throw new Error('当前执行通道不支持原生协作模式。');
|
|
682
|
+
return rpc.startTurn(id,text,approval.writable,session.preferences.get(id)||null,{mode});
|
|
372
683
|
}
|
|
373
684
|
if (message.method === 'thread.settings') {
|
|
374
685
|
if (!controlFor(session, id) || !store.row(id)) throw new Error('没有此会话的控制授权。');
|
|
@@ -377,8 +688,16 @@ export function createOrbitServer(options = {}) {
|
|
|
377
688
|
if (!model || (body.effort !== null && !model.supportedReasoningEfforts.includes(body.effort))) throw new Error('所选模型或推理级别不可用,请重新加载目录。');
|
|
378
689
|
if (executionMode === 'desktop') {
|
|
379
690
|
if (typeof body.expectedModel !== 'string' || !Object.hasOwn(body, 'expectedEffort')) throw new Error('缺少当前模型版本,请刷新会话。');
|
|
380
|
-
const runtime =
|
|
691
|
+
const runtime = runtimeForThread(id);
|
|
381
692
|
if (!runtime.attached) throw new Error('桌面实时快照未就绪,请重新连接会话。');
|
|
693
|
+
if(runtime.mode==='same-id-resume'){
|
|
694
|
+
if(runtime.turnId)throw new Error('当前任务正在执行,请等待完成后再切换模型参数。');
|
|
695
|
+
const settings={model:body.model,effort:body.effort};
|
|
696
|
+
session.preferences.set(id,settings);
|
|
697
|
+
const resumedSession=resumeManager.sessions.get(id);
|
|
698
|
+
if(resumedSession)resumedSession.model=body.model;
|
|
699
|
+
return {...settings,applied:true,mode:'next-turn'};
|
|
700
|
+
}
|
|
382
701
|
if (runtime.model !== body.expectedModel || runtime.effort !== body.expectedEffort) throw new Error('模型设置已变化,请刷新后重试。');
|
|
383
702
|
if (runtime.turnId) {
|
|
384
703
|
session.preferences.set(id,{
|
|
@@ -399,25 +718,50 @@ export function createOrbitServer(options = {}) {
|
|
|
399
718
|
if (message.method === 'turn.steer') {
|
|
400
719
|
if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
|
|
401
720
|
if (executionMode !== 'desktop') throw new Error('当前执行通道不支持向桌面任务追加指令。');
|
|
402
|
-
if
|
|
721
|
+
if(runtimeForThread(id)?.mode==='same-id-resume')throw new Error('当前原会话通过 App Server 接续;请等待本轮完成后再发送下一条消息。');
|
|
722
|
+
const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
|
|
723
|
+
if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
|
|
724
|
+
||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
|
|
725
|
+
if(typeof body.text!=='string'||(!body.text.trim()&&!attachmentIds.length)||body.text.length>20000)throw new Error('消息或附件必须至少提供一项;文本不得超过 20000 个字符。');
|
|
403
726
|
if (typeof body.turnId !== 'string' || !body.turnId) throw new Error('缺少当前任务标识,请刷新后重试。');
|
|
404
|
-
|
|
727
|
+
const attachments=attachmentIds.map(attachmentId=>{
|
|
728
|
+
const attachment=session.attachments.get(attachmentId);
|
|
729
|
+
if(!attachment||attachment.threadId!==id||attachment.consumed||attachment.expires<Date.now())throw new Error('文件附件不存在、属于其他会话或已过期。请重新添加。');
|
|
730
|
+
return attachment;
|
|
731
|
+
});
|
|
732
|
+
const text=body.text.trim()||(attachments.length?`请查看并处理以下附件:${attachments.map(file=>file.label).join('、')}`:'');
|
|
733
|
+
const result=await rpc.steer(id,text,body.turnId,{attachments});
|
|
734
|
+
for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
|
|
735
|
+
return result;
|
|
405
736
|
}
|
|
406
737
|
if (message.method === 'turn.interrupt') {
|
|
407
738
|
if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
|
|
408
|
-
if (executionMode === 'desktop')
|
|
739
|
+
if (executionMode === 'desktop') {
|
|
740
|
+
if(resumeManager?.has(id))return resumeManager.rpcFor(id).interrupt(id);
|
|
741
|
+
return rpc.interrupt(id, body.turnId);
|
|
742
|
+
}
|
|
409
743
|
return rpc.interrupt(id);
|
|
410
744
|
}
|
|
411
745
|
if (message.method === 'approval.reply') {
|
|
412
|
-
const
|
|
746
|
+
const approvalRpc=resumeManager?.rpcForApproval(body.requestId)||rpc;
|
|
747
|
+
const approval = approvalRpc.approvals.get(String(body.requestId));
|
|
413
748
|
if (!approval || !controlFor(session, approval.params.threadId)) throw new Error('没有此审批的控制授权。');
|
|
414
|
-
return
|
|
749
|
+
return approvalRpc.replyApproval(body.requestId, body);
|
|
415
750
|
}
|
|
416
751
|
throw new Error('不支持的操作。');
|
|
417
752
|
}
|
|
418
753
|
|
|
419
754
|
const eventListener = event => {
|
|
420
|
-
if (!/^(item\/|turn\/|thread\/status|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
|
|
755
|
+
if (!/^(item\/|turn\/|thread\/(status|goal\/)|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
|
|
756
|
+
if(event.method==='orbit/engine'&&event.params?.connected===false&&resumeManager?.ids().length){
|
|
757
|
+
event={...event,params:{...event.params,connected:true,desktopConnected:false,resumedConnected:true}};
|
|
758
|
+
}
|
|
759
|
+
if(event.method==='turn/completed'&&event.params?.threadId){
|
|
760
|
+
for(const session of sessions.values())for(const [id,attachment]of session.attachments){
|
|
761
|
+
if(attachment.threadId===event.params.threadId&&attachment.consumed)removeAttachment(session,id);
|
|
762
|
+
}
|
|
763
|
+
for(const [id,attachment]of consumedAttachments)if(attachment.threadId===event.params.threadId)removeAttachment(null,id);
|
|
764
|
+
}
|
|
421
765
|
if (event.method === 'orbit/desktop' && event.params?.threadId) void applyQueuedDesktopSettings(event.params.threadId,event.params);
|
|
422
766
|
for (const socket of sockets) {
|
|
423
767
|
const threadId = event.params?.threadId;
|
|
@@ -440,6 +784,8 @@ export function createOrbitServer(options = {}) {
|
|
|
440
784
|
};
|
|
441
785
|
rpc.on('event', eventListener);
|
|
442
786
|
rpc.on('lost', lostListener);
|
|
787
|
+
resumeManager?.on('event', eventListener);
|
|
788
|
+
resumeManager?.on('lost', lostListener);
|
|
443
789
|
async function listen() {
|
|
444
790
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
|
|
445
791
|
port = server.address().port;
|
|
@@ -453,7 +799,12 @@ export function createOrbitServer(options = {}) {
|
|
|
453
799
|
}
|
|
454
800
|
for (const [key, value] of dedup) if (Date.now() - value.created > 300000) dedup.delete(key);
|
|
455
801
|
for (const [key, value] of attempts) if (value.reset < Date.now()) attempts.delete(key);
|
|
456
|
-
for (const [key, value] of sessions) if (value.expires < Date.now()) sessions.delete(key);
|
|
802
|
+
for (const [key, value] of sessions) if (value.expires < Date.now()) {removeSessionAttachments(value);sessions.delete(key);}
|
|
803
|
+
for(const session of sessions.values()){
|
|
804
|
+
for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
|
|
805
|
+
}
|
|
806
|
+
for(const [id,attachment]of consumedAttachments)if(attachment.expires<Date.now())removeAttachment(null,id);
|
|
807
|
+
if(Date.now()-lastAttachmentCleanup>60*60*1000){cleanupStaleAttachments(store);lastAttachmentCleanup=Date.now();}
|
|
457
808
|
} catch {}
|
|
458
809
|
}, 1200);
|
|
459
810
|
timer.unref();
|
|
@@ -469,8 +820,12 @@ export function createOrbitServer(options = {}) {
|
|
|
469
820
|
async function close() {
|
|
470
821
|
clearInterval(timer); clearInterval(heartbeat);
|
|
471
822
|
for (const socket of sockets) unwatchDesktop(socket);
|
|
823
|
+
for(const session of sessions.values())removeSessionAttachments(session);
|
|
472
824
|
for (const socket of sockets) socket.terminate();
|
|
473
825
|
rpc.off('event', eventListener); rpc.off('lost', lostListener); rpc.close();
|
|
826
|
+
resumeManager?.off('event', eventListener);
|
|
827
|
+
resumeManager?.off('lost', lostListener);
|
|
828
|
+
if(resumeManager)await resumeManager.close();
|
|
474
829
|
await new Promise(resolve => wss.close(resolve));
|
|
475
830
|
server.closeAllConnections();
|
|
476
831
|
await new Promise(resolve => server.close(resolve));
|