orbit-codex-bridge 0.1.0 → 0.1.1
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 +55 -150
- package/cli.mjs +113 -33
- package/lib/connector-service.mjs +259 -0
- package/package.json +6 -3
- package/public/admin.html +1 -1
- package/public/admin.js +1 -1
- package/public/app.js +113 -38
- package/public/connect.html +2 -2
- package/public/index.html +21 -2
- package/relay-agent.mjs +46 -9
- package/relay-store.mjs +1 -1
- package/server.mjs +10 -4
|
@@ -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.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Connect your original Codex desktop conversations to Orbit on the web.",
|
|
6
|
-
"engines": {
|
|
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="
|
|
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('输入新密码(至少
|
|
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);
|
package/public/app.js
CHANGED
|
@@ -29,14 +29,16 @@ const state = { threads: [], status: null, selected: null, selectionVersion: 0,
|
|
|
29
29
|
let refreshTimer;
|
|
30
30
|
let toastTimer;
|
|
31
31
|
let reconnectTimer;
|
|
32
|
+
let controlRenewTimer;
|
|
32
33
|
let desktopConnectionAttempt = 0;
|
|
33
34
|
let desktopConnecting = false;
|
|
34
35
|
let workspaceGeneration = 0;
|
|
35
36
|
let cloudRegistrationEnabled = false;
|
|
36
37
|
let cloudAuthVersion = 0;
|
|
38
|
+
let cloudConnecting = false;
|
|
37
39
|
const computerInstallCommands = {
|
|
38
|
-
windows:'$minimum = 24; $node = Get-Command node -ErrorAction SilentlyContinue; if (
|
|
39
|
-
unix:'npx --yes --package=orbit-codex-bridge orbit login'
|
|
40
|
+
windows:'$minimum = 24; $node = Get-Command node.exe -ErrorAction SilentlyContinue; $nodeMajor = if ($node) { [int](& $node.Source -p "parseInt(process.versions.node)") } else { 0 }; if ($nodeMajor -lt $minimum) { if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) { throw "Node.js 24+ 未安装且找不到 winget;请先从 https://nodejs.org 安装 Node.js 24+。" }; winget.exe install --id OpenJS.NodeJS --exact --source winget --silent --accept-source-agreements --accept-package-agreements; if ($LASTEXITCODE -ne 0) { throw "Node.js 安装失败。" }; $env:Path = "$env:ProgramFiles\\nodejs;$env:Path"; $node = Get-Command node.exe -ErrorAction SilentlyContinue; $nodeMajor = if ($node) { [int](& $node.Source -p "parseInt(process.versions.node)") } else { 0 } }; if (-not $node -or $nodeMajor -lt $minimum) { throw "Node.js 24+ 安装未完成,请关闭并重新打开 PowerShell 后重试。" }; npx.cmd --yes --package=orbit-codex-bridge@latest orbit login',
|
|
41
|
+
unix:'npx --yes --package=orbit-codex-bridge@latest orbit login'
|
|
40
42
|
};
|
|
41
43
|
|
|
42
44
|
function setCloudAuthMode(register = false) {
|
|
@@ -58,7 +60,7 @@ function setCloudAuthMode(register = false) {
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
async function loadCloudConfig() {
|
|
61
|
-
const response = await fetch('/api/cloud/config', { cache:'no-store' });
|
|
63
|
+
const response = await fetch('/api/cloud/config', { cache:'no-store', signal:AbortSignal.timeout(15000) });
|
|
62
64
|
if (!response.ok) throw new Error('无法读取账号设置,请刷新页面重试。');
|
|
63
65
|
const config = await response.json();
|
|
64
66
|
cloudRegistrationEnabled = config.registrationEnabled === true;
|
|
@@ -95,20 +97,39 @@ async function api(url, body, signal) {
|
|
|
95
97
|
if (state.cloudMode && state.cloudDeviceId && !url.startsWith('/api/cloud/')) headers['X-Orbit-Device-Id'] = state.cloudDeviceId;
|
|
96
98
|
const options = body === undefined ? { headers } : { method: 'POST', headers, body: JSON.stringify(body) };
|
|
97
99
|
if (signal) options.signal = signal;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
let response;
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetch(url, {...options, signal:signal || AbortSignal.timeout(35000)});
|
|
104
|
+
result = await response.json();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (generation !== workspaceGeneration) throw Object.assign(new Error('工作台已切换。'), {code:'STALE_WORKSPACE'});
|
|
107
|
+
if (signal?.aborted) throw error;
|
|
108
|
+
throw new Error(error.name === 'TimeoutError' ? '连接超时,请检查电脑端和网络后重试。' : '无法连接 Orbit 服务,请检查网络后重试。');
|
|
109
|
+
}
|
|
100
110
|
if (generation !== workspaceGeneration) throw Object.assign(new Error('工作台已切换。'),{code:'STALE_WORKSPACE'});
|
|
101
|
-
if (response.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const error = Object.assign(new Error(result.error || '请求失败。'), {status:response.status, code:result.code});
|
|
113
|
+
if (response.status === 401 && state.cloudMode && (result.code === 'cloud_login_required' || url.startsWith('/api/cloud/') && url !== '/api/cloud/login')) {
|
|
114
|
+
showCloudLogin(result.error);
|
|
115
|
+
error.handled = true;
|
|
116
|
+
} else if (response.status === 401 && url !== '/api/pair' && !url.startsWith('/api/cloud/') && !state.cloudPairCode) {
|
|
117
|
+
showPairing();
|
|
118
|
+
error.handled = true;
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
105
122
|
return result;
|
|
106
123
|
}
|
|
107
|
-
function showPairing() {
|
|
124
|
+
function showPairing(error = '') {
|
|
108
125
|
state.loggedOut = true; clearTimeout(reconnectTimer);
|
|
109
126
|
state.socket?.close();
|
|
110
127
|
select('#workspace').hidden = true; select('#pair-screen').hidden = false;
|
|
111
128
|
select('#cloud-login-screen').hidden = true; select('#cloud-device-screen').hidden = true;
|
|
129
|
+
select('#pair-error').textContent = error;
|
|
130
|
+
select('#pair-screen .pair-form > p').textContent = state.cloudMode
|
|
131
|
+
? `首次连接“${state.cloudDevices.find(device => device.id === state.cloudDeviceId)?.name || '这台电脑'}”,请扫描电脑终端中的配对二维码,或粘贴终端显示的本机配对密钥。`
|
|
132
|
+
: '输入电脑上的配对密钥。会话内容仅向已配对的设备开放。';
|
|
112
133
|
const fragment = new URLSearchParams(location.hash.slice(1));
|
|
113
134
|
if (fragment.has('pair')) { select('#pair-code').value = fragment.get('pair'); history.replaceState(null, '', location.pathname); }
|
|
114
135
|
}
|
|
@@ -130,6 +151,7 @@ function showCloudDevices(message = '') {
|
|
|
130
151
|
state.cloudDeviceId = null; localStorage.removeItem('orbit_cloud_device_id');
|
|
131
152
|
select('#workspace').hidden = true; select('#pair-screen').hidden = true; select('#cloud-login-screen').hidden = true;
|
|
132
153
|
select('#cloud-device-screen').hidden = false;
|
|
154
|
+
select('#cloud-device-message').setAttribute('role', 'status');
|
|
133
155
|
const quota = state.cloudUser?.maxDevices || 1;
|
|
134
156
|
select('#cloud-devices-title').textContent = state.cloudDevices.length ? '选择要连接的电脑' : '连接你的第一台电脑';
|
|
135
157
|
const greeting = `已登录 ${state.cloudUser?.displayName || state.cloudUser?.username}。`;
|
|
@@ -141,10 +163,51 @@ function showCloudDevices(message = '') {
|
|
|
141
163
|
select('#cloud-add-computer').title = state.cloudDevices.length >= quota ? '已达到设备上限,可移除旧电脑或联系管理员调整额度。' : '';
|
|
142
164
|
select('#cloud-onboarding').hidden = state.cloudDevices.length > 0;
|
|
143
165
|
select('#cloud-devices-list').innerHTML = state.cloudDevices.map(device =>
|
|
144
|
-
`<article class="cloud-computer-card"><button class="device-card cloud-device-option" data-cloud-device="${escapeHtml(device.id)}" ${device.status !== 'active'
|
|
166
|
+
`<article class="cloud-computer-card"><button type="button" class="device-card cloud-device-option" data-cloud-device="${escapeHtml(device.id)}" ${device.status !== 'active' ? 'disabled' : ''}><div class="device-big-icon">${icon('monitor')}</div><h2>${escapeHtml(device.name)}<span class="capability-state ${device.online ? '' : 'pending'}">${device.online ? '在线 · 点击连接' : '电脑离线 · 点击检查'}</span></h2><p>${device.lastSeen ? `最近在线 · ${ago(device.lastSeen)}` : '请在电脑终端运行 orbit login,启动后台连接器。'}</p></button><footer><span>${device.online ? '仅你的账号可连接' : '点击可重新检查上线状态'}</span><button type="button" class="button small" data-cloud-revoke="${escapeHtml(device.id)}">移除电脑</button></footer></article>`
|
|
145
167
|
).join('');
|
|
146
168
|
}
|
|
147
169
|
|
|
170
|
+
async function connectCloudDevice(deviceId) {
|
|
171
|
+
if (cloudConnecting) return;
|
|
172
|
+
cloudConnecting = true;
|
|
173
|
+
const button = select(`[data-cloud-device="${CSS.escape(deviceId)}"]`);
|
|
174
|
+
if (button) {
|
|
175
|
+
button.disabled = true;
|
|
176
|
+
button.setAttribute('aria-busy', 'true');
|
|
177
|
+
button.querySelector('.capability-state').textContent = '正在连接…';
|
|
178
|
+
}
|
|
179
|
+
select('#cloud-device-message').textContent = '正在检查电脑连接,请稍候…';
|
|
180
|
+
try {
|
|
181
|
+
const profile = await api('/api/cloud/me');
|
|
182
|
+
state.cloudUser = profile.user;
|
|
183
|
+
state.cloudDevices = profile.devices || [];
|
|
184
|
+
const device = state.cloudDevices.find(item => item.id === deviceId && item.status === 'active');
|
|
185
|
+
if (!device) { showCloudDevices('这台电脑已被移除或不属于当前账号,请重新选择。'); return; }
|
|
186
|
+
if (!device.online) { showCloudDevices(`电脑“${device.name}”尚未上线。请确认电脑已联网,在电脑终端运行 orbit login,后台启动后再点击连接。`); return; }
|
|
187
|
+
state.cloudDeviceId = deviceId;
|
|
188
|
+
localStorage.setItem('orbit_cloud_device_id', deviceId);
|
|
189
|
+
if (state.cloudPairCode) {
|
|
190
|
+
try {
|
|
191
|
+
await api('/api/pair', {code:state.cloudPairCode});
|
|
192
|
+
state.cloudPairCode = null;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error.status === 401 && !error.handled) {
|
|
195
|
+
state.cloudPairCode = null;
|
|
196
|
+
showPairing(error.message);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
await boot();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (!error.handled && error.code !== 'STALE_WORKSPACE') showCloudDevices(error.message);
|
|
205
|
+
} finally {
|
|
206
|
+
cloudConnecting = false;
|
|
207
|
+
if (button?.isConnected) { button.disabled = false; button.removeAttribute('aria-busy'); }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
148
211
|
async function refreshCloudDevices() {
|
|
149
212
|
const workspace = await api('/api/cloud/me');
|
|
150
213
|
state.cloudUser = workspace.user;
|
|
@@ -153,7 +216,7 @@ async function refreshCloudDevices() {
|
|
|
153
216
|
}
|
|
154
217
|
|
|
155
218
|
function resetCloudWorkspace() {
|
|
156
|
-
state.loggedOut = true; clearTimeout(reconnectTimer); clearTimeout(refreshTimer); refreshTimer = null;
|
|
219
|
+
state.loggedOut = true; clearTimeout(reconnectTimer); clearTimeout(refreshTimer); clearTimeout(controlRenewTimer); refreshTimer = null;
|
|
157
220
|
state.selectionVersion++;
|
|
158
221
|
state.socket?.close();
|
|
159
222
|
state.socket = null;
|
|
@@ -173,7 +236,7 @@ function resetCloudWorkspace() {
|
|
|
173
236
|
async function discoverCloudWorkspace() {
|
|
174
237
|
const generation = workspaceGeneration;
|
|
175
238
|
let response;
|
|
176
|
-
try { response = await fetch('/api/cloud/me', { cache:'no-store' }); }
|
|
239
|
+
try { response = await fetch('/api/cloud/me', { cache:'no-store', signal:AbortSignal.timeout(15000) }); }
|
|
177
240
|
catch (error) { throw new Error('无法连接工作台服务,请检查网络。'); }
|
|
178
241
|
if (generation !== workspaceGeneration) return 'stale';
|
|
179
242
|
if (response.status === 404) return 'local';
|
|
@@ -192,7 +255,7 @@ async function discoverCloudWorkspace() {
|
|
|
192
255
|
state.cloudMode = true; state.loggedOut = false;
|
|
193
256
|
state.cloudUser = workspace.user; state.cloudDevices = workspace.devices || [];
|
|
194
257
|
const saved = localStorage.getItem('orbit_cloud_device_id');
|
|
195
|
-
if (saved && state.cloudDevices.some(device => device.id === saved && device.status === 'active')) state.cloudDeviceId = saved;
|
|
258
|
+
if (!state.cloudPairCode && saved && state.cloudDevices.some(device => device.id === saved && device.status === 'active')) state.cloudDeviceId = saved;
|
|
196
259
|
else if (state.cloudDevices.length === 1 && state.cloudDevices[0].status === 'active' && !state.cloudPairCode) {
|
|
197
260
|
state.cloudDeviceId = state.cloudDevices[0].id;
|
|
198
261
|
localStorage.setItem('orbit_cloud_device_id',state.cloudDeviceId);
|
|
@@ -227,7 +290,7 @@ async function boot() {
|
|
|
227
290
|
state.enabled = new Map(Object.entries(state.status.execution.grants || {}));
|
|
228
291
|
await refreshThreads(); connectSocket();
|
|
229
292
|
} catch (error) {
|
|
230
|
-
if (error.code === 'STALE_WORKSPACE') return;
|
|
293
|
+
if (error.code === 'STALE_WORKSPACE' || error.handled) return;
|
|
231
294
|
if (state.cloudMode) showCloudDevices(error.message);
|
|
232
295
|
else if (!state.loggedOut) { showPairing(); select('#pair-error').textContent = error.message; }
|
|
233
296
|
}
|
|
@@ -341,11 +404,20 @@ function scheduleReconnect() {
|
|
|
341
404
|
}, Math.min(15000, 1000 * 2 ** Math.min(4, state.reconnect++)));
|
|
342
405
|
}
|
|
343
406
|
async function subscribeThread(id) {
|
|
407
|
+
clearTimeout(controlRenewTimer);
|
|
344
408
|
const selectionVersion = state.selectionVersion;
|
|
345
409
|
const attempt = ++desktopConnectionAttempt;
|
|
346
410
|
desktopConnecting = true; updateMode();
|
|
347
411
|
try {
|
|
348
|
-
const
|
|
412
|
+
const desktop = state.status?.execution.mode === 'desktop';
|
|
413
|
+
const eligible = desktop && state.selected?.id === id && !state.selected.archived && !String(state.selected.source).startsWith('{');
|
|
414
|
+
let result = await rpc('thread.subscribe', { threadId:id, autoControl:eligible });
|
|
415
|
+
if (id !== state.selected?.id || selectionVersion !== state.selectionVersion || attempt !== desktopConnectionAttempt) return;
|
|
416
|
+
if (eligible && result.runtime?.attached && !result.control) {
|
|
417
|
+
await rpc('thread.enable', {threadId:id,confirm:true});
|
|
418
|
+
if (id !== state.selected?.id || selectionVersion !== state.selectionVersion || attempt !== desktopConnectionAttempt) return;
|
|
419
|
+
result = await rpc('thread.subscribe', {threadId:id});
|
|
420
|
+
}
|
|
349
421
|
if (id !== state.selected?.id || selectionVersion !== state.selectionVersion || attempt !== desktopConnectionAttempt) return;
|
|
350
422
|
if (result.control) state.enabled.set(id, result.control); else state.enabled.delete(id);
|
|
351
423
|
state.desktopRuntime = result.runtime || null;
|
|
@@ -354,6 +426,13 @@ async function subscribeThread(id) {
|
|
|
354
426
|
state.turnId = result.turnId;
|
|
355
427
|
state.busy = Boolean(result.turnId);
|
|
356
428
|
state.approvals = new Map((result.approvals || []).map(approval => [approval.requestId, approval]));
|
|
429
|
+
if (desktop && result.control) {
|
|
430
|
+
controlRenewTimer = setTimeout(() => {
|
|
431
|
+
if (state.selected?.id === id && state.socket?.readyState === WebSocket.OPEN && state.desktopRuntime?.attached) {
|
|
432
|
+
subscribeThread(id).catch(error => toast(error.message));
|
|
433
|
+
}
|
|
434
|
+
},Math.max(1000,result.control.expires - Date.now() - 120000));
|
|
435
|
+
}
|
|
357
436
|
renderMessages(); renderApprovals();
|
|
358
437
|
} catch (error) {
|
|
359
438
|
if (id === state.selected?.id && selectionVersion === state.selectionVersion && attempt === desktopConnectionAttempt) {
|
|
@@ -650,14 +729,14 @@ function updateMode() {
|
|
|
650
729
|
const attached = desktopConnected && state.desktopRuntime?.attached === true;
|
|
651
730
|
const blocked = thread.archived || (desktop ? !attached || desktopConnecting : thread.locked && !control && state.status?.execution.mode !== 'endpoint');
|
|
652
731
|
select('#enable-control').disabled = Boolean(blocked || control);
|
|
653
|
-
select('#enable-control').textContent = control ? desktop ? '
|
|
732
|
+
select('#enable-control').textContent = control ? desktop ? '热接管中' : '接续已授权' : thread.archived ? '归档只读' : desktopConnecting && desktop ? '连接中…' : desktop && !desktopConnected ? '桌面未连接' : desktop && !attached ? '原会话未连接' : blocked ? '原客户端占用' : desktop ? '重新接管' : '接续会话';
|
|
654
733
|
select('#mode-banner').classList.toggle('controlled', Boolean(control));
|
|
655
734
|
const banner = desktop
|
|
656
735
|
? desktopConnecting ? '正在连接这条原会话;历史仍可查看。'
|
|
657
736
|
: !desktopConnected ? 'Codex 桌面连接已断开。请保持桌面应用运行,再点击“重新连接”。'
|
|
658
737
|
: !attached ? state.desktopRuntime?.reason || '这条原会话尚未连接。请先在 Codex 桌面打开它,再点击“重新连接”。'
|
|
659
|
-
: control ? '
|
|
660
|
-
: '这条原会话已连接 ·
|
|
738
|
+
: control ? '热接管中 · 与电脑共用同一 Codex 执行实例;消息、运行状态和审批实时同步。'
|
|
739
|
+
: '这条原会话已连接 · 点击“重新接管”恢复控制。'
|
|
661
740
|
: control ? `原 ID 接续 · ${control.writable ? '工作区可写 / 操作需审批' : '只读执行沙箱'} · ${control.sharedExecution ? '目标服务已加载此会话' : '独立执行实例'}`
|
|
662
741
|
: thread.locked ? '原客户端持有此会话:独立接续不会抢占桌面执行。' : '只读镜像:查看原生历史不会启动执行实例。';
|
|
663
742
|
const modeBanner = select('#mode-banner');
|
|
@@ -672,7 +751,7 @@ function updateMode() {
|
|
|
672
751
|
select('#send-message').disabled = !control || !connected || desktop && (!attached || desktopConnecting) || waitingForTurn || state.settingsBusy || !select('#message-input').value.trim();
|
|
673
752
|
select('#send-message').title = state.busy && desktop ? '追加指令到当前桌面任务' : '沿用原会话发送';
|
|
674
753
|
select('#stop-turn').hidden = !state.busy || !state.turnId || !control;
|
|
675
|
-
select('#message-input').placeholder = state.busy && desktop ? '追加指令给电脑上正在运行的任务…' : control ? '沿着原来的上下文继续…' : '先确认接续方式,再沿着这条会话继续…';
|
|
754
|
+
select('#message-input').placeholder = state.busy && desktop ? '追加指令给电脑上正在运行的任务…' : control ? '沿着原来的上下文继续…' : desktop ? '连接原桌面会话后继续…' : '先确认接续方式,再沿着这条会话继续…';
|
|
676
755
|
select('#composer-note').textContent = control ? desktop ? '消息进入原 Codex 桌面会话;任务不会迁移或重启。断线不会自动重发。' : '原会话 ID 不变;断线不会自动重发消息。' : '只读查看不会唤醒或打断电脑上的执行。';
|
|
677
756
|
renderModelStatus();
|
|
678
757
|
}
|
|
@@ -805,27 +884,13 @@ document.addEventListener('click', async event => {
|
|
|
805
884
|
try {
|
|
806
885
|
await api(`/api/cloud/devices/${encodeURIComponent(device.id)}/revoke`, {});
|
|
807
886
|
await refreshCloudDevices();
|
|
808
|
-
toast('
|
|
887
|
+
toast('电脑已移除。原电脑运行 orbit rebind 可重新绑定。');
|
|
809
888
|
} catch (error) { toast(error.message); revoke.disabled = false; }
|
|
810
889
|
return;
|
|
811
890
|
}
|
|
812
891
|
const cloudDevice=event.target.closest('[data-cloud-device]');
|
|
813
892
|
if(cloudDevice){
|
|
814
|
-
|
|
815
|
-
localStorage.setItem('orbit_cloud_device_id',state.cloudDeviceId);
|
|
816
|
-
if(state.cloudPairCode){
|
|
817
|
-
cloudDevice.disabled=true;
|
|
818
|
-
try{
|
|
819
|
-
await api('/api/pair',{code:state.cloudPairCode});
|
|
820
|
-
state.cloudPairCode=null;
|
|
821
|
-
showCloudDevices();
|
|
822
|
-
await boot();
|
|
823
|
-
}catch(error){
|
|
824
|
-
showCloudDevices(error.message);
|
|
825
|
-
}
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
await boot();return;
|
|
893
|
+
await connectCloudDevice(cloudDevice.dataset.cloudDevice);return;
|
|
829
894
|
}
|
|
830
895
|
if(event.target.closest('#cloud-logout-picker')){
|
|
831
896
|
try{await api('/api/cloud/logout',{});}catch{}
|
|
@@ -838,7 +903,7 @@ document.addEventListener('click', async event => {
|
|
|
838
903
|
if (!id || select('#reconnect-desktop').disabled) return;
|
|
839
904
|
try {
|
|
840
905
|
await subscribeThread(id);
|
|
841
|
-
if (id === state.selected?.id) toast(state.
|
|
906
|
+
if (id === state.selected?.id) toast(state.enabled.has(id) ? '已连接并接管这条原会话。' : state.desktopRuntime?.reason || '原会话尚未连接。');
|
|
842
907
|
} catch (error) { toast(error.message); }
|
|
843
908
|
return;
|
|
844
909
|
}
|
|
@@ -901,8 +966,18 @@ select('#logout').onclick = async () => {
|
|
|
901
966
|
}
|
|
902
967
|
} catch(error){toast(error.message)}
|
|
903
968
|
};
|
|
904
|
-
select('#enable-control').onclick = () => {
|
|
969
|
+
select('#enable-control').onclick = async () => {
|
|
905
970
|
const desktop = state.status.execution.mode === 'desktop';
|
|
971
|
+
if (desktop) {
|
|
972
|
+
try {
|
|
973
|
+
await subscribeThread(state.selected.id);
|
|
974
|
+
if (state.reopenModelAfterGrant && state.enabled.has(state.selected?.id)) {
|
|
975
|
+
state.reopenModelAfterGrant = false;
|
|
976
|
+
await openModelSettings();
|
|
977
|
+
}
|
|
978
|
+
} catch (error) { toast(error.message); }
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
906
981
|
select('#control-form').reset(); select('#control-error').textContent = '';
|
|
907
982
|
select('#control-title').textContent = desktop ? '授权热接管' : '沿用原会话';
|
|
908
983
|
select('#control-description').textContent = desktop ? '不创建新会话、不迁移执行器。Orbit 将订阅并控制 Codex 桌面当前加载的原会话。' : '保持原会话 ID,使用独立执行通道继续。原客户端占用时不会强行抢锁。';
|
package/public/connect.html
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
</form>
|
|
26
26
|
<form id="connect-register-form" hidden>
|
|
27
27
|
<label for="connect-register-username">用户名</label><input id="connect-register-username" autocomplete="username" minlength="3" maxlength="64" autocapitalize="none" spellcheck="false" required>
|
|
28
|
-
<label for="connect-register-password">设置密码</label><input id="connect-register-password" type="password" autocomplete="new-password" minlength="
|
|
29
|
-
<label for="connect-register-confirm">确认密码</label><input id="connect-register-confirm" type="password" autocomplete="new-password" minlength="
|
|
28
|
+
<label for="connect-register-password">设置密码</label><input id="connect-register-password" type="password" autocomplete="new-password" minlength="6" maxlength="256" placeholder="至少 6 个字符" required>
|
|
29
|
+
<label for="connect-register-confirm">确认密码</label><input id="connect-register-confirm" type="password" autocomplete="new-password" minlength="6" maxlength="256" required>
|
|
30
30
|
<button class="primary full" type="submit">创建账号并确认 <span>↗</span></button>
|
|
31
31
|
<p id="connect-register-error" class="error" role="alert"></p>
|
|
32
32
|
</form>
|