orbit-codex-bridge 0.1.1 → 0.1.3
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 +12 -3
- package/cli.mjs +4 -13
- package/lib/desktop.mjs +45 -7
- package/package.json +1 -1
- package/public/app.js +170 -32
- package/public/index.html +4 -3
- package/public/style.css +30 -0
- package/relay-agent.mjs +80 -38
- package/relay-server.mjs +27 -8
- package/server.mjs +262 -8
package/server.mjs
CHANGED
|
@@ -13,12 +13,139 @@ import { DesktopRpc } from './lib/desktop.mjs';
|
|
|
13
13
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const packageVersion = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).version;
|
|
15
15
|
const sessionLifetime = 12 * 60 * 60 * 1000;
|
|
16
|
+
const attachmentLifetime = 30 * 60 * 1000;
|
|
17
|
+
const attachmentRetention = 24 * 60 * 60 * 1000;
|
|
18
|
+
const maxAttachmentBytes = 8 * 1024 * 1024;
|
|
19
|
+
const maxPendingAttachments = 5;
|
|
16
20
|
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
21
|
|
|
18
22
|
export function lanAddresses() {
|
|
19
23
|
return Object.values(os.networkInterfaces()).flat().filter(address => address && address.family === 'IPv4' && !address.internal).map(address => address.address);
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
function cleanupStaleAttachments(store) {
|
|
27
|
+
const now=Date.now();
|
|
28
|
+
for(const cwd of new Set(store.list().map(thread=>thread.cwd).filter(Boolean))){
|
|
29
|
+
let projectRoot;
|
|
30
|
+
try{projectRoot=fs.realpathSync(cwd)}catch{continue}
|
|
31
|
+
const directory=path.join(projectRoot,'.orbit-attachments');
|
|
32
|
+
let entries;
|
|
33
|
+
try{
|
|
34
|
+
const stat=fs.lstatSync(directory);
|
|
35
|
+
if(stat.isSymbolicLink()||!stat.isDirectory())continue;
|
|
36
|
+
entries=fs.readdirSync(directory);
|
|
37
|
+
}catch{continue}
|
|
38
|
+
for(const name of entries){
|
|
39
|
+
if(!/^[a-f0-9]{32}(?:\.[a-zA-Z0-9]{1,12})?$/.test(name))continue;
|
|
40
|
+
const filename=path.join(directory,name);
|
|
41
|
+
try{
|
|
42
|
+
const stat=fs.lstatSync(filename);
|
|
43
|
+
if(!stat.isSymbolicLink()&&stat.isFile()&&now-stat.mtimeMs>attachmentRetention)fs.unlinkSync(filename);
|
|
44
|
+
}catch{}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
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
|
+
|
|
22
149
|
export function createOrbitServer(options = {}) {
|
|
23
150
|
const home = options.home || process.env.ORBIT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
24
151
|
const dataDir = options.dataDir || path.join(root, '.runtime');
|
|
@@ -27,6 +154,7 @@ export function createOrbitServer(options = {}) {
|
|
|
27
154
|
let pairingKey = fs.existsSync(credentialPath) ? fs.readFileSync(credentialPath, 'utf8').trim() : crypto.randomBytes(24).toString('base64url');
|
|
28
155
|
fs.writeFileSync(credentialPath, pairingKey, { mode: 0o600 });
|
|
29
156
|
const store = options.store || new NativeStore(home);
|
|
157
|
+
cleanupStaleAttachments(store);
|
|
30
158
|
const endpoint = options.endpoint ?? process.env.ORBIT_APP_SERVER_URL ?? '';
|
|
31
159
|
const rpc = options.rpc || options.desktopRpc || (endpoint ? new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE }) : new DesktopRpc());
|
|
32
160
|
const executionMode = rpc.mode || (endpoint ? 'endpoint' : 'cold-resume');
|
|
@@ -70,6 +198,7 @@ export function createOrbitServer(options = {}) {
|
|
|
70
198
|
const sessions = new Map();
|
|
71
199
|
const attempts = new Map();
|
|
72
200
|
const dedup = new Map();
|
|
201
|
+
const consumedAttachments = new Map();
|
|
73
202
|
const sockets = new Set();
|
|
74
203
|
const desktopWatchers = new Map();
|
|
75
204
|
const queuedSettingsInFlight = new Set();
|
|
@@ -78,6 +207,7 @@ export function createOrbitServer(options = {}) {
|
|
|
78
207
|
let revision = store.revision();
|
|
79
208
|
let timer;
|
|
80
209
|
let lastDesktopConnect = 0;
|
|
210
|
+
let lastAttachmentCleanup=Date.now();
|
|
81
211
|
|
|
82
212
|
function requestOrigin(request) {
|
|
83
213
|
const hostname = new URL(`http://${request.headers.host || 'invalid'}`).hostname;
|
|
@@ -99,6 +229,65 @@ export function createOrbitServer(options = {}) {
|
|
|
99
229
|
return { ...session, key };
|
|
100
230
|
}
|
|
101
231
|
|
|
232
|
+
function removeAttachment(session,id){
|
|
233
|
+
const attachment=session?.attachments?.get(id)||consumedAttachments.get(id);
|
|
234
|
+
if(!attachment)return false;
|
|
235
|
+
session?.attachments?.delete(id);
|
|
236
|
+
consumedAttachments.delete(id);
|
|
237
|
+
const filename=path.resolve(attachment.path);
|
|
238
|
+
if(path.dirname(filename)===path.join(path.resolve(attachment.projectRoot),'.orbit-attachments')
|
|
239
|
+
&&/^[a-f0-9]{32}(?:\.[a-zA-Z0-9]{1,12})?$/.test(path.basename(filename))){
|
|
240
|
+
try{if(fs.lstatSync(filename).isFile())fs.unlinkSync(filename)}catch{}
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function removeSessionAttachments(session){
|
|
246
|
+
for(const [id,attachment] of session.attachments||[]){
|
|
247
|
+
if(attachment.consumed){consumedAttachments.set(id,attachment);session.attachments.delete(id);}
|
|
248
|
+
else removeAttachment(session,id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function attachmentPath(row,id,label){
|
|
253
|
+
const extension=path.extname(label).slice(1).toLowerCase();
|
|
254
|
+
const projectRoot=fs.realpathSync(row.cwd);
|
|
255
|
+
if(!fs.statSync(projectRoot).isDirectory())throw new Error('会话工作目录不可用。');
|
|
256
|
+
const directory=path.join(projectRoot,'.orbit-attachments');
|
|
257
|
+
try{
|
|
258
|
+
const existing=fs.lstatSync(directory);
|
|
259
|
+
if(existing.isSymbolicLink()||!existing.isDirectory())throw new Error('附件暂存目录不安全。');
|
|
260
|
+
}catch(error){if(error.code!=='ENOENT')throw error;fs.mkdirSync(directory,{mode:0o700});}
|
|
261
|
+
return path.join(directory,`${id}${/^[a-z0-9]{1,12}$/.test(extension)?`.${extension}`:''}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function uploadAttachment(request,response,session,threadId,url){
|
|
265
|
+
const row=store.row(threadId);
|
|
266
|
+
if(!row||row.archived||String(row.source).startsWith('{'))return json(response,400,{error:'归档会话或子代理不能接收文件。'});
|
|
267
|
+
if(executionMode!=='desktop'||!rpc.connected)return json(response,409,{error:'文件传输仅支持已连接的 Codex 桌面原会话。'});
|
|
268
|
+
if(!controlFor(session,threadId))return json(response,403,{error:'请先连接并接管这条原会话。'});
|
|
269
|
+
if(!rpc.runtime(threadId)?.attached)return json(response,409,{error:'请先在 Codex 桌面打开此会话并重新连接。'});
|
|
270
|
+
for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
|
|
271
|
+
if([...session.attachments.values()].filter(item=>item.threadId===threadId&&!item.consumed).length>=maxPendingAttachments)return json(response,429,{error:`这条会话最多可暂存 ${maxPendingAttachments} 个待发送文件。`});
|
|
272
|
+
const rawName=url.searchParams.get('name')||'';
|
|
273
|
+
const label=rawName.replace(/[\\/]/g,'_').replace(/[\u0000-\u001f\u007f<>:"|?*]/g,'_').trim().slice(0,160);
|
|
274
|
+
if(!label||label==='.'||label==='..')return json(response,400,{error:'文件名无效。'});
|
|
275
|
+
const chunks=[];
|
|
276
|
+
let size=0;
|
|
277
|
+
for await(const chunk of request){
|
|
278
|
+
size+=chunk.length;
|
|
279
|
+
if(size>maxAttachmentBytes)return json(response,413,{error:'单个文件不得超过 8 MiB。'});
|
|
280
|
+
chunks.push(chunk);
|
|
281
|
+
}
|
|
282
|
+
if(!size)return json(response,400,{error:'不能发送空文件。'});
|
|
283
|
+
const id=crypto.randomBytes(16).toString('hex');
|
|
284
|
+
const filename=attachmentPath(row,id,label);
|
|
285
|
+
fs.writeFileSync(filename,Buffer.concat(chunks),{flag:'wx',mode:0o600});
|
|
286
|
+
const projectRoot=fs.realpathSync(row.cwd);
|
|
287
|
+
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});
|
|
288
|
+
return json(response,201,{id,threadId,label,size,mimeType:session.attachments.get(id).mimeType});
|
|
289
|
+
}
|
|
290
|
+
|
|
102
291
|
function json(response, status, body, extra = {}) {
|
|
103
292
|
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra });
|
|
104
293
|
response.end(JSON.stringify(body));
|
|
@@ -223,7 +412,7 @@ export function createOrbitServer(options = {}) {
|
|
|
223
412
|
previous.count = 0;
|
|
224
413
|
if (sessions.size >= 64) return json(response, 429, { error: '已连接设备过多,请重启桥接服务后重新配对。' });
|
|
225
414
|
const key = crypto.randomBytes(32).toString('base64url');
|
|
226
|
-
sessions.set(key, { expires: Date.now() + sessionLifetime, enabled: new Map(), preferences: new Map() });
|
|
415
|
+
sessions.set(key, { expires: Date.now() + sessionLifetime, enabled: new Map(), preferences: new Map(), attachments:new Map() });
|
|
227
416
|
const secure = publicUrl ? '; Secure' : '';
|
|
228
417
|
return json(response, 200, { ok: true }, { 'Set-Cookie': `orbit_session=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200${secure}` });
|
|
229
418
|
}
|
|
@@ -235,6 +424,7 @@ export function createOrbitServer(options = {}) {
|
|
|
235
424
|
if (request.method === 'GET' && url.pathname === '/api/models') return json(response, 200, { models: await modelCatalog() });
|
|
236
425
|
if (request.method === 'POST' && url.pathname === '/api/logout') {
|
|
237
426
|
sessions.delete(session.key);
|
|
427
|
+
removeSessionAttachments(session);
|
|
238
428
|
for (const socket of sockets) if (socket.sessionKey === session.key) socket.close(1008, 'Logged out');
|
|
239
429
|
return json(response, 200, { ok: true }, { 'Set-Cookie': 'orbit_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
|
|
240
430
|
}
|
|
@@ -244,6 +434,29 @@ export function createOrbitServer(options = {}) {
|
|
|
244
434
|
if (!connection) return json(response, 400, { error: '请选择桥接服务提供的连接地址。' });
|
|
245
435
|
return json(response, 200, { image: await QRCode.toDataURL(`${connection}/#pair=${encodeURIComponent(pairingKey)}`, { width: 280, margin: 2 }), warning: '扫码者将能查看本机会话。仅向可信设备展示。' });
|
|
246
436
|
}
|
|
437
|
+
const uploadMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments$/);
|
|
438
|
+
if(request.method==='POST'&&uploadMatch)return await uploadAttachment(request,response,session,uploadMatch[1],url);
|
|
439
|
+
const removeAttachmentMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments\/remove$/);
|
|
440
|
+
if(request.method==='POST'&&removeAttachmentMatch){
|
|
441
|
+
const body=await readBody(request);
|
|
442
|
+
if(Object.keys(body).some(key=>key!=='attachmentId')||typeof body.attachmentId!=='string')return json(response,400,{error:'附件移除请求无效。'});
|
|
443
|
+
const attachment=session.attachments.get(body.attachmentId);
|
|
444
|
+
if(!attachment||attachment.threadId!==removeAttachmentMatch[1])return json(response,404,{error:'待发送附件不存在或已过期。'});
|
|
445
|
+
removeAttachment(session,body.attachmentId);
|
|
446
|
+
return json(response,200,{removed:true});
|
|
447
|
+
}
|
|
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
|
+
}
|
|
247
460
|
const match = url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/messages$/);
|
|
248
461
|
if (request.method === 'GET' && match) {
|
|
249
462
|
const history = store.historyFor(match[1], url.searchParams.get('before'), url.searchParams.get('limit') || 60);
|
|
@@ -335,7 +548,7 @@ export function createOrbitServer(options = {}) {
|
|
|
335
548
|
}
|
|
336
549
|
const control = controlFor(session, id);
|
|
337
550
|
const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...rpc.approvals.values()].filter(request => request.params.threadId === id).map(request => ({ ...request.params, requestId: String(request.id), method: request.method }))) : [];
|
|
338
|
-
return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || rpc.threads.get(id)?.turnId || null, approvals };
|
|
551
|
+
return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || rpc.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
552
|
}
|
|
340
553
|
if (message.method === 'thread.enable') {
|
|
341
554
|
const row = store.row(id);
|
|
@@ -365,10 +578,28 @@ export function createOrbitServer(options = {}) {
|
|
|
365
578
|
if (message.method === 'turn.start') {
|
|
366
579
|
const approval = controlFor(session, id);
|
|
367
580
|
if (!approval) throw new Error('请先确认这条会话的接续方式。');
|
|
368
|
-
|
|
369
|
-
if
|
|
581
|
+
const mode=body.mode??'default';
|
|
582
|
+
if(!['default','plan','goal'].includes(mode))throw new Error('对话模式无效。');
|
|
583
|
+
const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
|
|
584
|
+
if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
|
|
585
|
+
||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
|
|
586
|
+
if(typeof body.text!=='string'||(!body.text.trim()&&!attachmentIds.length)||body.text.length>20000)throw new Error('消息或附件必须至少提供一项;文本不得超过 20000 个字符。');
|
|
587
|
+
const attachments=attachmentIds.map(attachmentId=>{
|
|
588
|
+
const attachment=session.attachments.get(attachmentId);
|
|
589
|
+
if(!attachment||attachment.threadId!==id||attachment.consumed||attachment.expires<Date.now())throw new Error('文件附件不存在、属于其他会话或已过期。请重新添加。');
|
|
590
|
+
return attachment;
|
|
591
|
+
});
|
|
592
|
+
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
|
+
if (executionMode === 'desktop') {
|
|
596
|
+
const result=await rpc.startTurn(id,text,{mode,attachments});
|
|
597
|
+
for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
|
|
598
|
+
return result;
|
|
599
|
+
}
|
|
600
|
+
if(mode!=='default'||attachments.length)throw new Error('计划/目标模式和文件附件仅支持 Codex 桌面热接管。');
|
|
370
601
|
if (!rpc.threads.has(id) && !endpoint && store.locked(id)) throw new Error('会话已被其他进程占用,未发送消息。');
|
|
371
|
-
return rpc.startTurn(id,
|
|
602
|
+
return rpc.startTurn(id,text,approval.writable,session.preferences.get(id)||null);
|
|
372
603
|
}
|
|
373
604
|
if (message.method === 'thread.settings') {
|
|
374
605
|
if (!controlFor(session, id) || !store.row(id)) throw new Error('没有此会话的控制授权。');
|
|
@@ -399,9 +630,20 @@ export function createOrbitServer(options = {}) {
|
|
|
399
630
|
if (message.method === 'turn.steer') {
|
|
400
631
|
if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
|
|
401
632
|
if (executionMode !== 'desktop') throw new Error('当前执行通道不支持向桌面任务追加指令。');
|
|
402
|
-
|
|
633
|
+
const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
|
|
634
|
+
if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
|
|
635
|
+
||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
|
|
636
|
+
if(typeof body.text!=='string'||(!body.text.trim()&&!attachmentIds.length)||body.text.length>20000)throw new Error('消息或附件必须至少提供一项;文本不得超过 20000 个字符。');
|
|
403
637
|
if (typeof body.turnId !== 'string' || !body.turnId) throw new Error('缺少当前任务标识,请刷新后重试。');
|
|
404
|
-
|
|
638
|
+
const attachments=attachmentIds.map(attachmentId=>{
|
|
639
|
+
const attachment=session.attachments.get(attachmentId);
|
|
640
|
+
if(!attachment||attachment.threadId!==id||attachment.consumed||attachment.expires<Date.now())throw new Error('文件附件不存在、属于其他会话或已过期。请重新添加。');
|
|
641
|
+
return attachment;
|
|
642
|
+
});
|
|
643
|
+
const text=body.text.trim()||(attachments.length?`请查看并处理以下附件:${attachments.map(file=>file.label).join('、')}`:'');
|
|
644
|
+
const result=await rpc.steer(id,text,body.turnId,{attachments});
|
|
645
|
+
for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
|
|
646
|
+
return result;
|
|
405
647
|
}
|
|
406
648
|
if (message.method === 'turn.interrupt') {
|
|
407
649
|
if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
|
|
@@ -418,6 +660,12 @@ export function createOrbitServer(options = {}) {
|
|
|
418
660
|
|
|
419
661
|
const eventListener = event => {
|
|
420
662
|
if (!/^(item\/|turn\/|thread\/status|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
|
|
663
|
+
if(event.method==='turn/completed'&&event.params?.threadId){
|
|
664
|
+
for(const session of sessions.values())for(const [id,attachment]of session.attachments){
|
|
665
|
+
if(attachment.threadId===event.params.threadId&&attachment.consumed)removeAttachment(session,id);
|
|
666
|
+
}
|
|
667
|
+
for(const [id,attachment]of consumedAttachments)if(attachment.threadId===event.params.threadId)removeAttachment(null,id);
|
|
668
|
+
}
|
|
421
669
|
if (event.method === 'orbit/desktop' && event.params?.threadId) void applyQueuedDesktopSettings(event.params.threadId,event.params);
|
|
422
670
|
for (const socket of sockets) {
|
|
423
671
|
const threadId = event.params?.threadId;
|
|
@@ -453,7 +701,12 @@ export function createOrbitServer(options = {}) {
|
|
|
453
701
|
}
|
|
454
702
|
for (const [key, value] of dedup) if (Date.now() - value.created > 300000) dedup.delete(key);
|
|
455
703
|
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);
|
|
704
|
+
for (const [key, value] of sessions) if (value.expires < Date.now()) {removeSessionAttachments(value);sessions.delete(key);}
|
|
705
|
+
for(const session of sessions.values()){
|
|
706
|
+
for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
|
|
707
|
+
}
|
|
708
|
+
for(const [id,attachment]of consumedAttachments)if(attachment.expires<Date.now())removeAttachment(null,id);
|
|
709
|
+
if(Date.now()-lastAttachmentCleanup>60*60*1000){cleanupStaleAttachments(store);lastAttachmentCleanup=Date.now();}
|
|
457
710
|
} catch {}
|
|
458
711
|
}, 1200);
|
|
459
712
|
timer.unref();
|
|
@@ -469,6 +722,7 @@ export function createOrbitServer(options = {}) {
|
|
|
469
722
|
async function close() {
|
|
470
723
|
clearInterval(timer); clearInterval(heartbeat);
|
|
471
724
|
for (const socket of sockets) unwatchDesktop(socket);
|
|
725
|
+
for(const session of sessions.values())removeSessionAttachments(session);
|
|
472
726
|
for (const socket of sockets) socket.terminate();
|
|
473
727
|
rpc.off('event', eventListener); rpc.off('lost', lostListener); rpc.close();
|
|
474
728
|
await new Promise(resolve => wss.close(resolve));
|