orbit-codex-bridge 0.1.0 → 0.1.2

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/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 || '');
@@ -14,6 +16,11 @@ export function createRelayAgent(options = {}) {
14
16
  const deviceId = options.deviceId || process.env.ORBIT_RELAY_DEVICE_ID || '';
15
17
  const onReady = options.onReady || (() => {});
16
18
  const onError = options.onError || (() => {});
19
+ const onState = options.onState || (() => {});
20
+ const heartbeatInterval = options.heartbeatInterval ?? 15000;
21
+ const heartbeatTimeout = options.heartbeatTimeout ?? 45000;
22
+ const reconnectDelay = options.reconnectDelay ?? 1000;
23
+ if (heartbeatInterval <= 0 || heartbeatTimeout <= heartbeatInterval || reconnectDelay < 0) throw new Error('连接心跳参数无效。');
17
24
  if (relay.username || relay.password || relay.search || relay.hash || relay.pathname !== '/agent'
18
25
  || !(relay.protocol === 'wss:' || relay.protocol === 'ws:' && isLoopback(relay))) {
19
26
  throw new Error('ORBIT_RELAY_URL 必须为 wss://域名/agent;仅本地测试允许 ws。');
@@ -27,6 +34,7 @@ export function createRelayAgent(options = {}) {
27
34
  let stopped = true;
28
35
  let reconnectTimer;
29
36
  let readyTimer;
37
+ let heartbeatTimer;
30
38
  let retry = 0;
31
39
  const bridges = new Map();
32
40
  const requests = new Set();
@@ -42,51 +50,90 @@ export function createRelayAgent(options = {}) {
42
50
  requests.clear();
43
51
  }
44
52
 
45
- 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
+ },body:Buffer.concat(chunks)});
74
+ });
75
+ });
76
+ requests.add(request);
77
+ request.on('timeout',() => request.destroy(new Error('本机 Orbit 响应超时。')));
78
+ request.on('error',error => {
79
+ requests.delete(request);
80
+ reject(error);
81
+ });
82
+ request.end(body);
83
+ });
84
+ }
85
+
86
+ function respondLocal(message, cloudSocket, result) {
87
+ if (socket === cloudSocket && cloudSocket.readyState === WebSocket.OPEN) {
88
+ cloudSocket.send(JSON.stringify({type:'http-response',requestId:message.requestId,...result,body:result.body.toString('base64')}));
89
+ }
90
+ }
91
+
92
+ async function localRequest(message, cloudSocket) {
46
93
  if (typeof message.path !== 'string' || !message.path.startsWith('/api/') || message.path.startsWith('//')
47
94
  || !['GET','POST'].includes(message.method)) return cloudSocket.close(1008,'Invalid local target');
48
95
  const target = new URL(message.path,local);
49
96
  if (target.origin !== local.origin) return cloudSocket.close(1008,'Invalid local target');
50
97
  const body = Buffer.from(message.body || '', 'base64');
51
- if (body.length > maxBody || requests.size >= 100) return;
52
- const transport = target.protocol === 'https:' ? https : http;
53
- let finished = false;
54
- const respond = (statusCode,headers,responseBody) => {
55
- if (finished) return;
56
- finished = true;
57
- if (socket === cloudSocket && cloudSocket.readyState === WebSocket.OPEN) {
58
- cloudSocket.send(JSON.stringify({type:'http-response',requestId:message.requestId,statusCode,headers,body:responseBody.toString('base64')}));
59
- }
60
- };
98
+ if (body.length > maxBody) return;
61
99
  const headers = {};
62
- for (const name of ['accept','content-type','cookie','host','origin','user-agent']) {
100
+ for (const name of ['accept','content-type','cookie','user-agent']) {
63
101
  const value = message.headers?.[name];
64
102
  if (typeof value === 'string') headers[name] = value;
65
103
  }
66
- const request = transport.request(target,{method:message.method,headers,timeout:25000},response => {
67
- const chunks = [];
68
- let size = 0;
69
- response.on('data',chunk => {
70
- size += chunk.length;
71
- if (size > maxBody) request.destroy(new Error('本机响应过大。'));
72
- else chunks.push(chunk);
73
- });
74
- response.on('error',error => request.destroy(error));
75
- response.on('end',() => {
76
- requests.delete(request);
77
- respond(response.statusCode || 502,{
78
- 'content-type':response.headers['content-type'] || 'application/json',
79
- ...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {})
80
- },Buffer.concat(chunks));
104
+ try {
105
+ respondLocal(message,cloudSocket,await requestLocal(message.method,message.path,headers,body,cloudSocket));
106
+ } catch (error) {
107
+ respondLocal(message,cloudSocket,{statusCode:502,headers:{},body:Buffer.from(JSON.stringify({error:error.message}))});
108
+ }
109
+ }
110
+
111
+ async function connectAccount(message, cloudSocket) {
112
+ try {
113
+ const cookie = message.headers?.cookie || '';
114
+ if (cookie) {
115
+ const current = await requestLocal('GET','/api/status',{cookie},undefined,cloudSocket);
116
+ if (current.statusCode === 200) {
117
+ respondLocal(message,cloudSocket,{statusCode:200,headers:{},body:Buffer.from('{"ok":true}')});
118
+ return;
119
+ }
120
+ if (current.statusCode !== 401) throw new Error('本机连接状态异常。');
121
+ }
122
+ const filename = options.pairingKeyFile || process.env.ORBIT_PAIRING_KEY_FILE || path.join(root,'.runtime','pairing-key');
123
+ const pairingKey = String(options.pairingKey || await fs.readFile(filename,'utf8')).trim();
124
+ if (!/^[a-zA-Z0-9_-]{20,100}$/.test(pairingKey)) throw new Error('本机凭证不可用。');
125
+ const paired = await requestLocal('POST','/api/pair',{'content-type':'application/json'},
126
+ Buffer.from(JSON.stringify({code:pairingKey})),cloudSocket);
127
+ if (paired.statusCode !== 200 || !paired.headers['set-cookie']?.some(value => /^orbit_session=[a-zA-Z0-9_-]{20,100};/.test(value))) {
128
+ throw new Error('本机鉴权未完成。');
129
+ }
130
+ respondLocal(message,cloudSocket,{statusCode:200,headers:paired.headers,body:Buffer.from('{"ok":true}')});
131
+ } catch {
132
+ respondLocal(message,cloudSocket,{
133
+ statusCode:503,headers:{},
134
+ body:Buffer.from(JSON.stringify({error:'电脑连接器未能完成账号连接,请在电脑上运行 orbit restart,或更新连接器后重试。',code:'local_account_connect_failed'}))
81
135
  });
82
- });
83
- requests.add(request);
84
- request.on('timeout',() => request.destroy(new Error('本机 Orbit 响应超时。')));
85
- request.on('error',error => {
86
- requests.delete(request);
87
- respond(502,{'content-type':'application/json'},Buffer.from(JSON.stringify({error:error.message})));
88
- });
89
- request.end(body);
136
+ }
90
137
  }
91
138
 
92
139
  function openLocalSocket(message, cloudSocket) {
@@ -95,8 +142,8 @@ export function createRelayAgent(options = {}) {
95
142
  target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
96
143
  const localSocket = new WebSocket(target,{
97
144
  headers:{
98
- Cookie:message.headers?.cookie || '', Origin:message.headers?.origin || '',
99
- Host:message.headers?.host || local.host
145
+ Cookie:message.headers?.cookie || '', Origin:local.origin,
146
+ Host:local.host
100
147
  },
101
148
  maxPayload:maxBody,handshakeTimeout:8000
102
149
  });
@@ -122,8 +169,29 @@ export function createRelayAgent(options = {}) {
122
169
  if (stopped) return;
123
170
  const cloudSocket = new WebSocket(relay,{maxPayload:18*1024*1024,handshakeTimeout:10000});
124
171
  socket = cloudSocket;
172
+ onState({status:'connecting'});
125
173
  readyTimer = setTimeout(() => cloudSocket.terminate(),15000);
126
- cloudSocket.on('open',() => cloudSocket.send(JSON.stringify({type:'agent-auth',token,deviceId})));
174
+ let pendingPing = null;
175
+ let pingSentAt = 0;
176
+ cloudSocket.on('open',() => {
177
+ cloudSocket.send(JSON.stringify({type:'agent-auth',token,deviceId,capabilities:['account-connect']}));
178
+ heartbeatTimer = setInterval(() => {
179
+ if (socket !== cloudSocket || cloudSocket.readyState !== WebSocket.OPEN) return;
180
+ if (pendingPing !== null) {
181
+ if (Date.now() - pingSentAt >= heartbeatTimeout) {
182
+ onError(new Error('云端心跳超时,正在自动重连。'));
183
+ cloudSocket.terminate();
184
+ }
185
+ return;
186
+ }
187
+ pendingPing = `${Date.now()}`;
188
+ pingSentAt = Date.now();
189
+ cloudSocket.ping(pendingPing);
190
+ },heartbeatInterval);
191
+ });
192
+ cloudSocket.on('pong',data => {
193
+ if (data.toString() === pendingPing) pendingPing = null;
194
+ });
127
195
  cloudSocket.on('error',error => { if (!stopped) onError(error); });
128
196
  cloudSocket.on('message',raw => {
129
197
  if (socket !== cloudSocket) return;
@@ -131,8 +199,9 @@ export function createRelayAgent(options = {}) {
131
199
  try { message = JSON.parse(raw.toString()); } catch { cloudSocket.close(1003,'Invalid relay message'); return; }
132
200
  if (!message || typeof message !== 'object') return;
133
201
  if (message.type === 'agent-ready') {
134
- clearTimeout(readyTimer);retry = 0;onReady(message);return;
202
+ clearTimeout(readyTimer);retry = 0;onState({status:'online'});onReady(message);return;
135
203
  }
204
+ if (message.type === 'account-connect') return void connectAccount(message,cloudSocket);
136
205
  if (message.type === 'http-request') return localRequest(message,cloudSocket);
137
206
  if (message.type === 'ws-open') return openLocalSocket(message,cloudSocket);
138
207
  const bridge = bridges.get(message.bridgeId);
@@ -151,11 +220,19 @@ export function createRelayAgent(options = {}) {
151
220
  else bridge.socket.terminate();
152
221
  }
153
222
  });
154
- cloudSocket.on('close',() => {
223
+ cloudSocket.on('close',(code,reason) => {
155
224
  if (socket !== cloudSocket) return;
156
- socket = null;clearTimeout(readyTimer);clearLocalConnections();
225
+ socket = null;clearTimeout(readyTimer);clearInterval(heartbeatTimer);clearLocalConnections();
157
226
  if (!stopped) {
158
- const delay = Math.min(30000,1000 * 2 ** Math.min(5,retry++));
227
+ if (code === 1008 || code === 1012 && reason.toString() === 'Replaced by a newer agent') {
228
+ stopped = true;
229
+ const detail = code === 1008 ? '电脑凭证被拒绝,请检查账号或重新登录。' : '连接已由另一个 Orbit 实例接管。';
230
+ onState({status:'blocked',reason:detail});
231
+ onError(new Error(detail));
232
+ return;
233
+ }
234
+ const delay = Math.min(30000,reconnectDelay * 2 ** Math.min(5,retry++));
235
+ onState({status:'reconnecting',retryIn:delay,reason:reason.toString() || `连接断开 (${code})`});
159
236
  reconnectTimer = setTimeout(connect,delay);
160
237
  }
161
238
  });
@@ -164,7 +241,7 @@ export function createRelayAgent(options = {}) {
164
241
  return {
165
242
  start() { if (!stopped) return; stopped = false;connect(); },
166
243
  stop() {
167
- stopped = true;clearTimeout(reconnectTimer);clearTimeout(readyTimer);
244
+ stopped = true;clearTimeout(reconnectTimer);clearTimeout(readyTimer);clearInterval(heartbeatTimer);
168
245
  const previous = socket;socket = null;previous?.terminate();clearLocalConnections();
169
246
  }
170
247
  };
package/relay-server.mjs CHANGED
@@ -254,12 +254,15 @@ export function createRelayServer(options = {}) {
254
254
  return devices[0];
255
255
  }
256
256
 
257
- async function proxyHttp(request,response,authenticated) {
257
+ async function proxyHttp(request,response,authenticated,accountConnect = false) {
258
258
  let device;
259
259
  try{device=chooseDevice(request,authenticated.user)}
260
260
  catch(error){return json(response,error.status||409,{error:safeError(error),code:error.code||'device_unavailable'})}
261
261
  const agent=agents.get(device.id);
262
262
  if(!agent||agent.socket.readyState!==WebSocket.OPEN)return json(response,503,{error:`电脑“${device.name}”当前不在线。`});
263
+ if(accountConnect&&!agent.accountConnect)return json(response,409,{
264
+ error:'请在电脑上更新 Orbit 连接器到 0.1.2 或更高版本后重试。',code:'connector_upgrade_required'
265
+ });
263
266
  const body=await readBody(request);
264
267
  if (!store.hasSession(authenticated.tokenHash,authenticated.user.id)) return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
265
268
  chooseDevice(request, store.getUser(authenticated.user.id));
@@ -273,9 +276,12 @@ export function createRelayServer(options = {}) {
273
276
  pendingHttp.set(requestId,{resolve,reject,timer,deviceId:device.id,agent,userId:authenticated.user.id,sessionHash:authenticated.tokenHash});
274
277
  });
275
278
  try{
276
- sendAgent(agent,{type:'http-request',requestId,method:request.method,path:request.url,headers,body:body.toString('base64')});
279
+ sendAgent(agent,accountConnect
280
+ ? {type:'account-connect',requestId,headers:{cookie:headers.cookie}}
281
+ : {type:'http-request',requestId,method:request.method,path:request.url,headers,body:body.toString('base64')});
277
282
  const result=await promise;
278
283
  if (!store.hasSession(authenticated.tokenHash,authenticated.user.id)) return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
284
+ chooseDevice(request,store.getUser(authenticated.user.id));
279
285
  response.writeHead(result.statusCode,responseHeaders(result.headers,device.id,secureCookie));
280
286
  response.end(Buffer.from(result.body||'','base64'));
281
287
  }catch(error){if(!response.destroyed)json(response,502,{error:safeError(error)})}
@@ -530,12 +536,13 @@ export function createRelayServer(options = {}) {
530
536
  if(!device)return json(response,403,{error:'设备不属于当前账号。'});
531
537
  return json(response,200,{device:publicDevice(device)});
532
538
  }
533
- if(isLocalApi(url.pathname,request.method)){
539
+ const accountConnect=url.pathname==='/api/cloud/connect'&&request.method==='POST';
540
+ if(accountConnect||isLocalApi(url.pathname,request.method)){
534
541
  if(request.method!=='GET'&&!sameOrigin(request,true))return json(response,403,{error:'来源校验失败。'});
535
542
  const current=userSession(request);if(!current)return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
536
543
  const devices=store.devicesForUser(current.user.id);
537
544
  if(devices.length>Number(current.user.maxDevices))return json(response,403,{error:'设备数量超出套餐额度。'});
538
- return await proxyHttp(request,response,current);
545
+ return await proxyHttp(request,response,current,accountConnect);
539
546
  }
540
547
  const file=staticTarget(url.pathname);
541
548
  if(file&&['GET','HEAD'].includes(request.method)){
@@ -565,7 +572,8 @@ export function createRelayServer(options = {}) {
565
572
  agent=store.authenticateDevice(message.token,message.deviceId);
566
573
  if(!agent){clearTimeout(authTimer);return websocket.close(1008,'Agent authentication failed')}
567
574
  clearTimeout(authTimer);
568
- const entry={socket:websocket,deviceId:agent.id,userId:agent.userId,name:agent.name,isAlive:true};
575
+ const entry={socket:websocket,deviceId:agent.id,userId:agent.userId,name:agent.name,isAlive:true,
576
+ accountConnect:Array.isArray(message.capabilities)&&message.capabilities.includes('account-connect')};
569
577
  const previous=agents.get(agent.id);
570
578
  if(previous&&previous.socket!==websocket)closeDeviceConnections(agent.id,1012,'Replaced by a newer agent');
571
579
  agents.set(agent.id,entry);
package/relay-store.mjs CHANGED
@@ -21,7 +21,7 @@ function safeString(value, label, max = 120) {
21
21
  }
22
22
 
23
23
  function validPassword(value) {
24
- if(typeof value!=='string'||value.length<12||value.length>256||/[\u0000-\u001f\u007f]/.test(value))throw new Error('密码需要 12–256 个字符,不能包含控制字符。');
24
+ if(typeof value!=='string'||value.length<6||value.length>256||/[\u0000-\u001f\u007f]/.test(value))throw new Error('密码需要 6–256 个字符,不能包含控制字符。');
25
25
  return value;
26
26
  }
27
27
 
package/server.mjs CHANGED
@@ -11,6 +11,7 @@ import { CodexRpc } from './lib/rpc.mjs';
11
11
  import { DesktopRpc } from './lib/desktop.mjs';
12
12
 
13
13
  const root = path.dirname(fileURLToPath(import.meta.url));
14
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).version;
14
15
  const sessionLifetime = 12 * 60 * 60 * 1000;
15
16
  const safeError = error => String(error.message || error).replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]').replace(/(?:sk-|sk_)[\w-]{12,}/g, '[REDACTED]').slice(0, 700);
16
17
 
@@ -118,7 +119,7 @@ export function createOrbitServer(options = {}) {
118
119
  const threads = store.list();
119
120
  for (const id of session.enabled.keys()) controlFor(session, id);
120
121
  return {
121
- product: 'Orbit', version: '0.1.0', device: os.hostname(), platform: process.platform, home,
122
+ product: 'Orbit', version: packageVersion, device: os.hostname(), platform: process.platform, home,
122
123
  threadCount: threads.length, projectCount: new Set(threads.map(thread => thread.projectId)).size,
123
124
  archivedCount: threads.filter(thread => thread.archived).length, lockedCount: threads.filter(thread => thread.locked).length,
124
125
  bridge: 'connected', transport: 'websocket', readOnlyMirror: true,
@@ -308,7 +309,8 @@ export function createOrbitServer(options = {}) {
308
309
  const body = message.params || {};
309
310
  const id = body.threadId;
310
311
  if (message.method === 'thread.subscribe') {
311
- if (id && !store.row(id)) throw new Error('会话不存在。');
312
+ const row = id ? store.row(id) : null;
313
+ if (id && !row) throw new Error('会话不存在。');
312
314
  if (socket.desktopSubscription && socket.desktopSubscription !== (id || null)) unwatchDesktop(socket);
313
315
  socket.subscription = id || null;
314
316
  let runtime = null;
@@ -327,6 +329,10 @@ export function createOrbitServer(options = {}) {
327
329
  };
328
330
  }
329
331
  }
332
+ if (executionMode === 'desktop' && runtime?.attached && body.autoControl === true
333
+ && !row.archived && !String(row.source).startsWith('{')) {
334
+ session.enabled.set(id, {sharedExecution:true,desktop:true,writable:false,expires:Date.now() + 1800000});
335
+ }
330
336
  const control = controlFor(session, id);
331
337
  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 }))) : [];
332
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 };
@@ -476,10 +482,10 @@ export function createOrbitServer(options = {}) {
476
482
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
477
483
  const app = createOrbitServer();
478
484
  const info = await app.listen();
479
- console.log(`Orbit 0.1.0 · ${info.url}`);
485
+ console.log(`Orbit ${packageVersion} · ${info.url}`);
480
486
  console.log(`Native conversations: ${app.store.list().length} · default read-only`);
481
487
  console.log(`Pairing key file: ${info.credentialPath}`);
482
- console.log(`Execution channel: ${app.rpc.mode || 'cold-resume'} · desktop hot-attach is opt-in per conversation.`);
488
+ console.log(`Execution channel: ${app.rpc.mode || 'cold-resume'} · desktop chats support automatic hot attach.`);
483
489
  const shutdown = async () => { await app.close(); process.exit(0); };
484
490
  process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
485
491
  }