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/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 localRequest(message, cloudSocket) {
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 || requests.size >= 100) return;
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
- headers.host = local.host;
73
- headers.origin = local.origin;
74
- const request = transport.request(target,{method:message.method,headers,timeout:25000},response => {
75
- const chunks = [];
76
- let size = 0;
77
- response.on('data',chunk => {
78
- size += chunk.length;
79
- if (size > maxBody) request.destroy(new Error('本机响应过大。'));
80
- else chunks.push(chunk);
81
- });
82
- response.on('error',error => request.destroy(error));
83
- response.on('end',() => {
84
- requests.delete(request);
85
- respond(response.statusCode || 502,{
86
- 'content-type':response.headers['content-type'] || 'application/json',
87
- ...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {})
88
- },Buffer.concat(chunks));
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,{type:'http-request',requestId,method:request.method,path:request.url,headers,body:body.toString('base64')});
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
- if(isLocalApi(url.pathname,request.method)){
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);