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/public/style.css
CHANGED
|
@@ -37,4 +37,34 @@
|
|
|
37
37
|
.activity-diff .diff-added{color:#a8d89a;background:#47614845}
|
|
38
38
|
.activity-diff .diff-removed{color:#edb0a7;background:#70474445}
|
|
39
39
|
.activity-empty{margin:8px 0;color:#9ba38e;font-size:10px}
|
|
40
|
+
.turn-mode-label{font-size:10px;color:#8b9785}
|
|
41
|
+
.turn-mode-select{max-width:95px;border:1px solid #e2e7dc;background:#f7f8f4;border-radius:6px;padding:5px 7px;color:#506045;font-size:10px}
|
|
42
|
+
.turn-mode-select:disabled{opacity:.55}
|
|
43
|
+
.composer-toolbar .turn-mode-label,.composer-toolbar .turn-mode-select,.composer-toolbar #attach-files{flex:none}
|
|
44
|
+
.composer-toolbar #composer-mode{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
45
|
+
.composer-attachments{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
|
46
|
+
.composer-attachment{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:5px 7px;border:1px solid #dfe6d5;border-radius:6px;background:#f7f9f2;color:#68795a;font-size:9px}
|
|
47
|
+
.composer-attachment>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
48
|
+
.composer-attachment svg{width:13px;height:13px}
|
|
49
|
+
.composer-attachment button{width:20px;height:20px;border-radius:4px;color:#8b9785;font-size:16px;line-height:1}
|
|
50
|
+
.composer-attachment button:hover{background:#e8eddf}
|
|
51
|
+
.composer-attachment button:disabled{opacity:.5}
|
|
52
|
+
.file-browser-dialog{width:min(560px,calc(100vw - 28px));max-width:560px;max-height:min(78dvh,760px);padding:0;border:1px solid #dfe5d7;border-radius:13px;background:#fbfcf8;color:#354536;box-shadow:0 20px 80px #17251a40}
|
|
53
|
+
.file-browser-dialog::backdrop{background:#1b2c22a8;backdrop-filter:blur(3px)}
|
|
54
|
+
.file-browser-panel{display:flex;flex-direction:column;max-height:min(78dvh,760px);padding:20px}
|
|
55
|
+
.file-browser-panel>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding-bottom:12px}
|
|
56
|
+
.file-browser-panel h2{margin:0;font-size:17px;font-weight:550}
|
|
57
|
+
.file-browser-panel header p{margin:5px 0 0;max-width:360px;overflow-wrap:anywhere;color:#7d8a72;font-size:10px}
|
|
58
|
+
.file-browser-notice{font-size:10px;line-height:1.7;color:#899380}
|
|
59
|
+
.file-browser-list{overflow:auto;min-height:100px;max-height:52dvh;border:1px solid var(--line);border-radius:8px;background:#fff}
|
|
60
|
+
.file-browser-entry{width:100%;min-height:43px;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid #eef0e9;text-align:left}
|
|
61
|
+
.file-browser-entry:last-child{border-bottom:0}
|
|
62
|
+
.file-browser-entry:hover{background:#f3f6ed}
|
|
63
|
+
.file-browser-entry>span{color:#80916d;flex:none}
|
|
64
|
+
.file-browser-entry svg{width:15px;height:15px}
|
|
65
|
+
.file-browser-entry strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-weight:450}
|
|
66
|
+
.file-browser-entry small{margin-left:auto;flex:none;color:#929d88;font-size:9px}
|
|
67
|
+
.file-browser-parent{background:#f8faf4;color:#647653}
|
|
68
|
+
.file-browser-empty{padding:16px;color:#909b84;font-size:10px}
|
|
69
|
+
@media(max-width:760px){.composer-toolbar{flex-wrap:wrap}.composer-toolbar #composer-mode{flex:1 1 65px}.turn-mode-select{max-width:92px}.composer-attachment{max-width:100%}.file-browser-panel{padding:16px}.session-settings-bar{gap:5px}.session-settings-bar #browse-files{padding:6px 8px;font-size:9px}}
|
|
40
70
|
@media(max-width:760px){.message-head .message-copy,.copy-code{opacity:1}.activity-message{margin-left:0}.activity-content{padding-left:14px}.activity-meta{max-width:40%;white-space:normal}.message-attachments{margin-left:0}.message-body pre{padding-top:38px}}
|
package/relay-agent.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import https from 'node:https';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import WebSocket from 'ws';
|
|
6
7
|
|
|
7
8
|
const maxBody = 12 * 1024 * 1024;
|
|
8
9
|
const isLoopback = url => ['localhost','127.0.0.1','[::1]'].includes(url.hostname);
|
|
10
|
+
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
|
|
10
12
|
export function createRelayAgent(options = {}) {
|
|
11
13
|
const relay = new URL(options.relayUrl || process.env.ORBIT_RELAY_URL || '');
|
|
@@ -48,53 +50,92 @@ export function createRelayAgent(options = {}) {
|
|
|
48
50
|
requests.clear();
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
function
|
|
53
|
+
function requestLocal(method, targetPath, headers, body, cloudSocket) {
|
|
54
|
+
return new Promise((resolve,reject) => {
|
|
55
|
+
if (socket !== cloudSocket || cloudSocket.readyState !== WebSocket.OPEN) return reject(new Error('云端连接已断开。'));
|
|
56
|
+
if (requests.size >= 100) return reject(new Error('电脑端请求过多,请稍后重试。'));
|
|
57
|
+
const target = new URL(targetPath,local);
|
|
58
|
+
const transport = target.protocol === 'https:' ? https : http;
|
|
59
|
+
const request = transport.request(target,{method,headers:{...headers,host:local.host,origin:local.origin},timeout:25000},response => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let size = 0;
|
|
62
|
+
response.on('data',chunk => {
|
|
63
|
+
size += chunk.length;
|
|
64
|
+
if (size > maxBody) request.destroy(new Error('本机响应过大。'));
|
|
65
|
+
else chunks.push(chunk);
|
|
66
|
+
});
|
|
67
|
+
response.on('error',error => request.destroy(error));
|
|
68
|
+
response.on('end',() => {
|
|
69
|
+
requests.delete(request);
|
|
70
|
+
resolve({statusCode:response.statusCode || 502,headers:{
|
|
71
|
+
'content-type':response.headers['content-type'] || 'application/json',
|
|
72
|
+
...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {}),
|
|
73
|
+
...Object.fromEntries(['content-disposition','content-length','accept-ranges','content-range','etag']
|
|
74
|
+
.filter(name=>typeof response.headers[name]==='string').map(name=>[name,response.headers[name]]))
|
|
75
|
+
},body:Buffer.concat(chunks)});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
requests.add(request);
|
|
79
|
+
request.on('timeout',() => request.destroy(new Error('本机 Orbit 响应超时。')));
|
|
80
|
+
request.on('error',error => {
|
|
81
|
+
requests.delete(request);
|
|
82
|
+
reject(error);
|
|
83
|
+
});
|
|
84
|
+
request.end(body);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function respondLocal(message, cloudSocket, result) {
|
|
89
|
+
if (socket === cloudSocket && cloudSocket.readyState === WebSocket.OPEN) {
|
|
90
|
+
cloudSocket.send(JSON.stringify({type:'http-response',requestId:message.requestId,...result,body:result.body.toString('base64')}));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function localRequest(message, cloudSocket) {
|
|
52
95
|
if (typeof message.path !== 'string' || !message.path.startsWith('/api/') || message.path.startsWith('//')
|
|
53
96
|
|| !['GET','POST'].includes(message.method)) return cloudSocket.close(1008,'Invalid local target');
|
|
54
97
|
const target = new URL(message.path,local);
|
|
55
98
|
if (target.origin !== local.origin) return cloudSocket.close(1008,'Invalid local target');
|
|
56
99
|
const body = Buffer.from(message.body || '', 'base64');
|
|
57
|
-
if (body.length > maxBody
|
|
58
|
-
const transport = target.protocol === 'https:' ? https : http;
|
|
59
|
-
let finished = false;
|
|
60
|
-
const respond = (statusCode,headers,responseBody) => {
|
|
61
|
-
if (finished) return;
|
|
62
|
-
finished = true;
|
|
63
|
-
if (socket === cloudSocket && cloudSocket.readyState === WebSocket.OPEN) {
|
|
64
|
-
cloudSocket.send(JSON.stringify({type:'http-response',requestId:message.requestId,statusCode,headers,body:responseBody.toString('base64')}));
|
|
65
|
-
}
|
|
66
|
-
};
|
|
100
|
+
if (body.length > maxBody) return;
|
|
67
101
|
const headers = {};
|
|
68
|
-
for (const name of ['accept','content-type','cookie','user-agent']) {
|
|
102
|
+
for (const name of ['accept','content-type','cookie','user-agent','range','if-range','if-none-match']) {
|
|
69
103
|
const value = message.headers?.[name];
|
|
70
104
|
if (typeof value === 'string') headers[name] = value;
|
|
71
105
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
try {
|
|
107
|
+
respondLocal(message,cloudSocket,await requestLocal(message.method,message.path,headers,body,cloudSocket));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
respondLocal(message,cloudSocket,{statusCode:502,headers:{},body:Buffer.from(JSON.stringify({error:error.message}))});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function connectAccount(message, cloudSocket) {
|
|
114
|
+
try {
|
|
115
|
+
const cookie = message.headers?.cookie || '';
|
|
116
|
+
if (cookie) {
|
|
117
|
+
const current = await requestLocal('GET','/api/status',{cookie},undefined,cloudSocket);
|
|
118
|
+
if (current.statusCode === 200) {
|
|
119
|
+
respondLocal(message,cloudSocket,{statusCode:200,headers:{},body:Buffer.from('{"ok":true}')});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (current.statusCode !== 401) throw new Error('本机连接状态异常。');
|
|
123
|
+
}
|
|
124
|
+
const filename = options.pairingKeyFile || process.env.ORBIT_PAIRING_KEY_FILE || path.join(root,'.runtime','pairing-key');
|
|
125
|
+
const pairingKey = String(options.pairingKey || await fs.readFile(filename,'utf8')).trim();
|
|
126
|
+
if (!/^[a-zA-Z0-9_-]{20,100}$/.test(pairingKey)) throw new Error('本机凭证不可用。');
|
|
127
|
+
const paired = await requestLocal('POST','/api/pair',{'content-type':'application/json'},
|
|
128
|
+
Buffer.from(JSON.stringify({code:pairingKey})),cloudSocket);
|
|
129
|
+
if (paired.statusCode !== 200 || !paired.headers['set-cookie']?.some(value => /^orbit_session=[a-zA-Z0-9_-]{20,100};/.test(value))) {
|
|
130
|
+
throw new Error('本机鉴权未完成。');
|
|
131
|
+
}
|
|
132
|
+
respondLocal(message,cloudSocket,{statusCode:200,headers:paired.headers,body:Buffer.from('{"ok":true}')});
|
|
133
|
+
} catch {
|
|
134
|
+
respondLocal(message,cloudSocket,{
|
|
135
|
+
statusCode:503,headers:{},
|
|
136
|
+
body:Buffer.from(JSON.stringify({error:'电脑连接器未能完成账号连接,请在电脑上运行 orbit restart,或更新连接器后重试。',code:'local_account_connect_failed'}))
|
|
89
137
|
});
|
|
90
|
-
}
|
|
91
|
-
requests.add(request);
|
|
92
|
-
request.on('timeout',() => request.destroy(new Error('本机 Orbit 响应超时。')));
|
|
93
|
-
request.on('error',error => {
|
|
94
|
-
requests.delete(request);
|
|
95
|
-
respond(502,{'content-type':'application/json'},Buffer.from(JSON.stringify({error:error.message})));
|
|
96
|
-
});
|
|
97
|
-
request.end(body);
|
|
138
|
+
}
|
|
98
139
|
}
|
|
99
140
|
|
|
100
141
|
function openLocalSocket(message, cloudSocket) {
|
|
@@ -135,7 +176,7 @@ export function createRelayAgent(options = {}) {
|
|
|
135
176
|
let pendingPing = null;
|
|
136
177
|
let pingSentAt = 0;
|
|
137
178
|
cloudSocket.on('open',() => {
|
|
138
|
-
cloudSocket.send(JSON.stringify({type:'agent-auth',token,deviceId}));
|
|
179
|
+
cloudSocket.send(JSON.stringify({type:'agent-auth',token,deviceId,capabilities:['account-connect']}));
|
|
139
180
|
heartbeatTimer = setInterval(() => {
|
|
140
181
|
if (socket !== cloudSocket || cloudSocket.readyState !== WebSocket.OPEN) return;
|
|
141
182
|
if (pendingPing !== null) {
|
|
@@ -162,6 +203,7 @@ export function createRelayAgent(options = {}) {
|
|
|
162
203
|
if (message.type === 'agent-ready') {
|
|
163
204
|
clearTimeout(readyTimer);retry = 0;onState({status:'online'});onReady(message);return;
|
|
164
205
|
}
|
|
206
|
+
if (message.type === 'account-connect') return void connectAccount(message,cloudSocket);
|
|
165
207
|
if (message.type === 'http-request') return localRequest(message,cloudSocket);
|
|
166
208
|
if (message.type === 'ws-open') return openLocalSocket(message,cloudSocket);
|
|
167
209
|
const bridge = bridges.get(message.bridgeId);
|
package/relay-server.mjs
CHANGED
|
@@ -71,13 +71,24 @@ function responseHeaders(headers, deviceId, secureCookie) {
|
|
|
71
71
|
const token = paired.split(';')[0].slice('orbit_session='.length);
|
|
72
72
|
safe['Set-Cookie'] = `${deviceCookieName(deviceId)}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? 43200 : 0}${secureCookie ? '; Secure' : ''}`;
|
|
73
73
|
}
|
|
74
|
+
const contentType=headers?.['content-type'];
|
|
75
|
+
if(typeof contentType==='string'&&/^(?:application\/(?:json|pdf|zip|octet-stream)|text\/[a-z0-9.+-]+(?:;\s*charset=utf-8)?|image\/(?:png|jpeg|gif|webp|svg\+xml))$/i.test(contentType))safe['Content-Type']=contentType;
|
|
76
|
+
const disposition=headers?.['content-disposition'];
|
|
77
|
+
if(typeof disposition==='string'&&disposition.length<=1000
|
|
78
|
+
&&/^attachment;\s*filename="[^"\r\n]{1,180}";\s*filename\*=UTF-8''[a-zA-Z0-9%._~-]{1,600}$/.test(disposition))safe['Content-Disposition']=disposition;
|
|
79
|
+
const length=headers?.['content-length'];
|
|
80
|
+
if(/^\d{1,8}$/.test(String(length||'')))safe['Content-Length']=length;
|
|
81
|
+
if(/^(?:bytes \d+-\d+\/\d+|bytes \*\/\d+)$/.test(headers?.['content-range']||''))safe['Content-Range']=headers['content-range'];
|
|
82
|
+
if(headers?.['accept-ranges']==='bytes')safe['Accept-Ranges']='bytes';
|
|
83
|
+
if(typeof headers?.etag==='string'&&/^"orbit-\d+-\d+"$/.test(headers.etag))safe.ETag=headers.etag;
|
|
74
84
|
return safe;
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
function isLocalApi(pathname, method) {
|
|
78
88
|
if (method === 'GET') return ['/api/status','/api/threads','/api/models'].includes(pathname)
|
|
79
|
-
|| /^\/api\/threads\/[a-zA-Z0-9_-]{8,120}\/messages$/.test(pathname);
|
|
80
|
-
return method === 'POST' && ['/api/pair','/api/logout','/api/pair/qr'].includes(pathname)
|
|
89
|
+
|| /^\/api\/threads\/[a-zA-Z0-9_-]{8,120}\/(?:messages|files|file)$/.test(pathname);
|
|
90
|
+
return method === 'POST' && (['/api/pair','/api/logout','/api/pair/qr'].includes(pathname)
|
|
91
|
+
|| /^\/api\/threads\/[a-zA-Z0-9_-]{8,120}\/attachments(?:\/remove)?$/.test(pathname));
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
function json(response, status, body, headers = {}) {
|
|
@@ -254,18 +265,21 @@ export function createRelayServer(options = {}) {
|
|
|
254
265
|
return devices[0];
|
|
255
266
|
}
|
|
256
267
|
|
|
257
|
-
async function proxyHttp(request,response,authenticated) {
|
|
268
|
+
async function proxyHttp(request,response,authenticated,accountConnect = false) {
|
|
258
269
|
let device;
|
|
259
270
|
try{device=chooseDevice(request,authenticated.user)}
|
|
260
271
|
catch(error){return json(response,error.status||409,{error:safeError(error),code:error.code||'device_unavailable'})}
|
|
261
272
|
const agent=agents.get(device.id);
|
|
262
273
|
if(!agent||agent.socket.readyState!==WebSocket.OPEN)return json(response,503,{error:`电脑“${device.name}”当前不在线。`});
|
|
274
|
+
if(accountConnect&&!agent.accountConnect)return json(response,409,{
|
|
275
|
+
error:'请在电脑上更新 Orbit 连接器到 0.1.3 或更高版本后重试。',code:'connector_upgrade_required'
|
|
276
|
+
});
|
|
263
277
|
const body=await readBody(request);
|
|
264
278
|
if (!store.hasSession(authenticated.tokenHash,authenticated.user.id)) return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
|
|
265
279
|
chooseDevice(request, store.getUser(authenticated.user.id));
|
|
266
280
|
const requestId=crypto.randomUUID();
|
|
267
281
|
const headers={};
|
|
268
|
-
for(const name of ['accept','content-type','host','origin','user-agent'])if(request.headers[name])headers[name]=request.headers[name];
|
|
282
|
+
for(const name of ['accept','content-type','host','origin','user-agent','range','if-range','if-none-match'])if(request.headers[name])headers[name]=request.headers[name];
|
|
269
283
|
headers.cookie = localCookie(request,device.id);
|
|
270
284
|
if(pendingHttp.size>=500)return json(response,429,{error:'请求过多,请稍后重试。'});
|
|
271
285
|
const promise=new Promise((resolve,reject)=>{
|
|
@@ -273,9 +287,12 @@ export function createRelayServer(options = {}) {
|
|
|
273
287
|
pendingHttp.set(requestId,{resolve,reject,timer,deviceId:device.id,agent,userId:authenticated.user.id,sessionHash:authenticated.tokenHash});
|
|
274
288
|
});
|
|
275
289
|
try{
|
|
276
|
-
sendAgent(agent,
|
|
290
|
+
sendAgent(agent,accountConnect
|
|
291
|
+
? {type:'account-connect',requestId,headers:{cookie:headers.cookie}}
|
|
292
|
+
: {type:'http-request',requestId,method:request.method,path:request.url,headers,body:body.toString('base64')});
|
|
277
293
|
const result=await promise;
|
|
278
294
|
if (!store.hasSession(authenticated.tokenHash,authenticated.user.id)) return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
|
|
295
|
+
chooseDevice(request,store.getUser(authenticated.user.id));
|
|
279
296
|
response.writeHead(result.statusCode,responseHeaders(result.headers,device.id,secureCookie));
|
|
280
297
|
response.end(Buffer.from(result.body||'','base64'));
|
|
281
298
|
}catch(error){if(!response.destroyed)json(response,502,{error:safeError(error)})}
|
|
@@ -530,12 +547,13 @@ export function createRelayServer(options = {}) {
|
|
|
530
547
|
if(!device)return json(response,403,{error:'设备不属于当前账号。'});
|
|
531
548
|
return json(response,200,{device:publicDevice(device)});
|
|
532
549
|
}
|
|
533
|
-
|
|
550
|
+
const accountConnect=url.pathname==='/api/cloud/connect'&&request.method==='POST';
|
|
551
|
+
if(accountConnect||isLocalApi(url.pathname,request.method)){
|
|
534
552
|
if(request.method!=='GET'&&!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
|
|
535
553
|
const current=userSession(request);if(!current)return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
|
|
536
554
|
const devices=store.devicesForUser(current.user.id);
|
|
537
555
|
if(devices.length>Number(current.user.maxDevices))return json(response,403,{error:'设备数量超出套餐额度。'});
|
|
538
|
-
return await proxyHttp(request,response,current);
|
|
556
|
+
return await proxyHttp(request,response,current,accountConnect);
|
|
539
557
|
}
|
|
540
558
|
const file=staticTarget(url.pathname);
|
|
541
559
|
if(file&&['GET','HEAD'].includes(request.method)){
|
|
@@ -565,7 +583,8 @@ export function createRelayServer(options = {}) {
|
|
|
565
583
|
agent=store.authenticateDevice(message.token,message.deviceId);
|
|
566
584
|
if(!agent){clearTimeout(authTimer);return websocket.close(1008,'Agent authentication failed')}
|
|
567
585
|
clearTimeout(authTimer);
|
|
568
|
-
const entry={socket:websocket,deviceId:agent.id,userId:agent.userId,name:agent.name,isAlive:true
|
|
586
|
+
const entry={socket:websocket,deviceId:agent.id,userId:agent.userId,name:agent.name,isAlive:true,
|
|
587
|
+
accountConnect:Array.isArray(message.capabilities)&&message.capabilities.includes('account-connect')};
|
|
569
588
|
const previous=agents.get(agent.id);
|
|
570
589
|
if(previous&&previous.socket!==websocket)closeDeviceConnections(agent.id,1012,'Replaced by a newer agent');
|
|
571
590
|
agents.set(agent.id,entry);
|