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.
@@ -0,0 +1,259 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import http from 'node:http';
4
+ import path from 'node:path';
5
+ import {spawn} from 'node:child_process';
6
+ import {createRequire} from 'node:module';
7
+
8
+ const stateFile = home => path.join(home,'service.json');
9
+ const pause = milliseconds => new Promise(resolve => setTimeout(resolve,milliseconds));
10
+
11
+ async function serviceRequest(home, action = 'status') {
12
+ let saved;
13
+ try { saved = JSON.parse(await fs.readFile(stateFile(home),'utf8')); }
14
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
15
+ if (!Number.isInteger(saved.port) || saved.port < 1 || saved.port > 65535 || !/^[a-f0-9]{64}$/.test(saved.token)) return null;
16
+ try {
17
+ const response = await fetch(`http://127.0.0.1:${saved.port}/${action}`,{
18
+ method:action === 'stop' ? 'POST' : 'GET',
19
+ headers:{Authorization:`Bearer ${saved.token}`},
20
+ signal:AbortSignal.timeout(1500)
21
+ });
22
+ if (!response.ok) return null;
23
+ const result = await response.json();
24
+ return result.product === 'Orbit connector' ? result : null;
25
+ } catch { return null; }
26
+ }
27
+
28
+ export const connectorStatus = home => serviceRequest(home);
29
+
30
+ export async function stopConnector(home) {
31
+ if (!await serviceRequest(home,'stop')) return false;
32
+ for (let attempt = 0; attempt < 50; attempt++) {
33
+ const locked = await fs.access(path.join(home,'connector.lock')).then(() => true,() => false);
34
+ if (!await connectorStatus(home) && !locked) return true;
35
+ await pause(100);
36
+ }
37
+ throw new Error('后台连接器尚未停止,请稍后重试。');
38
+ }
39
+
40
+ async function packageRoot(resolver, name) {
41
+ let directory = path.dirname(resolver.resolve(name));
42
+ while (true) {
43
+ try {
44
+ const manifest = JSON.parse(await fs.readFile(path.join(directory,'package.json'),'utf8'));
45
+ if (manifest.name === name) return directory;
46
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
47
+ const parent = path.dirname(directory);
48
+ if (parent === directory) throw new Error(`找不到依赖:${name}`);
49
+ directory = parent;
50
+ }
51
+ }
52
+
53
+ export async function installConnectorRuntime(source, home) {
54
+ const manifest = JSON.parse(await fs.readFile(path.join(source,'package.json'),'utf8'));
55
+ const target = path.join(home,'runtime',manifest.version);
56
+ try { await fs.access(path.join(target,'ready')); return {cliPath:path.join(target,'cli.mjs'),version:manifest.version}; }
57
+ catch {}
58
+ const temporary = `${target}.${crypto.randomBytes(6).toString('hex')}.tmp`;
59
+ await fs.mkdir(temporary,{recursive:true,mode:0o700});
60
+ const copied = new Map();
61
+ async function copyDependency(name, resolver) {
62
+ const original = await packageRoot(resolver,name);
63
+ if (copied.has(name)) {
64
+ if (copied.get(name) !== original) throw new Error(`依赖 ${name} 存在多个版本,无法安装后台运行时。`);
65
+ return;
66
+ }
67
+ copied.set(name,original);
68
+ const destination = path.join(temporary,'node_modules',name);
69
+ await fs.cp(original,destination,{recursive:true,filter:entry => path.basename(entry) !== 'node_modules'});
70
+ const dependency = JSON.parse(await fs.readFile(path.join(original,'package.json'),'utf8'));
71
+ for (const child of Object.keys(dependency.dependencies || {})) {
72
+ await copyDependency(child,createRequire(path.join(original,'package.json')));
73
+ }
74
+ }
75
+ try {
76
+ for (const filename of ['package.json',...manifest.files]) {
77
+ await fs.cp(path.join(source,filename),path.join(temporary,filename),{recursive:true});
78
+ }
79
+ for (const name of Object.keys(manifest.dependencies || {})) await copyDependency(name,createRequire(path.join(source,'package.json')));
80
+ await fs.writeFile(path.join(temporary,'ready'),manifest.version,{mode:0o600});
81
+ try { await fs.rename(temporary,target); }
82
+ catch (error) {
83
+ if (!['EEXIST','ENOTEMPTY','EPERM'].includes(error.code)) throw error;
84
+ await fs.access(path.join(target,'ready'));
85
+ }
86
+ } finally {
87
+ const resolved = path.resolve(temporary);
88
+ if (path.dirname(resolved) !== path.join(path.resolve(home),'runtime') || !path.basename(resolved).endsWith('.tmp')) throw new Error('无效的临时运行时路径。');
89
+ await fs.rm(resolved,{recursive:true,force:true});
90
+ }
91
+ return {cliPath:path.join(target,'cli.mjs'),version:manifest.version};
92
+ }
93
+
94
+ export async function startConnector({home,cliPath,version}) {
95
+ const running = await connectorStatus(home);
96
+ if (running?.version === version && running.workerReady) return running;
97
+ if (running) await stopConnector(home);
98
+ await fs.mkdir(home,{recursive:true,mode:0o700});
99
+ const startupLog = path.join(home,'service-startup.log');
100
+ const diagnostic = await fs.open(startupLog,'w',0o600);
101
+ let child;
102
+ try {
103
+ child = spawn(process.execPath,[cliPath,'__service'],{
104
+ detached:true,windowsHide:true,stdio:['ignore',diagnostic.fd,diagnostic.fd],env:{...process.env,ORBIT_HOME:home}
105
+ });
106
+ await new Promise((resolve,reject) => {
107
+ child.once('spawn',resolve);
108
+ child.once('error',reject);
109
+ });
110
+ } finally {await diagnostic.close();}
111
+ let exited = false;
112
+ child.once('exit',() => {exited = true;});
113
+ child.unref();
114
+ for (let attempt = 0; attempt < 100; attempt++) {
115
+ const current = await connectorStatus(home);
116
+ if (current?.version === version && current.workerReady) return current;
117
+ if (exited) {
118
+ const diagnostic = (await fs.readFile(startupLog,'utf8').catch(() => '')).slice(-1500);
119
+ throw new Error(`后台连接器启动失败。${diagnostic}\n诊断日志:${startupLog}`);
120
+ }
121
+ await pause(150);
122
+ }
123
+ throw new Error(`后台连接器未就绪。诊断日志:${path.join(home,'connector.log')}`);
124
+ }
125
+
126
+ export async function superviseConnector(options) {
127
+ const {home} = options;
128
+ await fs.mkdir(home,{recursive:true,mode:0o700});
129
+ const lockfile = path.join(home,'connector.lock');
130
+ async function acquire() {
131
+ try {
132
+ const handle = await fs.open(lockfile,'wx',0o600);
133
+ try {await handle.writeFile(String(process.pid));}
134
+ finally {await handle.close();}
135
+ } catch (error) {
136
+ if (error.code !== 'EEXIST') throw error;
137
+ const owner = Number(await fs.readFile(lockfile,'utf8'));
138
+ if (!Number.isInteger(owner) || owner < 1) throw new Error('另一个后台连接器正在启动,请稍后重试。');
139
+ try {process.kill(owner,0);}
140
+ catch (check) {
141
+ if (check.code !== 'ESRCH') throw check;
142
+ await fs.unlink(lockfile);
143
+ await acquire();
144
+ return;
145
+ }
146
+ throw new Error('Orbit 后台连接器已经运行。');
147
+ }
148
+ }
149
+ await acquire();
150
+ try {await runSupervisor(options);}
151
+ finally {
152
+ if ((await fs.readFile(lockfile,'utf8').catch(() => '')) === String(process.pid)) await fs.unlink(lockfile);
153
+ }
154
+ }
155
+
156
+ async function runSupervisor({home,cliPath,version,restartDelay = 1000}) {
157
+ await fs.mkdir(home,{recursive:true,mode:0o700});
158
+ const token = crypto.randomBytes(32).toString('hex');
159
+ const logfile = path.join(home,'connector.log');
160
+ let logBytes = (await fs.stat(logfile).catch(() => ({size:0}))).size;
161
+ let writes = Promise.resolve();
162
+ function log(text) {
163
+ writes = writes.then(async () => {
164
+ const line = `${new Date().toISOString()} ${text.toString().trimEnd()}\n`;
165
+ if (logBytes > 2 * 1024 * 1024) {
166
+ await fs.rename(logfile,`${logfile}.1`);
167
+ logBytes = 0;
168
+ }
169
+ await fs.appendFile(logfile,line,{mode:0o600});
170
+ logBytes += Buffer.byteLength(line);
171
+ }).catch(() => {});
172
+ }
173
+ let worker = null;
174
+ let stopped = false;
175
+ let restartTimer;
176
+ let failures = 0;
177
+ let state = {product:'Orbit connector',version,pid:process.pid,workerReady:false,status:'starting',startedAt:Date.now()};
178
+ const finished = Promise.withResolvers();
179
+ async function stop() {
180
+ if (stopped) return;
181
+ stopped = true;
182
+ clearTimeout(restartTimer);
183
+ if (worker) {
184
+ const previous = worker;
185
+ const exited = new Promise(resolve => previous.once('exit',resolve));
186
+ const force = setTimeout(() => previous.kill(),3000);
187
+ if (previous.connected) previous.send({type:'stop'},() => {});
188
+ else previous.kill();
189
+ await exited;
190
+ clearTimeout(force);
191
+ }
192
+ await new Promise(resolve => control.close(resolve));
193
+ try {
194
+ const saved = JSON.parse(await fs.readFile(stateFile(home),'utf8'));
195
+ if (saved.token === token) await fs.unlink(stateFile(home));
196
+ } catch {}
197
+ log('连接器已停止。');
198
+ await writes;
199
+ finished.resolve();
200
+ }
201
+ const control = http.createServer((request,response) => {
202
+ response.setHeader('Content-Type','application/json');
203
+ response.setHeader('Cache-Control','no-store');
204
+ if (request.headers.origin || request.headers.authorization !== `Bearer ${token}`) {
205
+ response.writeHead(403);response.end('{}');return;
206
+ }
207
+ if (request.method === 'GET' && request.url === '/status') return response.end(JSON.stringify(state));
208
+ if (request.method === 'POST' && request.url === '/stop') {
209
+ response.end(JSON.stringify({...state,stopping:true}));
210
+ void stop();return;
211
+ }
212
+ response.writeHead(404);response.end('{}');
213
+ });
214
+ control.requestTimeout = 5000;
215
+ control.headersTimeout = 5000;
216
+ control.keepAliveTimeout = 1000;
217
+ await new Promise((resolve,reject) => {
218
+ control.once('error',reject);
219
+ control.listen(0,'127.0.0.1',resolve);
220
+ });
221
+ try {
222
+ const temporary = `${stateFile(home)}.${token}.tmp`;
223
+ await fs.writeFile(temporary,JSON.stringify({port:control.address().port,token}),{mode:0o600});
224
+ await fs.rename(temporary,stateFile(home));
225
+ } catch (error) {
226
+ await new Promise(resolve => control.close(resolve));throw error;
227
+ }
228
+ function launchWorker() {
229
+ if (stopped) return;
230
+ const started = Date.now();
231
+ state = {...state,workerReady:false,status:'starting'};
232
+ worker = spawn(process.execPath,[cliPath,'__worker'],{
233
+ windowsHide:true,stdio:['ignore','pipe','pipe','ipc'],env:{...process.env,ORBIT_HOME:home}
234
+ });
235
+ state.workerPid = worker.pid;
236
+ worker.stdout.on('data',log);
237
+ worker.stderr.on('data',log);
238
+ worker.on('error',error => log(error.message));
239
+ worker.on('message',message => {
240
+ if (message?.type === 'connector-state') state = {...state,...message.state};
241
+ });
242
+ worker.once('exit',(code,signal) => {
243
+ worker = null;
244
+ state = {...state,workerReady:false,status:'restarting'};
245
+ if (stopped) return;
246
+ failures = Date.now() - started > 60000 ? 0 : failures + 1;
247
+ const delay = Math.min(30000,restartDelay * 2 ** Math.min(failures - 1,5));
248
+ log(`工作进程退出 (${code ?? signal}),${delay}ms 后自动恢复。`);
249
+ restartTimer = setTimeout(launchWorker,delay);
250
+ });
251
+ }
252
+ process.once('SIGINT',stop);
253
+ process.once('SIGTERM',stop);
254
+ log(`Orbit ${version} 后台连接器启动。`);
255
+ launchWorker();
256
+ await finished.promise;
257
+ process.off('SIGINT',stop);
258
+ process.off('SIGTERM',stop);
259
+ }
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "orbit-codex-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Connect your original Codex desktop conversations to Orbit on the web.",
6
- "engines": { "node": ">=24" },
6
+ "engines": {
7
+ "node": ">=24"
8
+ },
7
9
  "bin": {
8
10
  "orbit": "orbit"
9
11
  },
@@ -23,8 +25,9 @@
23
25
  "relay:agent": "node --env-file-if-exists=.env.local relay-agent.mjs",
24
26
  "prepublishOnly": "npm run check && npm test",
25
27
  "test": "node --test test/*.test.mjs",
28
+ "test:browser": "node --test test/browser/*.test.mjs",
26
29
  "test:native": "node test/native-smoke.mjs",
27
- "check": "node --check cli.mjs && node --check server.mjs && node --check lib/store.mjs && node --check lib/rpc.mjs && node --check lib/desktop.mjs && node --check lib/device-auth.mjs && node --check public/app.js && node --check public/connect.js && node --check relay-server.mjs && node --check relay-store.mjs && node --check relay-agent.mjs && node --check public/admin.js"
30
+ "check": "node --check cli.mjs && node --check server.mjs && node --check lib/store.mjs && node --check lib/rpc.mjs && node --check lib/desktop.mjs && node --check lib/device-auth.mjs && node --check lib/connector-service.mjs && node --check public/app.js && node --check public/connect.js && node --check relay-server.mjs && node --check relay-store.mjs && node --check relay-agent.mjs && node --check public/admin.js"
28
31
  },
29
32
  "dependencies": {
30
33
  "dompurify": "^3.3.0",
package/public/admin.html CHANGED
@@ -11,7 +11,7 @@
11
11
  <section class="overview"><div><span>用户总数</span><strong id="overview-users">0</strong></div><div><span>有效用户</span><strong id="overview-active-users">0</strong></div><div><span>已停用</span><strong id="overview-suspended-users">0</strong></div><div><span>已到期</span><strong id="overview-expired-users">0</strong></div><div><span>有效电脑</span><strong id="overview-devices">0</strong></div><div><span>在线电脑</span><strong id="overview-online">0</strong></div></section>
12
12
  <section class="content-section"><div class="section-heading"><div><span class="eyebrow">TENANTS</span><h2>用户与设备</h2></div><button id="refresh-admin" type="button">刷新</button></div>
13
13
  <div class="user-filters"><label for="user-search">搜索用户</label><input id="user-search" type="search" maxlength="100" placeholder="用户名或显示名称"><label for="user-status">账户状态</label><select id="user-status"><option value="all">全部状态</option><option value="active">有效</option><option value="suspended">已停用</option><option value="expired">已到期</option></select><span id="user-count" role="status"></span></div>
14
- <form id="create-user-form" class="create-user-form"><label>登录名<input name="username" minlength="3" maxlength="64" autocomplete="off" required></label><label>显示名称<input name="displayName" maxlength="120"></label><label>初始密码<input name="password" type="password" minlength="12" autocomplete="new-password" placeholder="留空则自动生成"></label><label>套餐<input name="plan" value="standard" maxlength="40" required></label><label>设备上限<input name="maxDevices" type="number" min="1" max="50" value="2" required></label><label>账号到期<input name="expiresAt" type="datetime-local"></label><button class="primary" type="submit">创建账号</button></form>
14
+ <form id="create-user-form" class="create-user-form"><label>登录名<input name="username" minlength="3" maxlength="64" autocomplete="off" required></label><label>显示名称<input name="displayName" maxlength="120"></label><label>初始密码<input name="password" type="password" minlength="6" maxlength="256" autocomplete="new-password" placeholder="留空则自动生成"></label><label>套餐<input name="plan" value="standard" maxlength="40" required></label><label>设备上限<input name="maxDevices" type="number" min="1" max="50" value="2" required></label><label>账号到期<input name="expiresAt" type="datetime-local"></label><button class="primary" type="submit">创建账号</button></form>
15
15
  <div id="admin-message" role="status" hidden></div><div id="users-list" class="users-list"></div>
16
16
  <nav class="pagination" aria-label="用户列表分页"><button id="users-previous" type="button">上一页</button><span id="users-page">第 1 页</span><button id="users-next" type="button">下一页</button></nav>
17
17
  </section>
package/public/admin.js CHANGED
@@ -148,7 +148,7 @@ document.addEventListener('click',async event=>{
148
148
  showSecret('电脑代理密钥',`只显示一次。把密钥写入电脑的 ORBIT_RELAY_AGENT_TOKEN。设备 ID:${result.device.id}`,result.agentToken);
149
149
  await refresh();
150
150
  }else if(reset){
151
- const password=window.prompt('输入新密码(至少 12 个字符);留空则自动生成');
151
+ const password=window.prompt('输入新密码(至少 6 个字符);留空则自动生成');
152
152
  if(password===null)return;
153
153
  const result=await api(`/api/admin/users/${encodeURIComponent(reset.dataset.reset)}/reset-password`,password?{password}:{});
154
154
  showSecret('新密码已签发','旧用户会话已撤销,请通过安全渠道交付新密码。',result.initialPassword);