orbit-codex-bridge 0.1.3 → 0.1.7

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/lib/rpc.mjs CHANGED
@@ -3,7 +3,9 @@ import { spawn } from 'node:child_process';
3
3
  import { createInterface } from 'node:readline';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import crypto from 'node:crypto';
6
7
  import WebSocket from 'ws';
8
+ import { approvalResponse } from './approvals.mjs';
7
9
 
8
10
  export function findCodexBinary() {
9
11
  if (process.env.ORBIT_CODEX_BIN) return process.env.ORBIT_CODEX_BIN;
@@ -25,6 +27,7 @@ export class CodexRpc extends EventEmitter {
25
27
  this.tokenFile = tokenFile;
26
28
  this.pending = new Map();
27
29
  this.approvals = new Map();
30
+ this.approvalNamespace = crypto.randomUUID();
28
31
  this.threads = new Map();
29
32
  this.starting = new Set();
30
33
  this.sequence = 0;
@@ -106,16 +109,21 @@ export class CodexRpc extends EventEmitter {
106
109
  return;
107
110
  }
108
111
  if (message.id !== undefined && message.method) {
109
- const supported = ['item/commandExecution/requestApproval','item/fileChange/requestApproval','item/tool/requestUserInput','item/permissions/requestApproval'];
112
+ const supported = ['item/commandExecution/requestApproval','item/fileChange/requestApproval','item/tool/requestUserInput','item/permissions/requestApproval','mcpServer/elicitation/request'];
110
113
  if (!supported.includes(message.method) || !this.threads.has(message.params?.threadId)) {
111
114
  this.send({ id: message.id, error: { code: -32601, message: 'This client does not implement the requested host tool.' } });
112
115
  return;
113
116
  }
114
- this.approvals.set(String(message.id), message);
115
- this.emit('event', { method: 'orbit/approval', params: { requestId: String(message.id), method: message.method, ...message.params } });
117
+ const requestId = `${this.approvalNamespace}:${message.id}`;
118
+ this.approvals.set(requestId, message);
119
+ this.emit('event', { method: 'orbit/approval', params: { ...message.params, requestId, method: message.method } });
116
120
  return;
117
121
  }
118
- if (message.method === 'serverRequest/resolved') this.approvals.delete(String(message.params?.requestId));
122
+ if (message.method === 'serverRequest/resolved') {
123
+ const requestId = `${this.approvalNamespace}:${message.params?.requestId}`;
124
+ this.approvals.delete(requestId);
125
+ message = { ...message, params:{ ...message.params, requestId } };
126
+ }
119
127
  if (message.method === 'turn/started' && this.threads.has(message.params?.threadId)) this.threads.get(message.params.threadId).turnId = message.params.turn.id;
120
128
  if (message.method === 'turn/completed' && this.threads.has(message.params?.threadId)) {
121
129
  const thread = this.threads.get(message.params.threadId);
@@ -130,25 +138,71 @@ export class CodexRpc extends EventEmitter {
130
138
  if (this.threads.has(id)) return;
131
139
  const result = await this.request('thread/resume', { threadId: id, excludeTurns: true, sandbox: writable ? 'workspace-write' : 'read-only', approvalPolicy: 'on-request' });
132
140
  if (result.thread.id !== id) throw new Error('返回的会话 ID 不一致,已拒绝发送消息。');
133
- this.threads.set(id, { turnId: null, lastCompletedTurnId: null });
141
+ this.threads.set(id, {
142
+ turnId:null,lastCompletedTurnId:null,writable:Boolean(writable),
143
+ model:result.model||result.thread.model||null,
144
+ effort:result.reasoningEffort??result.thread.reasoningEffort??null,
145
+ collaborationMode:result.collaborationMode||{mode:'default',settings:{}}
146
+ });
134
147
  }
135
148
 
136
- async startTurn(id, text, writable, settings = null) {
149
+ async startThread({ cwd, model = null, projectId = null, writable = true } = {}) {
150
+ await this.connect();
151
+ const result = await this.request('thread/start', {
152
+ cwd,
153
+ projectId,
154
+ approvalPolicy: 'on-request',
155
+ sandbox: writable ? 'workspace-write' : 'read-only',
156
+ ...(model ? { model } : {})
157
+ });
158
+ const thread = result?.thread;
159
+ if (typeof thread?.id !== 'string' || !/^[a-zA-Z0-9_-]{8,120}$/.test(thread.id)) {
160
+ throw new Error('Codex 未返回有效的新会话 ID。');
161
+ }
162
+ this.threads.set(thread.id, {
163
+ turnId:null,lastCompletedTurnId:null,writable:Boolean(writable),
164
+ model:result.model||thread.model||null,
165
+ effort:result.reasoningEffort??thread.reasoningEffort??null,
166
+ collaborationMode:result.collaborationMode||{mode:'default',settings:{}}
167
+ });
168
+ return { threadId:thread.id, model:result.model||thread.model||null, cwd:thread.cwd||cwd, projectId:thread.projectId||projectId };
169
+ }
170
+
171
+ async startTurn(id, text, writable, settings = null, options = {}) {
137
172
  if (this.starting.has(id)) throw new Error('此会话正在发送,请勿并发提交。');
138
173
  this.starting.add(id);
139
174
  try {
140
175
  await this.resume(id, writable);
141
176
  const thread = this.threads.get(id);
142
177
  if (thread.turnId) throw new Error('此会话仍在执行,请等待完成或停止后再发送。');
178
+ const baseMode=thread.collaborationMode||{mode:'default',settings:{}};
179
+ const collaborationMode={
180
+ mode:options.mode==='plan'?'plan':baseMode.mode==='plan'?'default':baseMode.mode||'default',
181
+ settings:{
182
+ ...baseMode.settings,
183
+ model:settings?.model||baseMode.settings?.model||thread.model||'',
184
+ reasoning_effort:settings?.effort??baseMode.settings?.reasoning_effort??thread.effort??null,
185
+ developer_instructions:options.mode==='plan'||baseMode.mode==='plan'
186
+ ? null
187
+ : baseMode.settings?.developer_instructions??null
188
+ }
189
+ };
143
190
  const result = await this.request('turn/start', {
144
191
  threadId: id, input: [{ type: 'text', text }], approvalPolicy: 'on-request',
145
192
  sandboxPolicy: writable ? { type: 'workspaceWrite', networkAccess: false, excludeTmpdirEnvVar: true, excludeSlashTmp: true } : { type: 'readOnly' },
193
+ collaborationMode,
146
194
  ...(settings ? { model: settings.model, effort: settings.effort } : {})
147
195
  });
148
196
  const completed = thread.lastCompletedTurnId === result.turn.id;
149
197
  thread.turnId = completed ? null : result.turn.id;
198
+ thread.collaborationMode=collaborationMode;
199
+ if(settings?.model)thread.model=settings.model;
200
+ if(settings&&Object.hasOwn(settings,'effort'))thread.effort=settings.effort;
150
201
  return { threadId: id, turnId: result.turn.id, completed };
151
- } finally { this.starting.delete(id); }
202
+ } finally {
203
+ this.starting.delete(id);
204
+ this.emit('event', { method:'orbit/turn-settled', params:{ threadId:id } });
205
+ }
152
206
  }
153
207
 
154
208
  async interrupt(id) {
@@ -160,24 +214,10 @@ export class CodexRpc extends EventEmitter {
160
214
  replyApproval(id, body) {
161
215
  const request = this.approvals.get(String(id));
162
216
  if (!request) throw new Error('审批已结束或不属于当前桥接实例。');
163
- let result;
164
- if (request.method === 'item/tool/requestUserInput') {
165
- const answers = {};
166
- for (const question of request.params.questions || []) {
167
- const value = body.answers?.[question.id];
168
- if (typeof value !== 'string' || !value.trim() || value.length > 4000) throw new Error('请回答所有问题。');
169
- answers[question.id] = { answers: [value] };
170
- }
171
- result = { answers };
172
- } else if (request.method === 'item/permissions/requestApproval') {
173
- if (body.decision !== 'decline') throw new Error('首版暂不支持扩大权限,请在本机处理。');
174
- result = { permissions: {}, scope: 'turn' };
175
- } else {
176
- if (!['accept','decline'].includes(body.decision)) throw new Error('无效审批决定。');
177
- result = { decision: body.decision };
178
- }
217
+ const result = approvalResponse(request, body);
179
218
  this.send({ id: request.id, result });
180
219
  this.approvals.delete(String(id));
220
+ this.emit('event', { method:'serverRequest/resolved', params:{ threadId:request.params.threadId, requestId:String(id) } });
181
221
  return { resolved: true };
182
222
  }
183
223
 
@@ -198,6 +238,11 @@ export class CodexRpc extends EventEmitter {
198
238
  }
199
239
 
200
240
  close() {
241
+ const child = this.child;
242
+ const exited = !child || child.exitCode !== null || child.signalCode !== null
243
+ ? Promise.resolve()
244
+ : new Promise(resolve => child.once('exit', resolve));
201
245
  this.disconnect(new Error('服务已停止。'));
246
+ return exited;
202
247
  }
203
248
  }
package/lib/store.mjs CHANGED
@@ -92,6 +92,7 @@ export class NativeStore {
92
92
  id: row.id, title: row.name || row.title || '未命名会话',
93
93
  preview: cleanText(row.preview || row.first_user_message || '').slice(0, 220), cwd,
94
94
  projectId: row.project_id || cwd || 'ungrouped', projectName: projects.get(row.project_id)?.name || leaf,
95
+ nativeProjectId: row.project_id || null,
95
96
  source: row.source, provider: row.model_provider, model: row.model || null,
96
97
  archived: Boolean(row.archived), pinned: Boolean(row.is_pinned), locked: this.locked(row.id),
97
98
  createdAt: row.created_at_ms || row.created_at * 1000, updatedAt: row.updated_at_ms || row.updated_at * 1000,
@@ -103,6 +104,11 @@ export class NativeStore {
103
104
  try { projects = new Map(this.state.prepare('SELECT id,name FROM projects').all().map(project => [project.id, project])); } catch {}
104
105
  return this.state.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all().map(row => this.summary(row, projects));
105
106
  }
107
+ projectWorkspaces() {
108
+ try {
109
+ return this.state.prepare('SELECT projects.id AS projectId,projects.name AS label,project_roots.path AS cwd FROM projects JOIN project_roots ON project_roots.project_id=projects.id ORDER BY projects.position,project_roots.position').all();
110
+ } catch { return []; }
111
+ }
106
112
  historyFor(id, before = null, limit = 60) {
107
113
  const row = this.row(id);
108
114
  if (!row) return null;
@@ -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.3",
3
+ "version": "0.1.7",
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/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
  }
@@ -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
+ }