orbit-codex-bridge 0.1.6 → 0.1.8

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,97 @@
1
+ const registryUrl = 'https://registry.npmjs.org/orbit-codex-bridge/latest';
2
+ const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][a-zA-Z0-9-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][a-zA-Z0-9-]*))*))?(?:\+[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)?$/;
3
+
4
+ function parseVersion(value) {
5
+ if (typeof value !== 'string' || value.length > 120) throw new Error('版本格式无效。');
6
+ const match = value.match(versionPattern);
7
+ if (!match) throw new Error('版本格式无效。');
8
+ return { core:match.slice(1, 4).map(part => BigInt(part)), prerelease:match[4]?.split('.') || [] };
9
+ }
10
+
11
+ export function compareVersions(left, right) {
12
+ const first = parseVersion(left);
13
+ const second = parseVersion(right);
14
+ for (let index = 0; index < 3; index++) {
15
+ if (first.core[index] !== second.core[index]) return first.core[index] > second.core[index] ? 1 : -1;
16
+ }
17
+ if (!first.prerelease.length || !second.prerelease.length) {
18
+ return first.prerelease.length === second.prerelease.length ? 0 : first.prerelease.length ? -1 : 1;
19
+ }
20
+ for (let index = 0; index < Math.max(first.prerelease.length, second.prerelease.length); index++) {
21
+ const firstPart = first.prerelease[index];
22
+ const secondPart = second.prerelease[index];
23
+ if (firstPart === secondPart) continue;
24
+ if (firstPart === undefined || secondPart === undefined) return firstPart === undefined ? -1 : 1;
25
+ const firstNumeric = /^\d+$/.test(firstPart);
26
+ const secondNumeric = /^\d+$/.test(secondPart);
27
+ if (firstNumeric !== secondNumeric) return firstNumeric ? -1 : 1;
28
+ return firstNumeric ? BigInt(firstPart) > BigInt(secondPart) ? 1 : -1 : firstPart > secondPart ? 1 : -1;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ export function updateCommand(platform = process.platform) {
34
+ return `${platform === 'win32' ? 'npx.cmd' : 'npx'} --yes --package=orbit-codex-bridge@latest orbit login`;
35
+ }
36
+
37
+ async function readMetadata(response) {
38
+ if (!response.ok || !response.body) throw new Error('更新服务暂不可用。');
39
+ const reader = response.body.getReader();
40
+ const chunks = [];
41
+ let bytes = 0;
42
+ try {
43
+ while (true) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+ bytes += value.byteLength;
47
+ if (bytes > 256 * 1024) throw new Error('版本信息过大。');
48
+ chunks.push(Buffer.from(value));
49
+ }
50
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
51
+ } finally { await reader.cancel().catch(() => {}); }
52
+ }
53
+
54
+ export function createUpdateChecker(currentVersion, {
55
+ fetchImpl = globalThis.fetch,
56
+ now = Date.now,
57
+ platform = process.platform,
58
+ timeout = 5000,
59
+ cacheLifetime = 6 * 60 * 60 * 1000,
60
+ failureLifetime = 5 * 60 * 1000,
61
+ minimumInterval = 60000
62
+ } = {}) {
63
+ parseVersion(currentVersion);
64
+ let result = { currentVersion, latestVersion:null, available:false, status:'unchecked', checkedAt:null, command:updateCommand(platform) };
65
+ let inFlight = null;
66
+ const snapshot = () => ({ ...result });
67
+ async function check({ force = false } = {}) {
68
+ if (inFlight) return inFlight;
69
+ const age = result.checkedAt === null ? Infinity : Math.max(0, now() - result.checkedAt);
70
+ const lifetime = force ? minimumInterval : result.status === 'unavailable' ? failureLifetime : cacheLifetime;
71
+ if (age < lifetime) return snapshot();
72
+ inFlight = (async () => {
73
+ try {
74
+ const response = await fetchImpl(registryUrl, {
75
+ headers:{ Accept:'application/json' }, signal:AbortSignal.timeout(timeout), redirect:'error', cache:'no-store'
76
+ });
77
+ const metadata = await readMetadata(response);
78
+ if (metadata.name !== 'orbit-codex-bridge') throw new Error('包信息不一致。');
79
+ const comparison = compareVersions(metadata.version, currentVersion);
80
+ result = {
81
+ currentVersion, latestVersion:metadata.version, available:comparison > 0,
82
+ status:comparison > 0 ? 'update-available' : comparison < 0 ? 'ahead' : 'up-to-date',
83
+ checkedAt:now(), command:updateCommand(platform)
84
+ };
85
+ } catch {
86
+ result = {
87
+ currentVersion, latestVersion:null, available:false, status:'unavailable', checkedAt:now(),
88
+ command:updateCommand(platform), message:'暂时无法检查更新,请稍后重试。'
89
+ };
90
+ }
91
+ return snapshot();
92
+ })();
93
+ try { return await inFlight; }
94
+ finally { inFlight = null; }
95
+ }
96
+ return { check, snapshot };
97
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "orbit-codex-bridge",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
- "description": "Connect your original Codex desktop conversations to Orbit on the web.",
5
+ "description": "Connect your computer and original Codex conversations to Orbit.",
6
6
  "engines": {
7
7
  "node": ">=24"
8
8
  },
@@ -14,25 +14,26 @@
14
14
  "orbit",
15
15
  "server.mjs",
16
16
  "relay-agent.mjs",
17
- "relay-server.mjs",
18
- "relay-store.mjs",
19
- "lib/",
20
- "public/"
17
+ "lib/approvals.mjs",
18
+ "lib/client-files.mjs",
19
+ "lib/connector-service.mjs",
20
+ "lib/desktop.mjs",
21
+ "lib/resume-manager.mjs",
22
+ "lib/rpc.mjs",
23
+ "lib/store.mjs",
24
+ "lib/updates.mjs",
25
+ "scripts/verify-client-package.mjs"
21
26
  ],
22
27
  "scripts": {
23
- "start": "node --env-file-if-exists=.env.local server.mjs",
24
- "relay": "node --env-file-if-exists=.env.relay relay-server.mjs",
25
- "relay:agent": "node --env-file-if-exists=.env.local relay-agent.mjs",
26
- "prepublishOnly": "npm run check && npm test",
27
- "test": "node --test test/*.test.mjs",
28
- "test:browser": "node --test test/browser/*.test.mjs",
29
- "test:native": "node test/native-smoke.mjs",
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/resume-manager.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
+ "start": "node server.mjs",
29
+ "prepublishOnly": "node scripts/verify-client-package.mjs"
31
30
  },
32
31
  "dependencies": {
33
- "dompurify": "^3.3.0",
34
- "marked": "^17.0.0",
35
- "qrcode": "^1.5.4",
36
32
  "ws": "^8.18.3"
37
- }
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "access": "public"
37
+ },
38
+ "orbitDistribution": "client"
38
39
  }
package/relay-agent.mjs CHANGED
@@ -69,9 +69,7 @@ export function createRelayAgent(options = {}) {
69
69
  requests.delete(request);
70
70
  resolve({statusCode:response.statusCode || 502,headers:{
71
71
  'content-type':response.headers['content-type'] || 'application/json',
72
- ...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {}),
73
- ...Object.fromEntries(['content-disposition','content-length','accept-ranges','content-range','etag']
74
- .filter(name=>typeof response.headers[name]==='string').map(name=>[name,response.headers[name]]))
72
+ ...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {})
75
73
  },body:Buffer.concat(chunks)});
76
74
  });
77
75
  });
@@ -99,7 +97,7 @@ export function createRelayAgent(options = {}) {
99
97
  const body = Buffer.from(message.body || '', 'base64');
100
98
  if (body.length > maxBody) return;
101
99
  const headers = {};
102
- for (const name of ['accept','content-type','cookie','user-agent','range','if-range','if-none-match']) {
100
+ for (const name of ['accept','content-type','cookie','user-agent']) {
103
101
  const value = message.headers?.[name];
104
102
  if (typeof value === 'string') headers[name] = value;
105
103
  }
@@ -228,8 +226,15 @@ export function createRelayAgent(options = {}) {
228
226
  if (!stopped) {
229
227
  if (code === 1008 || code === 1012 && reason.toString() === 'Replaced by a newer agent') {
230
228
  stopped = true;
231
- const detail = code === 1008 ? '电脑凭证被拒绝,请检查账号或重新登录。' : '连接已由另一个 Orbit 实例接管。';
232
- onState({status:'blocked',reason:detail});
229
+ const replaced = code === 1012;
230
+ const authenticationFailed = code === 1008 && reason.toString() === 'Agent authentication failed';
231
+ const reasonCode = replaced ? 'AGENT_REPLACED' : authenticationFailed ? 'DEVICE_AUTH_REJECTED' : 'AGENT_ACCESS_DENIED';
232
+ const detail = replaced
233
+ ? '连接已由另一个 Orbit 实例接管。请只保留一个连接器实例;确认后运行 orbit restart。'
234
+ : authenticationFailed
235
+ ? '云端拒绝了此电脑的连接凭证。请确认账号有效、电脑绑定未撤销;需要重新绑定时运行 orbit rebind。'
236
+ : '云端拒绝了此电脑的访问。请检查账号状态、有效期及电脑绑定;恢复权限后运行 orbit restart。';
237
+ onState({status:'blocked',reason:detail,reasonCode});
233
238
  onError(new Error(detail));
234
239
  return;
235
240
  }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { clientFiles, clientManifest } from '../lib/client-files.mjs';
6
+
7
+ export function verifyClientPackage(directory) {
8
+ const root=fs.realpathSync(directory);
9
+ const manifest=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
10
+ if (!isDeepStrictEqual(manifest,clientManifest(manifest))) throw new Error('客户端 package.json 不符合发布白名单。');
11
+ const allowed=new Set(['package.json','package-lock.json','README.md',...clientFiles]);
12
+ const found=new Set();
13
+ const visit=(current,prefix='')=>{
14
+ for(const entry of fs.readdirSync(current,{withFileTypes:true})){
15
+ if(!prefix&&entry.name==='node_modules'){
16
+ if(!entry.isDirectory()||entry.isSymbolicLink())throw new Error('node_modules 必须是普通目录。');
17
+ continue;
18
+ }
19
+ const relative=prefix+entry.name;
20
+ if(entry.isSymbolicLink())throw new Error(`客户端发布目录不允许符号链接:${relative}`);
21
+ if(entry.isDirectory())visit(path.join(current,entry.name),`${relative}/`);
22
+ else if(entry.isFile()&&allowed.has(relative))found.add(relative);
23
+ else throw new Error(`客户端包含非白名单文件:${relative}`);
24
+ }
25
+ };
26
+ visit(root);
27
+ for(const filename of ['package.json','README.md',...clientFiles])if(!found.has(filename))throw new Error(`客户端缺少文件:${filename}`);
28
+ return {version:manifest.version,files:[...found].sort()};
29
+ }
30
+
31
+ if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
32
+ const result=verifyClientPackage(path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'));
33
+ console.log(`Client package verified: ${result.version} · ${result.files.length} files`);
34
+ }
package/server.mjs CHANGED
@@ -5,14 +5,15 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { WebSocketServer, WebSocket } from 'ws';
8
- import QRCode from 'qrcode';
9
8
  import { NativeStore } from './lib/store.mjs';
10
9
  import { CodexRpc } from './lib/rpc.mjs';
11
10
  import { DesktopRpc } from './lib/desktop.mjs';
12
11
  import { ResumeManager } from './lib/resume-manager.mjs';
12
+ import { createUpdateChecker } from './lib/updates.mjs';
13
13
 
14
14
  const root = path.dirname(fileURLToPath(import.meta.url));
15
- const packageVersion = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).version;
15
+ const packageManifest = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
16
+ const packageVersion = packageManifest.version;
16
17
  const sessionLifetime = 12 * 60 * 60 * 1000;
17
18
  const attachmentLifetime = 30 * 60 * 1000;
18
19
  const attachmentRetention = 24 * 60 * 60 * 1000;
@@ -47,107 +48,9 @@ function cleanupStaleAttachments(store) {
47
48
  }
48
49
  }
49
50
 
50
- const excludedProjectEntries=new Set([
51
- '.git','.codex','.orbit-attachments','node_modules','.venv','venv','dist','build',
52
- '.ssh','.aws','.azure','.config','.npm','.docker','secrets','credentials'
53
- ]);
54
- const excludedProjectFiles=/^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.git-credentials|id_(?:rsa|ed25519)(?:\.pub)?|.*(?:secret|credential)(?:\..*)?|.*\.(?:pem|p12|pfx|key|keystore))$/i;
55
-
56
- function checkedProjectEntry(row,relativePath=''){
57
- if(!row?.cwd||typeof relativePath!=='string'||relativePath.length>1024||relativePath.includes('\0'))throw new Error('项目文件路径无效。');
58
- const root=fs.realpathSync(row.cwd);
59
- if(!fs.statSync(root).isDirectory())throw new Error('会话工作目录不可用。');
60
- const parts=relativePath?relativePath.split(/[\\/]/):[];
61
- if(parts.some(part=>!part||part==='.'||part==='..'))throw new Error('不允许访问项目目录之外的路径。');
62
- let current=root;
63
- for(const part of parts){
64
- if(excludedProjectEntries.has(part.toLowerCase())||excludedProjectFiles.test(part))throw new Error('此系统目录或凭证文件不开放传输。');
65
- current=path.join(current,part);
66
- const stat=fs.lstatSync(current);
67
- if(stat.isSymbolicLink())throw new Error('不支持通过符号链接传输文件。');
68
- }
69
- const resolved=path.resolve(current);
70
- const relative=path.relative(root,resolved);
71
- if(relative==='..'||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative))throw new Error('不允许访问项目目录之外的路径。');
72
- return {root,path:resolved,stat:fs.statSync(resolved),relative:parts.join(path.sep)};
73
- }
74
-
75
- function projectFileList(row,relativePath=''){
76
- const directory=checkedProjectEntry(row,relativePath);
77
- if(!directory.stat.isDirectory())throw new Error('所选路径不是文件夹。');
78
- const entries=[];
79
- const visit=(current,relative,depth)=>{
80
- if(depth>8||entries.length>=1000)return;
81
- for(const name of fs.readdirSync(current)){
82
- if(excludedProjectEntries.has(name.toLowerCase())||excludedProjectFiles.test(name))continue;
83
- const target=path.join(current,name);
84
- let stat;
85
- try{stat=fs.lstatSync(target)}catch{continue}
86
- if(stat.isSymbolicLink())continue;
87
- const child=relative?`${relative}/${name}`:name;
88
- if(stat.isDirectory())entries.push({name,path:child,type:'directory',size:null,updatedAt:stat.mtimeMs});
89
- else if(stat.isFile())entries.push({name,path:child,type:'file',size:stat.size,updatedAt:stat.mtimeMs});
90
- if(stat.isDirectory())visit(target,child,depth+1);
91
- if(entries.length>=1000)break;
92
- }
93
- };
94
- visit(directory.path,directory.relative,0);
95
- entries.sort((left,right)=>left.type===right.type?left.name.localeCompare(right.name):left.type==='directory'?-1:1);
96
- return {path:directory.relative,entries:entries.slice(0,1000),truncated:entries.length>=1000};
97
- }
98
-
99
- const projectMimeTypes=new Map([
100
- ['.txt','text/plain; charset=utf-8'],['.md','text/markdown; charset=utf-8'],['.json','application/json'],
101
- ['.csv','text/csv; charset=utf-8'],['.html','text/html; charset=utf-8'],['.css','text/css; charset=utf-8'],
102
- ['.js','text/javascript; charset=utf-8'],['.ts','text/plain; charset=utf-8'],['.xml','application/xml'],
103
- ['.pdf','application/pdf'],['.png','image/png'],['.jpg','image/jpeg'],['.jpeg','image/jpeg'],['.gif','image/gif'],
104
- ['.webp','image/webp'],['.svg','image/svg+xml'],['.zip','application/zip']
105
- ]);
106
-
107
- function sendFileRange(request,response,row,relativePath){
108
- const file=checkedProjectEntry(row,relativePath);
109
- if(!file.stat.isFile())throw new Error('所选路径不是文件。');
110
- if(file.stat.size>8*1024*1024)throw Object.assign(new Error('单个文件下载不得超过 8 MiB。'),{status:413});
111
- const filename=path.basename(file.path);
112
- const fallback=filename.replace(/[^\x20-\x7e]|["\\;]/g,'_');
113
- const encoded=encodeURIComponent(filename).replace(/[!'()*]/g,character=>`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
114
- const etag=`"orbit-${file.stat.size}-${Math.trunc(file.stat.mtimeMs)}"`;
115
- const headers={
116
- 'Content-Type':projectMimeTypes.get(path.extname(filename).toLowerCase())||'application/octet-stream',
117
- 'Accept-Ranges':'bytes',
118
- 'ETag':etag,
119
- 'Content-Disposition':`attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
120
- 'X-Content-Type-Options':'nosniff'
121
- };
122
- if(request.headers['if-none-match']===etag){response.writeHead(304,headers);return response.end()}
123
- const ifRange=request.headers['if-range'];
124
- const range=ifRange&&ifRange!==etag?null:request.headers.range;
125
- if(!range){
126
- response.writeHead(200,{...headers,'Content-Length':file.stat.size});
127
- if(request.method==='HEAD')return response.end();
128
- return fs.createReadStream(file.path).pipe(response);
129
- }
130
- const match=String(range).match(/^bytes=(\d*)-(\d*)$/);
131
- if(!match||(!match[1]&&!match[2])){response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end()}
132
- let start,end;
133
- if(!match[1]){
134
- const suffix=Number(match[2]);
135
- if(!Number.isSafeInteger(suffix)||suffix<=0)return response.writeHead(416,{'Content-Range':`bytes */${file.stat.size}`}).end();
136
- start=Math.max(0,file.stat.size-suffix);end=file.stat.size-1;
137
- }else{
138
- start=Number(match[1]);end=match[2]?Number(match[2]):file.stat.size-1;
139
- }
140
- if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||start>=file.stat.size||end<start){
141
- response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end();
142
- }
143
- end=Math.min(end,file.stat.size-1);
144
- const length=end-start+1;
145
- response.writeHead(206,{...headers,'Content-Length':length,'Content-Range':`bytes ${start}-${end}/${file.stat.size}`});
146
- if(request.method==='HEAD')return response.end();
147
- fs.createReadStream(file.path,{start,end}).pipe(response);
148
- }
149
-
150
51
  export function createOrbitServer(options = {}) {
52
+ const clientOnly=options.clientOnly ?? packageManifest.orbitDistribution==='client';
53
+ const updateChecker=options.updateChecker || createUpdateChecker(packageVersion);
151
54
  const home = options.home || process.env.ORBIT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
152
55
  const dataDir = options.dataDir || path.join(root, '.runtime');
153
56
  fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
@@ -173,7 +76,7 @@ export function createOrbitServer(options = {}) {
173
76
  }
174
77
  async function attachThread(id) {
175
78
  if (executionMode !== 'desktop') return null;
176
- if (resumeManager?.has(id)) return resumeManager.runtime(id);
79
+ if (resumeManager?.has(id)) return resumeManager.resume(id);
177
80
  try { return await rpc.attach(id); }
178
81
  catch (error) {
179
82
  const row = store.row(id);
@@ -215,6 +118,21 @@ export function createOrbitServer(options = {}) {
215
118
  }).finally(() => { modelLoading = null; });
216
119
  return modelLoading;
217
120
  }
121
+ function workspaceCatalog() {
122
+ const workspaces=new Map();
123
+ workspaces.set('personal',{id:'personal',cwd:path.join(dataDir,'workspaces','personal'),projectId:null,label:'个人会话 · 不绑定项目',isDefault:true});
124
+ const add=(directory,label,projectId=null)=>{
125
+ try{
126
+ const resolved=fs.realpathSync(directory);
127
+ if(!fs.statSync(resolved).isDirectory())return;
128
+ const key=`${projectId||''}:${process.platform==='win32'?resolved.toLowerCase():resolved}`;
129
+ if(!workspaces.has(key))workspaces.set(key,{id:key,cwd:resolved,label,projectId,isDefault:false});
130
+ }catch{}
131
+ };
132
+ for(const workspace of store.projectWorkspaces?.()||[])add(workspace.cwd,workspace.label,workspace.projectId);
133
+ for(const thread of store.list())if(thread.cwd)add(thread.cwd,thread.projectName||path.basename(thread.cwd),thread.nativeProjectId||null);
134
+ return [...workspaces.values()].sort((left,right)=>Number(right.isDefault)-Number(left.isDefault)||left.label.localeCompare(right.label));
135
+ }
218
136
  const publicUrl = options.publicUrl || process.env.ORBIT_PUBLIC_URL || '';
219
137
  if (publicUrl && new URL(publicUrl).protocol !== 'https:') throw new Error('ORBIT_PUBLIC_URL 必须使用 HTTPS。');
220
138
  const host = options.host || process.env.ORBIT_HOST || '127.0.0.1';
@@ -229,6 +147,7 @@ export function createOrbitServer(options = {}) {
229
147
  let port = Number(options.port ?? process.env.ORBIT_PORT ?? 4318);
230
148
  let revision = store.revision();
231
149
  let timer;
150
+ let updateTimer;
232
151
  let lastDesktopConnect = 0;
233
152
  let lastAttachmentCleanup=Date.now();
234
153
 
@@ -333,7 +252,7 @@ export function createOrbitServer(options = {}) {
333
252
  const resumedIds = resumeManager?.ids() || [];
334
253
  for (const id of session.enabled.keys()) controlFor(session, id);
335
254
  return {
336
- product: 'Orbit', version: packageVersion, device: os.hostname(), platform: process.platform, home,
255
+ product: 'Orbit', version: packageVersion, updates:updateChecker.snapshot(), device: os.hostname(), platform: process.platform, home,
337
256
  threadCount: threads.length, projectCount: new Set(threads.map(thread => thread.projectId)).size,
338
257
  archivedCount: threads.filter(thread => thread.archived).length, lockedCount: threads.filter(thread => thread.locked).length,
339
258
  bridge: 'connected', transport: 'websocket', readOnlyMirror: true,
@@ -447,7 +366,9 @@ export function createOrbitServer(options = {}) {
447
366
  const session = sessionFor(request);
448
367
  if (!session) return json(response, 401, { error: '请先配对这台电脑。' });
449
368
  if (request.method === 'GET' && url.pathname === '/api/status') return json(response, 200, status(session));
369
+ if (request.method === 'GET' && url.pathname === '/api/updates') return json(response, 200, await updateChecker.check({force:true}));
450
370
  if (request.method === 'GET' && url.pathname === '/api/threads') return json(response, 200, { threads: store.list(), revision });
371
+ if (request.method === 'GET' && url.pathname === '/api/workspaces') return json(response, 200, { workspaces:workspaceCatalog() });
451
372
  if (request.method === 'GET' && url.pathname === '/api/models') return json(response, 200, { models: await modelCatalog() });
452
373
  if (request.method === 'POST' && url.pathname === '/api/logout') {
453
374
  sessions.delete(session.key);
@@ -456,9 +377,11 @@ export function createOrbitServer(options = {}) {
456
377
  return json(response, 200, { ok: true }, { 'Set-Cookie': 'orbit_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
457
378
  }
458
379
  if (request.method === 'POST' && url.pathname === '/api/pair/qr') {
380
+ if(clientOnly)return json(response,404,{error:'电脑连接器不提供网页配对,请在工作台登录账号。'});
459
381
  const body = await readBody(request);
460
382
  const connection = status(session).connectionUrls.find(value => value === body.url);
461
383
  if (!connection) return json(response, 400, { error: '请选择桥接服务提供的连接地址。' });
384
+ const {default:QRCode}=await import('qrcode');
462
385
  return json(response, 200, { image: await QRCode.toDataURL(`${connection}/#pair=${encodeURIComponent(pairingKey)}`, { width: 280, margin: 2 }), warning: '扫码者将能查看本机会话。仅向可信设备展示。' });
463
386
  }
464
387
  const uploadMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments$/);
@@ -472,18 +395,6 @@ export function createOrbitServer(options = {}) {
472
395
  removeAttachment(session,body.attachmentId);
473
396
  return json(response,200,{removed:true});
474
397
  }
475
- const filesMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/files$/);
476
- if(request.method==='GET'&&filesMatch){
477
- const row=store.row(filesMatch[1]);
478
- if(!row)return json(response,404,{error:'会话不存在。'});
479
- return json(response,200,projectFileList(row,url.searchParams.get('path')||''));
480
- }
481
- const fileMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/file$/);
482
- if(request.method==='GET'&&fileMatch){
483
- const row=store.row(fileMatch[1]);
484
- if(!row)return json(response,404,{error:'会话不存在。'});
485
- return sendFileRange(request,response,row,url.searchParams.get('path')||'');
486
- }
487
398
  const match = url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/messages$/);
488
399
  if (request.method === 'GET' && match) {
489
400
  const history = store.historyFor(match[1], url.searchParams.get('before'), url.searchParams.get('limit') || 60);
@@ -491,6 +402,7 @@ export function createOrbitServer(options = {}) {
491
402
  }
492
403
  return json(response, 404, { error: '接口不存在。' });
493
404
  }
405
+ if(clientOnly)return json(response,404,{error:'这是 Orbit 电脑连接器。请在 Orbit 工作台登录并选择这台电脑。'});
494
406
  if (request.method !== 'GET' && request.method !== 'HEAD') return json(response, 405, { error: '不支持的方法。' });
495
407
  let file;
496
408
  let type;
@@ -548,9 +460,34 @@ export function createOrbitServer(options = {}) {
548
460
  async function command(socket, session, message) {
549
461
  const body = message.params || {};
550
462
  const id = body.threadId;
463
+ if (message.method === 'thread.create') {
464
+ if (executionMode !== 'desktop' || !resumeManager) throw new Error('当前执行通道不支持在本机 Codex 会话库创建会话。');
465
+ if (typeof body.text !== 'string' || !body.text.trim() || body.text.length > 20000) throw new Error('新会话的首条消息不能为空,且不得超过 20000 个字符。');
466
+ if (!['default','plan'].includes(body.mode || 'default')) throw new Error('新会话模式无效。');
467
+ const workspace=workspaceCatalog().find(entry=>entry.id===body.workspaceId);
468
+ if(!workspace)throw new Error('项目空间不可用,请刷新后重新选择。');
469
+ if(workspace.isDefault)fs.mkdirSync(workspace.cwd,{recursive:true,mode:0o700});
470
+ let model=null;
471
+ if(body.model!==undefined&&body.model!==null){
472
+ if(typeof body.model!=='string')throw new Error('模型选择无效。');
473
+ model=(await modelCatalog()).find(entry=>entry.model===body.model)?.model||null;
474
+ if(!model)throw new Error('所选模型不可用,请刷新模型列表。');
475
+ }
476
+ const created=await resumeManager.startNew({cwd:workspace.cwd,projectId:workspace.projectId,model,text:body.text.trim(),writable:true,mode:body.mode||'default',onCreated:threadId=>{
477
+ unwatchDesktop(socket);
478
+ session.enabled.set(threadId,{sharedExecution:false,desktop:false,writable:true,expires:Date.now()+1800000});
479
+ if(socket.readyState!==WebSocket.OPEN)return;
480
+ socket.subscription=threadId;
481
+ socket.desktopSubscription=threadId;
482
+ desktopWatchers.set(threadId,(desktopWatchers.get(threadId)||0)+1);
483
+ }});
484
+ if(!(desktopWatchers.get(created.threadId)>0))void resumeManager.release(created.threadId).catch(()=>{});
485
+ if(created.startError)created.startError=safeError(new Error(created.startError));
486
+ return created;
487
+ }
551
488
  if (message.method === 'thread.subscribe') {
552
489
  const row = id ? store.row(id) : null;
553
- if (id && !row) throw new Error('会话不存在。');
490
+ if (id && !row && !resumeManager?.has(id)) throw new Error('会话不存在。');
554
491
  if (socket.desktopSubscription && socket.desktopSubscription !== (id || null)) unwatchDesktop(socket);
555
492
  socket.subscription = id || null;
556
493
  let runtime = null;
@@ -573,13 +510,13 @@ export function createOrbitServer(options = {}) {
573
510
  if (resumeManager?.has(id) && (desktopWatchers.get(id) || 0) === 0) void resumeManager.release(id).catch(() => {});
574
511
  }
575
512
  if (executionMode === 'desktop' && runtime?.attached && body.autoControl === true
576
- && !row.archived && !String(row.source).startsWith('{')) {
513
+ && !row?.archived && !String(row?.source||'').startsWith('{')) {
577
514
  const desktop = runtime.mode !== 'same-id-resume';
578
- session.enabled.set(id, {sharedExecution:desktop,desktop,writable:!desktop,expires:Date.now() + 1800000});
515
+ session.enabled.set(id, {sharedExecution:desktop,desktop,writable:!desktop&&runtime.permissionMode!=='read-only',expires:Date.now() + 1800000});
579
516
  }
580
517
  const control = controlFor(session, id);
581
518
  const activeRpc = resumeManager?.rpcFor(id) || rpc;
582
- const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...activeRpc.approvals.values()].filter(request => request.params.threadId === id).map(request => ({ ...request.params, requestId: String(request.id), method: request.method }))) : [];
519
+ const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...activeRpc.approvals.entries()].filter(([,request]) => request.params.threadId === id).map(([requestId,request]) => ({ ...request.params, requestId, method: request.method }))) : [];
583
520
  return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || activeRpc.threads.get(id)?.turnId || null, approvals, attachments:[...session.attachments.values()].filter(item=>item.threadId===id&&!item.consumed&&item.expires>Date.now()).map(({id,label,size,mimeType})=>({id,label,size,mimeType})) };
584
521
  }
585
522
  if (message.method === 'thread.enable') {
@@ -593,7 +530,7 @@ export function createOrbitServer(options = {}) {
593
530
  catch (error) { lostListener(id,error.message,error.code); throw error; }
594
531
  if (!runtime.attached) throw new Error('桌面实时快照未就绪,未授权控制。');
595
532
  const desktop = runtime.mode !== 'same-id-resume';
596
- const control = { sharedExecution:desktop, desktop, writable:!desktop, expires: Date.now() + 1800000 };
533
+ const control = { sharedExecution:desktop, desktop, writable:!desktop&&runtime.permissionMode!=='read-only', expires: Date.now() + 1800000 };
597
534
  session.enabled.set(id, control);
598
535
  return { threadId: id, ...control, mode:runtime.mode || 'desktop', turnId: runtime.turnId };
599
536
  }
@@ -612,7 +549,8 @@ export function createOrbitServer(options = {}) {
612
549
  if (!store.row(id)) throw new Error('会话不存在。');
613
550
  if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
614
551
  const runtime = runtimeForThread(id);
615
- if (runtime?.mode === 'same-id-resume' || executionMode === 'desktop' && runtime?.attached) return { goal:runtime.goal || null };
552
+ if (runtime?.mode === 'same-id-resume') return { goal:runtime.goal || null };
553
+ if (executionMode === 'desktop' && runtime?.attached) return { goal:runtime.goal || null };
616
554
  if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/get', { threadId:id });
617
555
  return { goal:null };
618
556
  }
@@ -625,7 +563,7 @@ export function createOrbitServer(options = {}) {
625
563
  if (tokenBudget !== null && (!Number.isSafeInteger(tokenBudget) || tokenBudget < 1 || tokenBudget > 10000000)) throw new Error('目标 token 预算必须在 1 至 10000000 之间。');
626
564
  const runtime = runtimeForThread(id);
627
565
  if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoal(id, { objective:body.objective.trim(), tokenBudget }) };
628
- if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 写入接口;请在电脑端设置目标。不会用提示词模拟原生 Goal。');
566
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标修改需要桌面 owner 支持。草稿可以继续编辑,当前未保存。');
629
567
  if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', {
630
568
  threadId:id, origin:'user', objective:body.objective.trim(), status:'active', tokenBudget
631
569
  });
@@ -636,7 +574,7 @@ export function createOrbitServer(options = {}) {
636
574
  if (!['active','paused','blocked','complete'].includes(body.status)) throw new Error('无效的目标状态。');
637
575
  const runtime = runtimeForThread(id);
638
576
  if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoalStatus(id, body.status) };
639
- if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 状态接口;不会伪造目标状态。');
577
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标状态修改需要桌面 owner 支持。');
640
578
  if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', { threadId:id, origin:'user', status:body.status });
641
579
  throw new Error('当前执行通道不支持原生 Goal 状态更新。');
642
580
  }
@@ -644,7 +582,7 @@ export function createOrbitServer(options = {}) {
644
582
  if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
645
583
  const runtime = runtimeForThread(id);
646
584
  if (runtime?.mode === 'same-id-resume') return resumeManager.clearGoal(id);
647
- if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 清除接口;请在电脑端清除目标。');
585
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标清除需要桌面 owner 支持。');
648
586
  if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/clear', { threadId:id, origin:'user' });
649
587
  throw new Error('当前执行通道不支持原生 Goal 清除。');
650
588
  }
@@ -789,6 +727,11 @@ export function createOrbitServer(options = {}) {
789
727
  async function listen() {
790
728
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
791
729
  port = server.address().port;
730
+ if(clientOnly){
731
+ void updateChecker.check();
732
+ updateTimer=setInterval(()=>{void updateChecker.check();},6*60*60*1000);
733
+ updateTimer.unref();
734
+ }
792
735
  timer = setInterval(() => {
793
736
  try {
794
737
  tryDesktopConnect();
@@ -818,7 +761,7 @@ export function createOrbitServer(options = {}) {
818
761
  }, 30000);
819
762
  heartbeat.unref();
820
763
  async function close() {
821
- clearInterval(timer); clearInterval(heartbeat);
764
+ clearInterval(timer); clearInterval(heartbeat); clearInterval(updateTimer);
822
765
  for (const socket of sockets) unwatchDesktop(socket);
823
766
  for(const session of sessions.values())removeSessionAttachments(session);
824
767
  for (const socket of sockets) socket.terminate();
@@ -1,69 +0,0 @@
1
- import crypto from 'node:crypto';
2
-
3
- const digest = value => crypto.createHash('sha256').update(value).digest('hex');
4
-
5
- export class DeviceAuthorization {
6
- constructor({ lifetime = 5 * 60 * 1000, now = Date.now } = {}) {
7
- this.lifetime = lifetime;
8
- this.now = now;
9
- this.pending = new Map();
10
- this.codes = new Map();
11
- }
12
-
13
- cleanup() {
14
- for (const [key, value] of this.pending) {
15
- if (value.expiresAt > this.now()) continue;
16
- this.pending.delete(key);
17
- this.codes.delete(value.userCode);
18
- }
19
- }
20
-
21
- start(name) {
22
- this.cleanup();
23
- if (typeof name !== 'string' || !name.trim() || name.length > 120 || /[\u0000-\u001f\u007f]/.test(name)) {
24
- throw new Error('电脑名称格式无效。');
25
- }
26
- if (this.pending.size >= 1000) throw Object.assign(new Error('连接请求过多,请稍后再试。'), {status:429});
27
- const deviceCode = crypto.randomBytes(32).toString('base64url');
28
- let userCode;
29
- do { userCode = crypto.randomBytes(5).toString('hex').toUpperCase(); } while (this.codes.has(userCode));
30
- const key = digest(deviceCode);
31
- const entry = {name:name.trim(), userCode, expiresAt:this.now() + this.lifetime, result:null};
32
- this.pending.set(key, entry);
33
- this.codes.set(userCode, key);
34
- return {deviceCode, userCode, expiresIn:Math.floor(this.lifetime / 1000), interval:2};
35
- }
36
-
37
- preview(userCode) {
38
- this.cleanup();
39
- if (typeof userCode !== 'string') return null;
40
- const entry = this.pending.get(this.codes.get(userCode));
41
- return entry && !entry.result ? {name:entry.name, userCode:entry.userCode, expiresAt:entry.expiresAt} : null;
42
- }
43
-
44
- approve(userCode, issue) {
45
- if (!this.preview(userCode)) throw Object.assign(new Error('连接请求已失效,请在电脑重新运行 orbit login。'), {status:410});
46
- const entry = this.pending.get(this.codes.get(userCode));
47
- entry.result = issue(entry.name);
48
- }
49
-
50
- poll(deviceCode) {
51
- this.cleanup();
52
- if (typeof deviceCode !== 'string' || !/^[a-zA-Z0-9_-]{43}$/.test(deviceCode)) return {status:410};
53
- const key = digest(deviceCode);
54
- const entry = this.pending.get(key);
55
- if (!entry) return {status:410};
56
- if (!entry.result) return {status:202};
57
- return {status:200, result:structuredClone(entry.result)};
58
- }
59
-
60
- acknowledge(deviceCode) {
61
- if (typeof deviceCode !== 'string' || !/^[a-zA-Z0-9_-]{43}$/.test(deviceCode)) return false;
62
- const key = digest(deviceCode);
63
- const entry = this.pending.get(key);
64
- if (!entry?.result) return false;
65
- this.pending.delete(key);
66
- this.codes.delete(entry.userCode);
67
- return true;
68
- }
69
- }