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
package/public/index.html
CHANGED
|
@@ -7,11 +7,30 @@
|
|
|
7
7
|
<body>
|
|
8
8
|
<section id="cloud-login-screen" class="pair-screen" hidden>
|
|
9
9
|
<div class="pair-art"><a class="brand light" href="/"><img src="/icon.svg" alt=""><span>orbit<span class="brand-dot">.</span></span><small>SECURE CLOUD</small></a><div class="pair-copy"><span class="eyebrow">YOUR DEVICES. YOUR SESSION.</span><h1>连接你的<br>个人工作空间。</h1><p>通过账号连接云端工作台。会话仍留在你的电脑上。</p></div><span class="art-caption">DEVICE-BOUND / LOCAL-FIRST</span></div>
|
|
10
|
-
<div class="pair-form-wrap"><div class="pair-form"
|
|
10
|
+
<div class="pair-form-wrap"><div class="pair-form">
|
|
11
|
+
<span class="mini-badge">CLOUD WORKSPACE</span>
|
|
12
|
+
<div id="cloud-auth-tabs" class="cloud-auth-tabs" role="group" aria-label="账号入口" hidden><button id="cloud-show-login" class="active" type="button" aria-pressed="true">登录</button><button id="cloud-show-register" type="button" aria-pressed="false">创建账号</button></div>
|
|
13
|
+
<h2 id="cloud-auth-title">登录 Orbit</h2><p id="cloud-auth-description">登录后,继续连接你的电脑和原有会话。</p>
|
|
14
|
+
<form id="cloud-login-form">
|
|
15
|
+
<label for="cloud-username">用户名</label><input id="cloud-username" autocomplete="username" maxlength="64" autocapitalize="none" spellcheck="false" required>
|
|
16
|
+
<label for="cloud-password">密码</label><input id="cloud-password" type="password" autocomplete="current-password" maxlength="256" required>
|
|
17
|
+
<button class="primary full" type="submit">安全登录 <span>↗</span></button><p id="cloud-login-error" class="error" role="alert"></p>
|
|
18
|
+
</form>
|
|
19
|
+
<form id="cloud-register-form" hidden>
|
|
20
|
+
<label for="register-username">用户名</label><input id="register-username" autocomplete="username" minlength="3" maxlength="64" autocapitalize="none" spellcheck="false" aria-describedby="register-username-help" required>
|
|
21
|
+
<small id="register-username-help" class="field-help">3–64 位,可用英文字母、数字、点、连字符和下划线。</small>
|
|
22
|
+
<label for="register-display-name">显示名称 <span class="optional">选填</span></label><input id="register-display-name" autocomplete="nickname" maxlength="120" placeholder="你希望我们如何称呼你">
|
|
23
|
+
<label for="register-password">设置密码</label><input id="register-password" type="password" autocomplete="new-password" minlength="6" maxlength="256" placeholder="至少 6 个字符" required>
|
|
24
|
+
<label for="register-password-confirm">确认密码</label><input id="register-password-confirm" type="password" autocomplete="new-password" minlength="6" maxlength="256" required>
|
|
25
|
+
<p class="registration-note">免费账号可连接 1 台电脑。注册后即可添加电脑。</p>
|
|
26
|
+
<button class="primary full" type="submit">创建账号并继续 <span>↗</span></button><p id="cloud-register-error" class="error" role="alert"></p>
|
|
27
|
+
</form>
|
|
28
|
+
<p id="cloud-registration-notice" class="field-help" hidden>当前暂停自助注册,请联系管理员开通账号。</p><p class="cloud-admin-link"><a href="/admin">管理员入口</a></p>
|
|
29
|
+
</div></div>
|
|
11
30
|
</section>
|
|
12
31
|
<section id="cloud-device-screen" class="devices-view cloud-device-screen" hidden>
|
|
13
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>
|
|
14
|
-
<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>授权完成后,终端会启动 Orbit 并显示手机配对二维码;用手机扫描完成首次连接。</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"
|
|
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>授权完成后,终端会启动 Orbit 并显示手机配对二维码;用手机扫描完成首次连接。</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 orbit login</code>。</p></div></section>
|
|
15
34
|
<div id="cloud-devices-list" class="device-grid cloud-device-list"></div></section>
|
|
16
35
|
<section id="pair-screen" class="pair-screen" hidden>
|
|
17
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>
|
package/relay-agent.mjs
CHANGED
|
@@ -14,6 +14,11 @@ export function createRelayAgent(options = {}) {
|
|
|
14
14
|
const deviceId = options.deviceId || process.env.ORBIT_RELAY_DEVICE_ID || '';
|
|
15
15
|
const onReady = options.onReady || (() => {});
|
|
16
16
|
const onError = options.onError || (() => {});
|
|
17
|
+
const onState = options.onState || (() => {});
|
|
18
|
+
const heartbeatInterval = options.heartbeatInterval ?? 15000;
|
|
19
|
+
const heartbeatTimeout = options.heartbeatTimeout ?? 45000;
|
|
20
|
+
const reconnectDelay = options.reconnectDelay ?? 1000;
|
|
21
|
+
if (heartbeatInterval <= 0 || heartbeatTimeout <= heartbeatInterval || reconnectDelay < 0) throw new Error('连接心跳参数无效。');
|
|
17
22
|
if (relay.username || relay.password || relay.search || relay.hash || relay.pathname !== '/agent'
|
|
18
23
|
|| !(relay.protocol === 'wss:' || relay.protocol === 'ws:' && isLoopback(relay))) {
|
|
19
24
|
throw new Error('ORBIT_RELAY_URL 必须为 wss://域名/agent;仅本地测试允许 ws。');
|
|
@@ -27,6 +32,7 @@ export function createRelayAgent(options = {}) {
|
|
|
27
32
|
let stopped = true;
|
|
28
33
|
let reconnectTimer;
|
|
29
34
|
let readyTimer;
|
|
35
|
+
let heartbeatTimer;
|
|
30
36
|
let retry = 0;
|
|
31
37
|
const bridges = new Map();
|
|
32
38
|
const requests = new Set();
|
|
@@ -59,10 +65,12 @@ export function createRelayAgent(options = {}) {
|
|
|
59
65
|
}
|
|
60
66
|
};
|
|
61
67
|
const headers = {};
|
|
62
|
-
for (const name of ['accept','content-type','cookie','
|
|
68
|
+
for (const name of ['accept','content-type','cookie','user-agent']) {
|
|
63
69
|
const value = message.headers?.[name];
|
|
64
70
|
if (typeof value === 'string') headers[name] = value;
|
|
65
71
|
}
|
|
72
|
+
headers.host = local.host;
|
|
73
|
+
headers.origin = local.origin;
|
|
66
74
|
const request = transport.request(target,{method:message.method,headers,timeout:25000},response => {
|
|
67
75
|
const chunks = [];
|
|
68
76
|
let size = 0;
|
|
@@ -95,8 +103,8 @@ export function createRelayAgent(options = {}) {
|
|
|
95
103
|
target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
96
104
|
const localSocket = new WebSocket(target,{
|
|
97
105
|
headers:{
|
|
98
|
-
Cookie:message.headers?.cookie || '', Origin:
|
|
99
|
-
Host:
|
|
106
|
+
Cookie:message.headers?.cookie || '', Origin:local.origin,
|
|
107
|
+
Host:local.host
|
|
100
108
|
},
|
|
101
109
|
maxPayload:maxBody,handshakeTimeout:8000
|
|
102
110
|
});
|
|
@@ -122,8 +130,29 @@ export function createRelayAgent(options = {}) {
|
|
|
122
130
|
if (stopped) return;
|
|
123
131
|
const cloudSocket = new WebSocket(relay,{maxPayload:18*1024*1024,handshakeTimeout:10000});
|
|
124
132
|
socket = cloudSocket;
|
|
133
|
+
onState({status:'connecting'});
|
|
125
134
|
readyTimer = setTimeout(() => cloudSocket.terminate(),15000);
|
|
126
|
-
|
|
135
|
+
let pendingPing = null;
|
|
136
|
+
let pingSentAt = 0;
|
|
137
|
+
cloudSocket.on('open',() => {
|
|
138
|
+
cloudSocket.send(JSON.stringify({type:'agent-auth',token,deviceId}));
|
|
139
|
+
heartbeatTimer = setInterval(() => {
|
|
140
|
+
if (socket !== cloudSocket || cloudSocket.readyState !== WebSocket.OPEN) return;
|
|
141
|
+
if (pendingPing !== null) {
|
|
142
|
+
if (Date.now() - pingSentAt >= heartbeatTimeout) {
|
|
143
|
+
onError(new Error('云端心跳超时,正在自动重连。'));
|
|
144
|
+
cloudSocket.terminate();
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pendingPing = `${Date.now()}`;
|
|
149
|
+
pingSentAt = Date.now();
|
|
150
|
+
cloudSocket.ping(pendingPing);
|
|
151
|
+
},heartbeatInterval);
|
|
152
|
+
});
|
|
153
|
+
cloudSocket.on('pong',data => {
|
|
154
|
+
if (data.toString() === pendingPing) pendingPing = null;
|
|
155
|
+
});
|
|
127
156
|
cloudSocket.on('error',error => { if (!stopped) onError(error); });
|
|
128
157
|
cloudSocket.on('message',raw => {
|
|
129
158
|
if (socket !== cloudSocket) return;
|
|
@@ -131,7 +160,7 @@ export function createRelayAgent(options = {}) {
|
|
|
131
160
|
try { message = JSON.parse(raw.toString()); } catch { cloudSocket.close(1003,'Invalid relay message'); return; }
|
|
132
161
|
if (!message || typeof message !== 'object') return;
|
|
133
162
|
if (message.type === 'agent-ready') {
|
|
134
|
-
clearTimeout(readyTimer);retry = 0;onReady(message);return;
|
|
163
|
+
clearTimeout(readyTimer);retry = 0;onState({status:'online'});onReady(message);return;
|
|
135
164
|
}
|
|
136
165
|
if (message.type === 'http-request') return localRequest(message,cloudSocket);
|
|
137
166
|
if (message.type === 'ws-open') return openLocalSocket(message,cloudSocket);
|
|
@@ -151,11 +180,19 @@ export function createRelayAgent(options = {}) {
|
|
|
151
180
|
else bridge.socket.terminate();
|
|
152
181
|
}
|
|
153
182
|
});
|
|
154
|
-
cloudSocket.on('close',() => {
|
|
183
|
+
cloudSocket.on('close',(code,reason) => {
|
|
155
184
|
if (socket !== cloudSocket) return;
|
|
156
|
-
socket = null;clearTimeout(readyTimer);clearLocalConnections();
|
|
185
|
+
socket = null;clearTimeout(readyTimer);clearInterval(heartbeatTimer);clearLocalConnections();
|
|
157
186
|
if (!stopped) {
|
|
158
|
-
|
|
187
|
+
if (code === 1008 || code === 1012 && reason.toString() === 'Replaced by a newer agent') {
|
|
188
|
+
stopped = true;
|
|
189
|
+
const detail = code === 1008 ? '电脑凭证被拒绝,请检查账号或重新登录。' : '连接已由另一个 Orbit 实例接管。';
|
|
190
|
+
onState({status:'blocked',reason:detail});
|
|
191
|
+
onError(new Error(detail));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const delay = Math.min(30000,reconnectDelay * 2 ** Math.min(5,retry++));
|
|
195
|
+
onState({status:'reconnecting',retryIn:delay,reason:reason.toString() || `连接断开 (${code})`});
|
|
159
196
|
reconnectTimer = setTimeout(connect,delay);
|
|
160
197
|
}
|
|
161
198
|
});
|
|
@@ -164,7 +201,7 @@ export function createRelayAgent(options = {}) {
|
|
|
164
201
|
return {
|
|
165
202
|
start() { if (!stopped) return; stopped = false;connect(); },
|
|
166
203
|
stop() {
|
|
167
|
-
stopped = true;clearTimeout(reconnectTimer);clearTimeout(readyTimer);
|
|
204
|
+
stopped = true;clearTimeout(reconnectTimer);clearTimeout(readyTimer);clearInterval(heartbeatTimer);
|
|
168
205
|
const previous = socket;socket = null;previous?.terminate();clearLocalConnections();
|
|
169
206
|
}
|
|
170
207
|
};
|
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<
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|