orbit-codex-bridge 0.1.1 → 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/README.md +3 -3
- package/cli.mjs +4 -13
- package/package.json +1 -1
- package/public/app.js +23 -27
- package/public/index.html +2 -2
- package/relay-agent.mjs +77 -37
- package/relay-server.mjs +13 -5
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ orbit login
|
|
|
29
29
|
|
|
30
30
|
1. 命令会打开电脑授权页面,登录或注册 Orbit 账号,确认添加这台电脑。
|
|
31
31
|
2. 手机打开 [orbit.cubesugar.tech](https://orbit.cubesugar.tech),登录同一账号。
|
|
32
|
-
3.
|
|
32
|
+
3. 登录后选择这台电脑;账号会自动完成安全配对,无需扫码或输入本机密钥。
|
|
33
33
|
4. 选择原有会话,开始使用。尚未加载的会话可先在 Codex 桌面打开,再点“重新连接”。
|
|
34
34
|
|
|
35
35
|
授权信息保存在电脑本地,下次启动会自动复用。连接器默认在当前用户的后台运行,可以关闭安装终端;断网后自动重连,工作进程异常退出后自动恢复。电脑需要保持开机、联网,Codex 桌面需要保持运行。电脑重启后运行 `orbit start` 恢复连接。
|
|
@@ -90,6 +90,6 @@ npx.cmd --yes --package=orbit-codex-bridge@latest orbit rebind
|
|
|
90
90
|
|
|
91
91
|
在 Codex 桌面打开同一条会话,再点击网页中的“重新连接”。桌面热接管依赖 Codex 内部接口,桌面版本更新后可能需要更新 Orbit。
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
**账号登录与设备安全**
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
手机端只需使用 Orbit 账号登录。电脑连接凭证保存在本机,账号登录后由中继自动配对,不会要求输入或传输本机密钥。每台电脑使用独立连接凭证,可在工作台移除电脑,或运行 `orbit logout` 撤销授权。连接中断时,Orbit 不会自动重发已提交的消息和审批。
|
package/cli.mjs
CHANGED
|
@@ -6,7 +6,6 @@ import path from 'node:path';
|
|
|
6
6
|
import process from 'node:process';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import QRCode from 'qrcode';
|
|
10
9
|
import { createOrbitServer } from './server.mjs';
|
|
11
10
|
import { createRelayAgent } from './relay-agent.mjs';
|
|
12
11
|
import {connectorStatus,installConnectorRuntime,startConnector,stopConnector,superviseConnector} from './lib/connector-service.mjs';
|
|
@@ -147,13 +146,6 @@ export async function existingOrbitUrl(port) {
|
|
|
147
146
|
} catch { return null; }
|
|
148
147
|
}
|
|
149
148
|
|
|
150
|
-
async function printPairing(pairingKey, publicUrl) {
|
|
151
|
-
const pairingUrl = `${publicUrl}/#pair=${encodeURIComponent(pairingKey)}`;
|
|
152
|
-
console.log('本机配对二维码和密钥仅供自己的手机使用,请勿公开。');
|
|
153
|
-
console.log(await QRCode.toString(pairingUrl,{type:'terminal',errorCorrectionLevel:'L',small:true}));
|
|
154
|
-
console.log(`本机配对密钥:${pairingKey}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
149
|
async function runComputer(config, background = false) {
|
|
158
150
|
process.env.ORBIT_HOST = '127.0.0.1';
|
|
159
151
|
process.env.ORBIT_PORT = String(config.port || 4318);
|
|
@@ -168,13 +160,14 @@ async function runComputer(config, background = false) {
|
|
|
168
160
|
const info=await app.listen();
|
|
169
161
|
localUrl=info.url;
|
|
170
162
|
console.log(`Orbit 电脑端运行中:${localUrl}`);
|
|
171
|
-
if (!background) await printPairing(app.pairingKey,config.publicUrl);
|
|
172
163
|
}
|
|
173
164
|
agent = createRelayAgent({
|
|
174
165
|
relayUrl:config.relayUrl,
|
|
175
166
|
localUrl,
|
|
176
167
|
deviceId:config.deviceId,
|
|
177
168
|
agentToken:config.agentToken,
|
|
169
|
+
pairingKey:app?.pairingKey,
|
|
170
|
+
pairingKeyFile:path.join(configDirectory(),'pairing-key'),
|
|
178
171
|
onReady:() => console.log(`电脑“${config.deviceName}”已安全连接 Orbit。`),
|
|
179
172
|
onState:state => {
|
|
180
173
|
if (background && process.connected) process.send({type:'connector-state',state:{...state,workerReady:true}},() => {});
|
|
@@ -184,6 +177,7 @@ async function runComputer(config, background = false) {
|
|
|
184
177
|
agent.start();
|
|
185
178
|
if(!app)console.log(`复用本机 Orbit:${localUrl}`);
|
|
186
179
|
console.log(`手机工作台:${config.publicUrl}`);
|
|
180
|
+
console.log('手机登录同一 Orbit 账号,选择这台电脑即可,无需输入配对密钥。');
|
|
187
181
|
if (!background) console.log('前台诊断模式;保持此窗口运行,按 Ctrl+C 停止。');
|
|
188
182
|
await new Promise(resolve => {
|
|
189
183
|
const stop = () => {
|
|
@@ -211,10 +205,7 @@ async function runBackground(config) {
|
|
|
211
205
|
console.log('可以关闭此终端。断网自动重连,异常退出自动恢复;电脑休眠期间无法远程使用。');
|
|
212
206
|
console.log('停止:orbit stop;状态:orbit status;前台诊断:orbit login --foreground');
|
|
213
207
|
console.log(`诊断日志:${path.join(configDirectory(),'connector.log')}`);
|
|
214
|
-
|
|
215
|
-
const key = (await fs.readFile(path.join(configDirectory(),'pairing-key'),'utf8')).trim();
|
|
216
|
-
if (key) await printPairing(key,config.publicUrl);
|
|
217
|
-
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
208
|
+
console.log('手机登录同一 Orbit 账号,选择这台电脑即可,无需输入配对密钥。');
|
|
218
209
|
}
|
|
219
210
|
|
|
220
211
|
async function revokeComputer(config) {
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -25,7 +25,7 @@ const paths = {
|
|
|
25
25
|
const icon = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.inbox}</svg>`;
|
|
26
26
|
function decorate(parent = document) { parent.querySelectorAll('[data-icon]').forEach(element => { element.innerHTML = icon(element.dataset.icon); }); }
|
|
27
27
|
function markdown(text) { return DOMPurify.sanitize(marked.parse(String(text || ''), { breaks: true }), { ALLOWED_TAGS: ['p','br','strong','b','em','i','s','del','ul','ol','li','blockquote','pre','code','h1','h2','h3','h4','h5','h6','hr','a','table','thead','tbody','tr','th','td','sup','sub','kbd','details','summary'], ALLOWED_ATTR: ['href','title','start','colspan','rowspan'], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false }); }
|
|
28
|
-
const state = { threads: [], status: null, selected: null, selectionVersion: 0, historyStatus: 'idle', historyError: '', filter: 'all', project: null, search: '', view: 'sessions', socket: null, pending: new Map(), enabled: new Map(), baseMessages: [], olderMessages: [], cursor: null, liveMessages: new Map(), desktopMessages: [], desktopRuntime: null, selectedSettings: null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null,
|
|
28
|
+
const state = { threads: [], status: null, selected: null, selectionVersion: 0, historyStatus: 'idle', historyError: '', filter: 'all', project: null, search: '', view: 'sessions', socket: null, pending: new Map(), enabled: new Map(), baseMessages: [], olderMessages: [], cursor: null, liveMessages: new Map(), desktopMessages: [], desktopRuntime: null, selectedSettings: null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null, approvals: new Map(), reconnect: 0, loggedOut: false, busy: false, turnId: null };
|
|
29
29
|
let refreshTimer;
|
|
30
30
|
let toastTimer;
|
|
31
31
|
let reconnectTimer;
|
|
@@ -69,10 +69,9 @@ async function loadCloudConfig() {
|
|
|
69
69
|
if (!cloudRegistrationEnabled) setCloudAuthMode(false);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function
|
|
72
|
+
function discardCloudPairCode() {
|
|
73
73
|
const fragment=new URLSearchParams(location.hash.slice(1));
|
|
74
74
|
if(!fragment.has('pair'))return;
|
|
75
|
-
state.cloudPairCode=fragment.get('pair');
|
|
76
75
|
history.replaceState(null,'',location.pathname+location.search);
|
|
77
76
|
}
|
|
78
77
|
|
|
@@ -94,7 +93,7 @@ async function api(url, body, signal) {
|
|
|
94
93
|
const generation = workspaceGeneration;
|
|
95
94
|
const headers = {};
|
|
96
95
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
97
|
-
if (state.cloudMode && state.cloudDeviceId && !url.startsWith('/api/cloud/')) headers['X-Orbit-Device-Id'] = state.cloudDeviceId;
|
|
96
|
+
if (state.cloudMode && state.cloudDeviceId && (!url.startsWith('/api/cloud/') || url === '/api/cloud/connect')) headers['X-Orbit-Device-Id'] = state.cloudDeviceId;
|
|
98
97
|
const options = body === undefined ? { headers } : { method: 'POST', headers, body: JSON.stringify(body) };
|
|
99
98
|
if (signal) options.signal = signal;
|
|
100
99
|
let response;
|
|
@@ -113,7 +112,7 @@ async function api(url, body, signal) {
|
|
|
113
112
|
if (response.status === 401 && state.cloudMode && (result.code === 'cloud_login_required' || url.startsWith('/api/cloud/') && url !== '/api/cloud/login')) {
|
|
114
113
|
showCloudLogin(result.error);
|
|
115
114
|
error.handled = true;
|
|
116
|
-
} else if (response.status === 401 && url !== '/api/pair' && !url.startsWith('/api/cloud/')
|
|
115
|
+
} else if (response.status === 401 && !state.cloudMode && url !== '/api/pair' && !url.startsWith('/api/cloud/')) {
|
|
117
116
|
showPairing();
|
|
118
117
|
error.handled = true;
|
|
119
118
|
}
|
|
@@ -127,9 +126,7 @@ function showPairing(error = '') {
|
|
|
127
126
|
select('#workspace').hidden = true; select('#pair-screen').hidden = false;
|
|
128
127
|
select('#cloud-login-screen').hidden = true; select('#cloud-device-screen').hidden = true;
|
|
129
128
|
select('#pair-error').textContent = error;
|
|
130
|
-
select('#pair-screen .pair-form > p').textContent =
|
|
131
|
-
? `首次连接“${state.cloudDevices.find(device => device.id === state.cloudDeviceId)?.name || '这台电脑'}”,请扫描电脑终端中的配对二维码,或粘贴终端显示的本机配对密钥。`
|
|
132
|
-
: '输入电脑上的配对密钥。会话内容仅向已配对的设备开放。';
|
|
129
|
+
select('#pair-screen .pair-form > p').textContent = '输入电脑上的配对密钥。会话内容仅向已配对的设备开放。';
|
|
133
130
|
const fragment = new URLSearchParams(location.hash.slice(1));
|
|
134
131
|
if (fragment.has('pair')) { select('#pair-code').value = fragment.get('pair'); history.replaceState(null, '', location.pathname); }
|
|
135
132
|
}
|
|
@@ -155,9 +152,7 @@ function showCloudDevices(message = '') {
|
|
|
155
152
|
const quota = state.cloudUser?.maxDevices || 1;
|
|
156
153
|
select('#cloud-devices-title').textContent = state.cloudDevices.length ? '选择要连接的电脑' : '连接你的第一台电脑';
|
|
157
154
|
const greeting = `已登录 ${state.cloudUser?.displayName || state.cloudUser?.username}。`;
|
|
158
|
-
select('#cloud-device-message').textContent = state.
|
|
159
|
-
? `${greeting}选择发起连接的电脑,完成本机配对。${message ? ` ${message}` : ''}`
|
|
160
|
-
: message || greeting + (state.cloudDevices.length ? '选择一台电脑继续。' : '先添加你自己的电脑,连接后即可查看原有会话。');
|
|
155
|
+
select('#cloud-device-message').textContent = message || greeting + (state.cloudDevices.length ? '选择一台电脑继续,无需输入配对密钥。' : '先添加你自己的电脑,连接后即可查看原有会话。');
|
|
161
156
|
select('#cloud-device-quota').textContent = `套餐 ${state.cloudUser?.plan || 'free'} · 已添加 ${state.cloudDevices.length} / ${quota} 台电脑`;
|
|
162
157
|
select('#cloud-add-computer').disabled = state.cloudDevices.length >= quota;
|
|
163
158
|
select('#cloud-add-computer').title = state.cloudDevices.length >= quota ? '已达到设备上限,可移除旧电脑或联系管理员调整额度。' : '';
|
|
@@ -186,19 +181,6 @@ async function connectCloudDevice(deviceId) {
|
|
|
186
181
|
if (!device.online) { showCloudDevices(`电脑“${device.name}”尚未上线。请确认电脑已联网,在电脑终端运行 orbit login,后台启动后再点击连接。`); return; }
|
|
187
182
|
state.cloudDeviceId = deviceId;
|
|
188
183
|
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
184
|
await boot();
|
|
203
185
|
} catch (error) {
|
|
204
186
|
if (!error.handled && error.code !== 'STALE_WORKSPACE') showCloudDevices(error.message);
|
|
@@ -240,7 +222,7 @@ async function discoverCloudWorkspace() {
|
|
|
240
222
|
catch (error) { throw new Error('无法连接工作台服务,请检查网络。'); }
|
|
241
223
|
if (generation !== workspaceGeneration) return 'stale';
|
|
242
224
|
if (response.status === 404) return 'local';
|
|
243
|
-
|
|
225
|
+
discardCloudPairCode();
|
|
244
226
|
let configError = '';
|
|
245
227
|
try { await loadCloudConfig(); }
|
|
246
228
|
catch (error) { configError = error.message; }
|
|
@@ -255,8 +237,8 @@ async function discoverCloudWorkspace() {
|
|
|
255
237
|
state.cloudMode = true; state.loggedOut = false;
|
|
256
238
|
state.cloudUser = workspace.user; state.cloudDevices = workspace.devices || [];
|
|
257
239
|
const saved = localStorage.getItem('orbit_cloud_device_id');
|
|
258
|
-
if (
|
|
259
|
-
else if (state.cloudDevices.length === 1 && state.cloudDevices[0].status === 'active'
|
|
240
|
+
if (saved && state.cloudDevices.some(device => device.id === saved && device.status === 'active')) state.cloudDeviceId = saved;
|
|
241
|
+
else if (state.cloudDevices.length === 1 && state.cloudDevices[0].status === 'active') {
|
|
260
242
|
state.cloudDeviceId = state.cloudDevices[0].id;
|
|
261
243
|
localStorage.setItem('orbit_cloud_device_id',state.cloudDeviceId);
|
|
262
244
|
} else {
|
|
@@ -280,6 +262,13 @@ async function boot() {
|
|
|
280
262
|
try {
|
|
281
263
|
const cloudState = await discoverCloudWorkspace();
|
|
282
264
|
if (cloudState !== 'local' && cloudState !== 'ready') return;
|
|
265
|
+
if (cloudState === 'ready') {
|
|
266
|
+
try { await api('/api/cloud/connect',{}); }
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (error.status === 404) throw new Error('云端尚未支持账号直接连接,请先更新云端 Orbit 服务到 0.1.2 或更高版本。');
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
283
272
|
state.status = await api('/api/status');
|
|
284
273
|
if (generation !== workspaceGeneration) return;
|
|
285
274
|
state.loggedOut = false;
|
|
@@ -397,6 +386,7 @@ function scheduleReconnect() {
|
|
|
397
386
|
clearTimeout(reconnectTimer);
|
|
398
387
|
reconnectTimer = setTimeout(async () => {
|
|
399
388
|
try {
|
|
389
|
+
if (state.cloudMode) await api('/api/cloud/connect',{});
|
|
400
390
|
state.status = await api('/api/status');
|
|
401
391
|
state.enabled = new Map(Object.entries(state.status.execution.grants || {}));
|
|
402
392
|
connectSocket();
|
|
@@ -772,12 +762,18 @@ function renderDevices() {
|
|
|
772
762
|
cloudRow.querySelector('span:last-child').classList.toggle('pending', !state.cloudMode);
|
|
773
763
|
select('#copy-url').onclick = async () => { try { await navigator.clipboard.writeText(url); toast('连接地址已复制。'); } catch { toast('浏览器不允许复制,请手动复制地址。'); } };
|
|
774
764
|
select('#show-qr').onclick = async () => {
|
|
765
|
+
if (state.cloudMode) return;
|
|
775
766
|
try {
|
|
776
767
|
const data = await api('/api/pair/qr', { url });
|
|
777
768
|
select('#qr-container').innerHTML = `<img src="${escapeHtml(data.image)}" alt="敏感的设备配对二维码"><p>${escapeHtml(data.warning)}</p>`;
|
|
778
769
|
select('#qr-container').hidden = false;
|
|
779
770
|
} catch (error) { toast(error.message); }
|
|
780
771
|
};
|
|
772
|
+
if (state.cloudMode) {
|
|
773
|
+
const card = select('#show-qr').closest('section');
|
|
774
|
+
card.querySelector('p').textContent = '手机打开工作台,登录同一 Orbit 账号并选择这台电脑,即可继续原有会话,无需扫码或输入密钥。';
|
|
775
|
+
select('#show-qr').hidden = true;
|
|
776
|
+
}
|
|
781
777
|
}
|
|
782
778
|
|
|
783
779
|
function closeNav() { select('#workspace').classList.remove('nav-open'); select('#scrim').hidden = true; }
|
package/public/index.html
CHANGED
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
</section>
|
|
31
31
|
<section id="cloud-device-screen" class="devices-view cloud-device-screen" hidden>
|
|
32
32
|
<header class="cloud-device-header"><div><span class="eyebrow">YOUR CONNECTED COMPUTERS</span><h1 id="cloud-devices-title">选择要连接的电脑</h1><p id="cloud-device-message">只显示绑定到你账号的设备。</p><p id="cloud-device-quota"></p></div><div class="cloud-device-actions"><button id="cloud-refresh-devices" class="button small" type="button">刷新状态</button><button id="cloud-add-computer" class="primary" type="button">添加电脑</button><button id="cloud-logout-picker" class="button small" type="button">退出登录</button></div></header>
|
|
33
|
-
<section id="cloud-onboarding" class="cloud-onboarding" hidden><div><span class="mini-badge">FIRST CONNECTION</span><h2>一条命令,连接你的电脑</h2><p>电脑需要运行 Codex 桌面应用。Windows 用户复制下面命令到 PowerShell 运行。</p><ol><li>命令会检查 Node.js 24+,缺少时通过 winget 安装。</li><li>浏览器打开后登录此 Orbit 账号,确认添加这台电脑。</li><li
|
|
33
|
+
<section id="cloud-onboarding" class="cloud-onboarding" hidden><div><span class="mini-badge">FIRST CONNECTION</span><h2>一条命令,连接你的电脑</h2><p>电脑需要运行 Codex 桌面应用。Windows 用户复制下面命令到 PowerShell 运行。</p><ol><li>命令会检查 Node.js 24+,缺少时通过 winget 安装。</li><li>浏览器打开后登录此 Orbit 账号,确认添加这台电脑。</li><li>手机登录同一账号,选择已上线的电脑即可使用,无需扫码或输入配对密钥。</li></ol><div class="cloud-command-box"><pre class="cloud-install-command"><code id="cloud-install-command"></code></pre><button id="cloud-copy-install" class="button small" type="button">复制命令</button></div><p id="cloud-install-status" class="cloud-install-status" role="status"></p><p class="cloud-release-note">如果电脑没有 winget,请先从 nodejs.org 安装 Node.js 24+。winget 可能会请求系统安装授权。</p><p class="cloud-release-note">macOS / Linux:先安装 Node.js 24+,再运行 <code>npx --yes --package=orbit-codex-bridge@latest orbit login</code>。</p></div></section>
|
|
34
34
|
<div id="cloud-devices-list" class="device-grid cloud-device-list"></div></section>
|
|
35
35
|
<section id="pair-screen" class="pair-screen" hidden>
|
|
36
36
|
<div class="pair-art"><a class="brand light" href="/"><img src="/icon.svg" alt=""><span>orbit<span class="brand-dot">.</span></span></a><div class="orbital-art"><div class="orbit-ring ring-one"></div><div class="orbit-ring ring-two"></div><div class="orbit-core">o</div><span class="orbit-point"></span></div><div class="pair-copy"><span class="eyebrow">YOUR WORK. IN YOUR ORBIT.</span><h1>会话留在电脑,<br>灵感随你出发。</h1><p>不复制上下文,不从头再来。<br>从熟悉的项目,找到原来的对话。</p></div><span class="art-caption">LOCAL FIRST / BUILT FOR CONTINUITY</span></div>
|
|
37
|
-
<div class="pair-form-wrap"><div class="pair-form"><span class="mini-badge">PRIVATE WORKSPACE</span><h2>连接你的工作台</h2><p>输入电脑上的配对密钥。会话内容仅向已配对的设备开放。</p><form id="pair-form"><label for="pair-code">设备配对密钥</label><input id="pair-code" type="password" placeholder="粘贴本机配对密钥" autocomplete="off" required><button class="primary full" type="submit">安全连接 <span>↗</span></button><p id="pair-error" class="error" role="alert"></p></form><div class="pair-help"><span data-icon="shield"></span><div><strong
|
|
37
|
+
<div class="pair-form-wrap"><div class="pair-form"><span class="mini-badge">PRIVATE WORKSPACE</span><h2>连接你的工作台</h2><p>输入电脑上的配对密钥。会话内容仅向已配对的设备开放。</p><form id="pair-form"><label for="pair-code">设备配对密钥</label><input id="pair-code" type="password" placeholder="粘贴本机配对密钥" autocomplete="off" required><button class="primary full" type="submit">安全连接 <span>↗</span></button><p id="pair-error" class="error" role="alert"></p></form><div class="pair-help"><span data-icon="shield"></span><div><strong>原会话始终留在电脑</strong><p>选择原会话后,消息和操作会发送到这台电脑上正在运行的 Codex。</p></div></div><details><summary>在哪里找到密钥?</summary><p>在 Orbit 目录运行 <code>Get-Content .runtime\pairing-key</code>。仅用于可信设备;它不是模型 API Key。</p></details></div><footer>ORBIT <span>原生会话 · 电脑执行</span></footer></div>
|
|
38
38
|
</section>
|
|
39
39
|
<div id="workspace" class="workspace" hidden>
|
|
40
40
|
<button id="scrim" class="scrim" aria-label="收起导航" hidden></button>
|
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,90 @@ export function createRelayAgent(options = {}) {
|
|
|
48
50
|
requests.clear();
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
function
|
|
53
|
+
function requestLocal(method, targetPath, headers, body, cloudSocket) {
|
|
54
|
+
return new Promise((resolve,reject) => {
|
|
55
|
+
if (socket !== cloudSocket || cloudSocket.readyState !== WebSocket.OPEN) return reject(new Error('云端连接已断开。'));
|
|
56
|
+
if (requests.size >= 100) return reject(new Error('电脑端请求过多,请稍后重试。'));
|
|
57
|
+
const target = new URL(targetPath,local);
|
|
58
|
+
const transport = target.protocol === 'https:' ? https : http;
|
|
59
|
+
const request = transport.request(target,{method,headers:{...headers,host:local.host,origin:local.origin},timeout:25000},response => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let size = 0;
|
|
62
|
+
response.on('data',chunk => {
|
|
63
|
+
size += chunk.length;
|
|
64
|
+
if (size > maxBody) request.destroy(new Error('本机响应过大。'));
|
|
65
|
+
else chunks.push(chunk);
|
|
66
|
+
});
|
|
67
|
+
response.on('error',error => request.destroy(error));
|
|
68
|
+
response.on('end',() => {
|
|
69
|
+
requests.delete(request);
|
|
70
|
+
resolve({statusCode:response.statusCode || 502,headers:{
|
|
71
|
+
'content-type':response.headers['content-type'] || 'application/json',
|
|
72
|
+
...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {})
|
|
73
|
+
},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) {
|
|
52
93
|
if (typeof message.path !== 'string' || !message.path.startsWith('/api/') || message.path.startsWith('//')
|
|
53
94
|
|| !['GET','POST'].includes(message.method)) return cloudSocket.close(1008,'Invalid local target');
|
|
54
95
|
const target = new URL(message.path,local);
|
|
55
96
|
if (target.origin !== local.origin) return cloudSocket.close(1008,'Invalid local target');
|
|
56
97
|
const body = Buffer.from(message.body || '', 'base64');
|
|
57
|
-
if (body.length > maxBody
|
|
58
|
-
const transport = target.protocol === 'https:' ? https : http;
|
|
59
|
-
let finished = false;
|
|
60
|
-
const respond = (statusCode,headers,responseBody) => {
|
|
61
|
-
if (finished) return;
|
|
62
|
-
finished = true;
|
|
63
|
-
if (socket === cloudSocket && cloudSocket.readyState === WebSocket.OPEN) {
|
|
64
|
-
cloudSocket.send(JSON.stringify({type:'http-response',requestId:message.requestId,statusCode,headers,body:responseBody.toString('base64')}));
|
|
65
|
-
}
|
|
66
|
-
};
|
|
98
|
+
if (body.length > maxBody) return;
|
|
67
99
|
const headers = {};
|
|
68
100
|
for (const name of ['accept','content-type','cookie','user-agent']) {
|
|
69
101
|
const value = message.headers?.[name];
|
|
70
102
|
if (typeof value === 'string') headers[name] = value;
|
|
71
103
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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'}))
|
|
89
135
|
});
|
|
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);
|
|
136
|
+
}
|
|
98
137
|
}
|
|
99
138
|
|
|
100
139
|
function openLocalSocket(message, cloudSocket) {
|
|
@@ -135,7 +174,7 @@ export function createRelayAgent(options = {}) {
|
|
|
135
174
|
let pendingPing = null;
|
|
136
175
|
let pingSentAt = 0;
|
|
137
176
|
cloudSocket.on('open',() => {
|
|
138
|
-
cloudSocket.send(JSON.stringify({type:'agent-auth',token,deviceId}));
|
|
177
|
+
cloudSocket.send(JSON.stringify({type:'agent-auth',token,deviceId,capabilities:['account-connect']}));
|
|
139
178
|
heartbeatTimer = setInterval(() => {
|
|
140
179
|
if (socket !== cloudSocket || cloudSocket.readyState !== WebSocket.OPEN) return;
|
|
141
180
|
if (pendingPing !== null) {
|
|
@@ -162,6 +201,7 @@ export function createRelayAgent(options = {}) {
|
|
|
162
201
|
if (message.type === 'agent-ready') {
|
|
163
202
|
clearTimeout(readyTimer);retry = 0;onState({status:'online'});onReady(message);return;
|
|
164
203
|
}
|
|
204
|
+
if (message.type === 'account-connect') return void connectAccount(message,cloudSocket);
|
|
165
205
|
if (message.type === 'http-request') return localRequest(message,cloudSocket);
|
|
166
206
|
if (message.type === 'ws-open') return openLocalSocket(message,cloudSocket);
|
|
167
207
|
const bridge = bridges.get(message.bridgeId);
|
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,
|
|
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
|
-
|
|
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);
|