@walkhi/code-relax 0.1.0-beta.28 → 0.1.0-beta.29

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.
@@ -27,6 +27,9 @@ const help = `Code Relax(Windows)
27
27
  常用指令:
28
28
  start 启动或复用桌面服务和 Codex 内核,并恢复已配置的 Code Relax 中继
29
29
  pair 启动或复用桌面服务,并打开一次性配对二维码
30
+ pair --expires <带时区的截止时间> 创建本工作站可重复使用的长期二维码
31
+ pair-list 查看本工作站长期邀请的 ID 和截止时间
32
+ pair-revoke <邀请ID> 废止本工作站指定长期邀请
30
33
  dots-tasks 读取当前 ChatGPT 账号的 Dots 委派任务与最近轮次状态
31
34
  维护指令:
32
35
  setup 部署并启动桌面服务、Codex 内核和登录自启动;安全配置 Desktop 后续接入
@@ -53,10 +56,17 @@ async function main() {
53
56
  console.log(help);
54
57
  return;
55
58
  }
56
- if (!['kernel-restart', 'desktop-recover', 'setup', 'doctor', 'uninstall', 'start', 'pair', 'dots-tasks'].includes(command)
57
- || flags.some(flag => flag !== '--json' && !(['kernel-restart', 'desktop-recover'].includes(command) && flag === '--confirm'))) {
59
+ const options = flags.filter(flag => flag !== '--json');
60
+ const longPair = command === 'pair' && options.length === 2 && options[0] === '--expires';
61
+ const revokeInvite = command === 'pair-revoke' && options.length === 1 && /^[\w-]{16,128}$/.test(options[0]);
62
+ if (!['kernel-restart', 'desktop-recover', 'setup', 'doctor', 'uninstall', 'start', 'pair', 'pair-list', 'pair-revoke', 'dots-tasks'].includes(command)
63
+ || (!longPair && !revokeInvite && (command === 'pair-revoke' || options.some(flag => !(['kernel-restart', 'desktop-recover'].includes(command) && flag === '--confirm'))))) {
58
64
  throw new Error('无效命令或参数。使用 --help 查看用法。');
59
65
  }
66
+ const inviteExpires = longPair ? Date.parse(options[1]) : undefined;
67
+ if (longPair && (!/(Z|[+-]\d{2}:\d{2})$/.test(options[1]) || !Number.isSafeInteger(inviteExpires) || inviteExpires <= Date.now())) {
68
+ throw new Error('截止时间必须在未来且含时区,例如 2026-11-01T00:00:00+08:00。');
69
+ }
60
70
  if (process.platform !== 'win32') throw new Error('当前被控端仅支持 Windows。');
61
71
  const json = flags.includes('--json');
62
72
  if (command === 'dots-tasks') {
@@ -79,7 +89,7 @@ async function main() {
79
89
  && ['start', 'pair', 'doctor'].includes(command)) {
80
90
  writeRecord(installationPath, { ...installation, nodeExecutable: currentNodeExecutable() });
81
91
  }
82
- if (['kernel-restart', 'desktop-recover', 'start', 'pair'].includes(command) && !installed) {
92
+ if (['kernel-restart', 'desktop-recover', 'start', 'pair', 'pair-list', 'pair-revoke'].includes(command) && !installed) {
83
93
  if (!fs.existsSync(installedEntry)) throw new Error('尚未部署服务,请先执行 relax setup。');
84
94
  const executable = readRecord(installationPath)?.nodeExecutable;
85
95
  if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新执行 relax setup。');
@@ -139,6 +149,12 @@ async function main() {
139
149
  console.log(json ? JSON.stringify(result) : `${result.message || result.status}${result.error ? '\n' + result.error : ''}\n${result.operationId || ''}`.trim());
140
150
  return;
141
151
  }
152
+ if (command === 'pair-list' || command === 'pair-revoke') {
153
+ const result = await selfRelay(command === 'pair-list' ? 'invite-list' : 'invite-revoke', stateRoot,
154
+ command === 'pair-revoke' ? { id: options[0] } : undefined);
155
+ console.log(json ? JSON.stringify(result) : result.displayText);
156
+ return;
157
+ }
142
158
  if (command === 'pair') {
143
159
  await ensureAutostart();
144
160
  let bridge = await service.status();
@@ -147,7 +163,8 @@ async function main() {
147
163
  bridge = await service.status();
148
164
  }
149
165
  if (!bridge.running) throw new Error('Code Relax 工作站服务未能启动。');
150
- const result = await selfRelay('relay-pair', stateRoot);
166
+ if (longPair) await selfRelay('relay-start', stateRoot);
167
+ const result = await selfRelay(longPair ? 'invite-create' : 'relay-pair', stateRoot, longPair ? { expires: inviteExpires } : undefined);
151
168
  if (!json && result.qrPath) {
152
169
  const viewer = spawn('explorer.exe', [result.qrPath], { detached: true, stdio: 'ignore', windowsHide: true });
153
170
  viewer.unref();
@@ -14,6 +14,7 @@ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Inv
14
14
  const stateFile = process.env.RELAY_STATE_FILE;
15
15
  const mysqlConfigFile = process.env.RELAY_MYSQL_CONFIG_FILE;
16
16
  const accountConfigFile = process.env.RELAY_ACCOUNT_CONFIG_FILE;
17
+ const longPairingFile = process.env.RELAY_LONG_PAIRING_FILE || '/etc/codex-relay/long-pairing.json';
17
18
  if (!stateFile && !mysqlConfigFile) throw new Error('RELAY_STATE_FILE or RELAY_MYSQL_CONFIG_FILE is required');
18
19
  const debugAllowlistFile = process.env.RELAY_DEBUG_ALLOWLIST_FILE || '/etc/codex-relay/debug-allowlist.json';
19
20
  const adminStateFile = process.env.RELAY_ADMIN_STATE_FILE || '/var/lib/codex-relay/admin.json';
@@ -53,6 +54,7 @@ if (revokeIndex >= 0) {
53
54
  console.log('Computer and phone authorization revoked'); process.exit(0);
54
55
  }
55
56
  const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile, accountConfigFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
57
+ longPairingFile,
56
58
  usageDiagnosticKeyFile: process.env.RELAY_USAGE_DIAGNOSTIC_KEY_FILE,
57
59
  publicRelayUrl: process.env.RELAY_PUBLIC_URL || serviceEndpoints.relayUrl, appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || serviceEndpoints.installUrl,
58
60
  log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
@@ -243,7 +243,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
243
243
  resolve(message); return;
244
244
  }
245
245
  if (!registered) throw new Error('Relay not registered');
246
- if (message.type === 'pair-code' || message.type === 'revoked' || message.type === 'status') {
246
+ if (['pair-code', 'revoked', 'status', 'invite-create', 'invite-list', 'invite-revoke'].includes(message.type)) {
247
247
  if (operation && message.commandId === operation.id && message.type === operation.responseType) {
248
248
  clearTimeout(operation.timer); const pending = operation; operation = undefined;
249
249
  message.error ? pending.reject(new Error(message.error)) : pending.resolve(message);
@@ -27,6 +27,12 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
27
27
  const provider = providerOverride || tencentAccountProvider(config);
28
28
  const codeHash = (email, code) => createHmac('sha256', config.codePepper).update(`${email}\0${code}`).digest('hex');
29
29
  const scopeHash = value => createHmac('sha256', config.codePepper).update(value).digest('hex');
30
+ const review = config.reviewLogin?.enabled === true ? config.reviewLogin : null;
31
+ if (review && (review.email !== normalizeEmail(review.email) || !/^[a-f0-9]{64}$/.test(review.codeHash || '')
32
+ || !Number.isFinite(Date.parse(review.startsAt)) || !Number.isFinite(Date.parse(review.expiresAt))
33
+ || Date.parse(review.startsAt) >= Date.parse(review.expiresAt))) {
34
+ throw new Error('Invalid review login configuration');
35
+ }
30
36
 
31
37
  async function requestCode({ email: rawEmail, ticket, randstr }, ip) {
32
38
  const email = normalizeEmail(rawEmail);
@@ -78,17 +84,33 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
78
84
  const db = await pool.getConnection();
79
85
  try {
80
86
  await db.beginTransaction();
81
- const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
82
- const challenge = challenges[0];
83
- if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
84
- const supplied = Buffer.from(codeHash(email, code), 'hex');
85
- if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
86
- if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
87
- else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
87
+ const reviewActive = review?.email === email && Date.now() >= Date.parse(review.startsAt)
88
+ && Date.now() < Date.parse(review.expiresAt);
89
+ let reviewLogin = false;
90
+ if (reviewActive) {
91
+ // Reuse the persisted rate-limit table so restarts cannot reset guesses.
92
+ const windowStart = sqlTime(new Date(Math.floor(Date.now() / 3600000) * 3600000).toISOString()).slice(0, 19);
93
+ const key = scopeHash(`review-login:${email}`);
94
+ await db.execute('INSERT INTO email_send_limits (scope_hash,window_start,request_count) VALUES (?,?,1) ON DUPLICATE KEY UPDATE request_count=request_count+1', [key, windowStart]);
95
+ const [attempts] = await db.execute('SELECT request_count FROM email_send_limits WHERE scope_hash=? AND window_start=? FOR UPDATE', [key, windowStart]);
88
96
  await db.commit();
89
- throw error(401, 'Code expired or incorrect');
97
+ if (attempts[0].request_count > 10) throw error(429, 'Too many login attempts');
98
+ await db.beginTransaction();
99
+ reviewLogin = timingSafeEqual(Buffer.from(codeHash(email, code), 'hex'), Buffer.from(review.codeHash, 'hex'));
100
+ }
101
+ if (!reviewLogin) {
102
+ const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
103
+ const challenge = challenges[0];
104
+ if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
105
+ const supplied = Buffer.from(codeHash(email, code), 'hex');
106
+ if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
107
+ if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
108
+ else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
109
+ await db.commit();
110
+ throw error(401, 'Code expired or incorrect');
90
111
  }
91
112
  await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
113
+ }
92
114
  const [identities] = await db.execute(`SELECT u.id,u.status FROM user_login_identities i
93
115
  JOIN users u ON u.id=i.user_id WHERE i.kind='email' AND i.normalized_value=?`, [email]);
94
116
  let userId = identities[0]?.id;
@@ -101,10 +123,13 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
101
123
  VALUES (?,'email',?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))`, [userId, email]);
102
124
  }
103
125
  const token = randomBytes(32).toString('base64url');
126
+ const expiresAt = Math.min(Date.now() + 30 * 86400000, reviewLogin ? Date.parse(review.expiresAt) : Infinity);
127
+ if (expiresAt <= Date.now()) throw error(401, 'Code expired or incorrect');
104
128
  await db.execute(`INSERT INTO user_sessions (token_hash,user_id,created_at,expires_at)
105
- VALUES (?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3) + INTERVAL 30 DAY)`, [tokenHash(token), userId]);
129
+ VALUES (?,?,UTC_TIMESTAMP(3),?)`, [tokenHash(token), userId, sqlTime(new Date(expiresAt).toISOString())]);
106
130
  await db.commit();
107
- return { token, user: { id: userId, email }, expiresInSeconds: 30 * 86400 };
131
+ if (reviewLogin) console.info(JSON.stringify({ event: 'review-login', userId, expiresAt: new Date(expiresAt).toISOString() }));
132
+ return { token, user: { id: userId, email }, expiresInSeconds: Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)) };
108
133
  } catch (failure) { await db.rollback(); throw failure; }
109
134
  finally { db.release(); }
110
135
  }
@@ -33,14 +33,14 @@ async function loadConfig(stateRoot) {
33
33
  (pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
34
34
  return { ...config, url: resolved, pairingUrl: pairing.href };
35
35
  }
36
- export async function selfRelay(command, stateRoot) {
36
+ export async function selfRelay(command, stateRoot, details) {
37
37
  const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
38
38
  if (!session) {
39
39
  if (command === 'relay-status') return { running: false, displayText: '桌面服务未运行。' };
40
40
  if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
41
41
  throw new Error('桌面服务尚未运行,请先执行 relax start。');
42
42
  }
43
- return controlRequest(session, 'POST', command);
43
+ return controlRequest(session, 'POST', command, details);
44
44
  }
45
45
 
46
46
  async function createRelayRuntime(stateRoot) {
@@ -186,6 +186,22 @@ async function createRelayRuntime(stateRoot) {
186
186
  await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
187
187
  return { ...status(), displayText: '全部移动设备授权已撤销;重新添加请调用 relax pair --json。' };
188
188
  }
189
+ async function invite(command, details) {
190
+ if (!online || closing) throw new Error('中继尚未连接,请先执行 relax start。');
191
+ const result = await connector.command(command, command === 'invite-create' ? { expires: details.expires } : command === 'invite-revoke' ? { id: details.id } : {});
192
+ if (command === 'invite-create') {
193
+ const link = new URL(config.pairingUrl);
194
+ link.searchParams.set('invite', result.code);
195
+ const inviteQrPath = path.join(stateRoot, `self-relay-long-qr-${result.id}.png`);
196
+ try { await QRCode.toFile(inviteQrPath, link.href, { width: 640, margin: 3 }); }
197
+ catch (error) { throw new Error(`二维码保存失败:${error.message}。邀请 ID:${result.id};请执行 relax pair-revoke ${result.id} 废止。`); }
198
+ return { invitationId: result.id, deviceId: result.room, expiresAt: new Date(result.expires).toISOString(), qrPath: inviteQrPath,
199
+ displayText: `长期配对邀请已创建。邀请 ID:${result.id}\n截止:${new Date(result.expires).toISOString()}` };
200
+ }
201
+ if (command === 'invite-list') return { invitations: result.invitations,
202
+ displayText: result.invitations.length ? result.invitations.map(item => `${item.id} ${new Date(item.expires).toISOString()}${Date.now() >= item.expires ? '(已过期)' : ''}`).join('\n') : '本工作站没有长期配对邀请。' };
203
+ return { invitationId: result.id, revoked: result.revoked, displayText: '长期邀请已废止;已配对手机的独立授权保持不变。' };
204
+ }
189
205
  async function refreshLan() {
190
206
  if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
191
207
  await refreshHealth();
@@ -201,14 +217,14 @@ async function createRelayRuntime(stateRoot) {
201
217
  }
202
218
  return status();
203
219
  };
204
- try { await connect(); return { status, inspect, pair, revoke, refreshLan, shutdown }; }
220
+ try { await connect(); return { status, inspect, pair, revoke, invite, refreshLan, shutdown }; }
205
221
  catch (error) { await shutdown(); throw error; }
206
222
  }
207
223
 
208
224
  export function createRelayManager(stateRoot) {
209
225
  let current;
210
226
  let operation = Promise.resolve();
211
- const run = command => {
227
+ const run = (command, details = {}) => {
212
228
  const next = operation.then(async () => {
213
229
  if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
214
230
  if (command === 'relay-stop') {
@@ -216,10 +232,12 @@ export function createRelayManager(stateRoot) {
216
232
  return { stopped: true, displayText: '中继连接已停止;桌面服务和 Codex 内核保持运行。' };
217
233
  }
218
234
  if (!current) {
235
+ if (command.startsWith('invite-')) throw new Error('请先执行 relax start 连接中继。');
219
236
  if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
220
237
  current = await createRelayRuntime(stateRoot);
221
238
  return command === 'relay-pair' && current.status().relayConnected ? current.pair() : current.status();
222
239
  }
240
+ if (command.startsWith('invite-')) return current.invite(command, details);
223
241
  if (command === 'relay-revoke') return current.revoke();
224
242
  if (command === 'relay-pair' || (current.status().relayConnected && !current.status().paired && !current.status().qrPath)) return current.pair();
225
243
  if (command === 'relay-start' && current.status().relayConnected) return current.refreshLan();
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { validId } from './device-store.mjs';
4
+ import { digest, matches, secret } from './wire.mjs';
5
+
6
+ // Operator-owned file, separate from device state. Only hashes are retained here.
7
+ export function longPairing(file) {
8
+ function read() {
9
+ if (!file || !fs.existsSync(file)) return [];
10
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
11
+ if (data.version !== 1 || !Array.isArray(data.invitations) || data.invitations.some(item =>
12
+ !validId(item.id) || !validId(item.room) || !/^[a-f0-9]{64}$/.test(item.hash) ||
13
+ !Number.isSafeInteger(item.expires))) throw new Error('Invalid long pairing invitations');
14
+ return data.invitations;
15
+ }
16
+ function write(invitations) {
17
+ if (!file) throw new Error('Long pairing file is not configured');
18
+ const temporary = `${file}.${randomUUID()}.tmp`;
19
+ try {
20
+ fs.writeFileSync(temporary, JSON.stringify({ version: 1, invitations }), { mode: 0o600, flag: 'wx' });
21
+ fs.renameSync(temporary, file);
22
+ } finally { fs.rmSync(temporary, { force: true }); }
23
+ }
24
+ return {
25
+ list: () => read().map(({ hash, ...item }) => item),
26
+ find: code => read().find(item => Date.now() < item.expires && matches(code, item.hash)),
27
+ create(room, expires) {
28
+ if (!validId(room) || !Number.isSafeInteger(expires) || expires <= Date.now()) throw new Error('Invalid invitation room or expiry');
29
+ const invitations = read(), code = secret();
30
+ const item = { id: randomUUID(), room, expires, hash: digest(code) };
31
+ write([...invitations, item]);
32
+ return { id: item.id, room, expires, code };
33
+ },
34
+ revoke(id) {
35
+ const invitations = read();
36
+ if (!invitations.some(item => item.id === id)) throw new Error('Invitation not found');
37
+ write(invitations.filter(item => item.id !== id));
38
+ },
39
+ };
40
+ }
@@ -1,4 +1,5 @@
1
1
  import { privacyPage } from './privacy-page.mjs';
2
+ import { longPairing } from './long-pairing.mjs';
2
3
  import http from 'node:http';
3
4
  import fs from 'node:fs';
4
5
  import { isIP } from 'node:net';
@@ -20,7 +21,13 @@ import { relayDiagnostics } from './diagnostics.mjs';
20
21
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
21
22
  maxConnections = 128, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
22
23
  maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
23
- publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
24
+ publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, longPairingFile, log = () => {} } = {}) {
25
+ const longInvitations = longPairing(longPairingFile);
26
+ longInvitations.list();
27
+ function longInvitation(code) {
28
+ try { return longInvitations.find(code); }
29
+ catch (error) { log({ event: 'long-pairing-error', message: error.message }); return undefined; }
30
+ }
24
31
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
25
32
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
26
33
  if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
@@ -93,7 +100,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
93
100
  }
94
101
  function findInvitation(code) {
95
102
  if (!validSecret(code)) return undefined;
96
- return [...rooms.values()].find(room => room.status === 'active' && room.pairingHash && Date.now() < room.expires && matches(code, room.pairingHash));
103
+ const room = [...rooms.values()].find(room => room.status === 'active' && room.pairingHash && Date.now() < room.expires && matches(code, room.pairingHash));
104
+ if (room) return { room, expires: room.expires };
105
+ const invite = longInvitation(code), target = invite && rooms.get(invite.room);
106
+ return target?.status === 'active' ? { room: target, expires: invite.expires } : undefined;
97
107
  }
98
108
  const server = http.createServer((request, response) => {
99
109
  const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
@@ -121,19 +131,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
121
131
  response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
122
132
  }
123
133
  const code = target.searchParams.get('invite') || '';
124
- const room = findInvitation(code);
125
- if (!room) {
134
+ const invitation = findInvitation(code);
135
+ if (!invitation) {
126
136
  const json = target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '');
127
137
  const body = json ? JSON.stringify({ error: '配对邀请已过期或已使用' }) : pairingPage({ expired: true });
128
138
  response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
129
139
  }
130
140
  if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
131
- const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
141
+ const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: invitation.room.id, expires: invitation.expires });
132
142
  response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
133
143
  }
134
144
  const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
135
145
  const current = new URL(target.pathname + target.search, `${secure ? 'https' : 'http'}://${request.headers.host || new URL(listenUrl).host}`);
136
- const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: room.expires, appDownloadUrl });
146
+ const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: invitation.expires, appDownloadUrl });
137
147
  response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
138
148
  });
139
149
  function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
@@ -541,7 +551,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
541
551
  room = rooms.get(message.room);
542
552
  if (!room || room.status !== 'active') return fail();
543
553
  let credential;
544
- if (message.type === 'pair' && !room.pairingInProgress && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
554
+ const longInvite = message.type === 'pair' ? longInvitation(message.code) : undefined;
555
+ const reusableInvite = longInvite?.room === room.id;
556
+ if (message.type === 'pair' && !room.pairingInProgress && (reusableInvite ||
557
+ (room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)))) {
545
558
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
546
559
  credential = message.credential || secret();
547
560
  clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
@@ -553,7 +566,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
553
566
  room.pairingInProgress = true;
554
567
  try {
555
568
  await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
556
- room.pairingHash = undefined;
569
+ if (!reusableInvite) room.pairingHash = undefined;
557
570
  } finally { room.pairingInProgress = false; }
558
571
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
559
572
  send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
@@ -695,6 +708,21 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
695
708
  if (message.type === 'pair-code') {
696
709
  send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
697
710
  }
711
+ if (['invite-create', 'invite-list', 'invite-revoke'].includes(message.type)) {
712
+ try {
713
+ if (!room.durable) throw new Error('长期邀请需要持久工作站身份');
714
+ let result;
715
+ if (message.type === 'invite-create') result = longInvitations.create(room.id, message.expires);
716
+ else if (message.type === 'invite-list') result = { invitations: longInvitations.list().filter(item => item.room === room.id) };
717
+ else {
718
+ if (!longInvitations.list().some(item => item.id === message.id && item.room === room.id)) throw new Error('未找到本工作站的邀请');
719
+ longInvitations.revoke(message.id);
720
+ result = { id: message.id, revoked: true };
721
+ }
722
+ send(ws, { type: message.type, commandId: message.commandId, ...result });
723
+ } catch (error) { send(ws, { type: message.type, commandId: message.commandId, error: error.message }); }
724
+ return;
725
+ }
698
726
  if (message.type === 'revoke') {
699
727
  await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
700
728
  room.pairingHash = undefined;
@@ -819,6 +819,9 @@ server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
819
819
  'relay-stop': () => relayManager.run('relay-stop'),
820
820
  'relay-pair': () => relayManager.run('relay-pair'),
821
821
  'relay-revoke': () => relayManager.run('relay-revoke'),
822
+ 'invite-create': details => relayManager.run('invite-create', details),
823
+ 'invite-list': () => relayManager.run('invite-list'),
824
+ 'invite-revoke': details => relayManager.run('invite-revoke', details),
822
825
  'restart-managed-app-server': () => {
823
826
  const state = managedAppServer.snapshot();
824
827
  return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
@@ -22,7 +22,17 @@ export async function openControl({ instanceId, secret, shutdown, status = () =>
22
22
  response.setHeader('Content-Type', 'application/json');
23
23
  response.setHeader('Connection', 'close');
24
24
  if (action) {
25
- Promise.resolve().then(() => actions[action]()).then(result => {
25
+ Promise.resolve().then(async () => {
26
+ let body = '';
27
+ request.setEncoding('utf8');
28
+ for await (const chunk of request) {
29
+ body += chunk;
30
+ if (Buffer.byteLength(body) > 4096) throw new Error('管理请求过大');
31
+ }
32
+ const details = body ? JSON.parse(body) : {};
33
+ if (!details || typeof details !== 'object' || Array.isArray(details)) throw new Error('无效管理请求');
34
+ return actions[action](details);
35
+ }).then(result => {
26
36
  response.end(JSON.stringify({ ...result, instanceId, pid: process.pid }));
27
37
  }).catch(error => { response.writeHead(409).end(JSON.stringify({ error: error.message })); });
28
38
  return;
@@ -44,7 +54,7 @@ export async function openControl({ instanceId, secret, shutdown, status = () =>
44
54
  }) };
45
55
  }
46
56
 
47
- export function controlRequest(session, method = 'GET', action = '') {
57
+ export function controlRequest(session, method = 'GET', action = '', details) {
48
58
  return new Promise((resolve, reject) => {
49
59
  const request = http.request({ hostname: '127.0.0.1', port: session.controlPort,
50
60
  path: `/${session.instanceId}${action ? '/' + action : ''}`, method, agent: false,
@@ -67,6 +77,6 @@ export function controlRequest(session, method = 'GET', action = '') {
67
77
  });
68
78
  request.on('timeout', () => request.destroy(new Error('桌面服务管理通道响应超时。')));
69
79
  request.on('error', reject);
70
- request.end();
80
+ request.end(details === undefined ? undefined : JSON.stringify(details));
71
81
  });
72
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.28",
3
+ "version": "0.1.0-beta.29",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {