@walkhi/code-relax 0.1.0-beta.25 → 0.1.0-beta.26
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/dist/src/lan-socket-server.mjs +37 -11
- package/dist/src/self-relay/admin-server.mjs +2 -1
- package/dist/src/self-relay/admin-state.mjs +3 -0
- package/dist/src/self-relay/lan-authorizations.mjs +13 -0
- package/dist/src/self-relay/lifecycle.mjs +9 -15
- package/dist/src/self-relay/server.mjs +36 -25
- package/dist/src/server.mjs +3 -2
- package/dist/src/service-restart.mjs +42 -9
- package/dist/src/shared-app-server.mjs +23 -15
- package/package.json +1 -1
|
@@ -6,12 +6,18 @@ import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COU
|
|
|
6
6
|
|
|
7
7
|
// One authenticated connection carries requests and explicitly owned subscriptions.
|
|
8
8
|
// The dispatcher is shared business routing, not an HTTP loopback proxy.
|
|
9
|
-
export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, uploadsRoot }) {
|
|
9
|
+
export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, onDeviceAuthorizationsChanged, uploadsRoot }) {
|
|
10
10
|
// Allow four individually valid images to reach the aggregate-size validator,
|
|
11
11
|
// so an oversized composition returns an error instead of dropping the connection.
|
|
12
12
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 48 * 1024 * 1024, perMessageDeflate: false });
|
|
13
13
|
const uploadWss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
14
14
|
const activeUploads = new Set();
|
|
15
|
+
const deviceConnections = new Map();
|
|
16
|
+
const stopWatching = onDeviceAuthorizationsChanged?.(() => {
|
|
17
|
+
for (const connection of deviceConnections.values()) {
|
|
18
|
+
if (!authorizeDevice(connection.clientId, connection.credential)) connection.revoke();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
15
21
|
server.on('upgrade', (request, socket, head) => {
|
|
16
22
|
const origin = request.headers.origin;
|
|
17
23
|
const upload = request.url === '/api/file-transfer' && Boolean(uploadsRoot);
|
|
@@ -32,20 +38,26 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
32
38
|
target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
|
|
33
39
|
});
|
|
34
40
|
uploadWss.on('connection', ws => {
|
|
35
|
-
let authenticated = false, identity = '', upload;
|
|
41
|
+
let authenticated = false, stopped = false, identity = '', upload;
|
|
36
42
|
let pending = Promise.resolve();
|
|
37
43
|
const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
|
|
38
|
-
const reply = value => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
|
|
44
|
+
const reply = value => { if (!stopped && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
|
|
39
45
|
ws.on('error', () => {});
|
|
40
|
-
|
|
46
|
+
const stop = () => {
|
|
47
|
+
if (stopped) return;
|
|
48
|
+
stopped = true;
|
|
49
|
+
deviceConnections.delete(ws);
|
|
41
50
|
clearTimeout(authTimer);
|
|
42
51
|
void pending.finally(async () => {
|
|
43
52
|
await upload?.close();
|
|
44
53
|
if (identity) activeUploads.delete(identity);
|
|
45
54
|
});
|
|
46
|
-
}
|
|
55
|
+
};
|
|
56
|
+
ws.on('close', stop);
|
|
47
57
|
ws.on('message', (bytes, binary) => {
|
|
58
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
48
59
|
pending = pending.then(async () => {
|
|
60
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
49
61
|
if (binary || bytes.length > 512 * 1024) throw new Error('无效的上传分片');
|
|
50
62
|
const message = JSON.parse(bytes.toString());
|
|
51
63
|
if (!authenticated) {
|
|
@@ -56,6 +68,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
56
68
|
if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) identity = 'local-token';
|
|
57
69
|
}
|
|
58
70
|
if (!identity || activeUploads.has(identity) || activeUploads.size >= 2) throw new Error('上传身份无效或工作站已有上传任务');
|
|
71
|
+
if (message.type === 'device-auth') deviceConnections.set(ws, {
|
|
72
|
+
clientId: message.clientId, credential: message.credential,
|
|
73
|
+
revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
|
|
74
|
+
});
|
|
59
75
|
activeUploads.add(identity); authenticated = true; clearTimeout(authTimer);
|
|
60
76
|
upload = createFileUploadSession(uploadsRoot);
|
|
61
77
|
reply({ type: 'ready', maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
|
|
@@ -67,10 +83,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
67
83
|
});
|
|
68
84
|
});
|
|
69
85
|
wss.on('connection', ws => {
|
|
70
|
-
let authenticated = false, alive = true;
|
|
86
|
+
let authenticated = false, alive = true, stopped = false;
|
|
71
87
|
const active = new Map();
|
|
72
88
|
const send = value => {
|
|
73
|
-
if (ws.readyState !== WebSocket.OPEN) return;
|
|
89
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
74
90
|
if (ws.bufferedAmount > 64 * 1024 * 1024) { ws.close(1013, 'Client too slow'); return; }
|
|
75
91
|
ws.send(JSON.stringify(value));
|
|
76
92
|
};
|
|
@@ -81,12 +97,17 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
81
97
|
}, 30000);
|
|
82
98
|
ws.on('pong', () => { alive = true; });
|
|
83
99
|
ws.on('error', () => {}); // close rejects requests and releases every subscription.
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
const stop = () => {
|
|
101
|
+
if (stopped) return;
|
|
102
|
+
stopped = true;
|
|
103
|
+
deviceConnections.delete(ws);
|
|
104
|
+
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
86
105
|
for (const reply of active.values()) reply.end();
|
|
87
106
|
active.clear();
|
|
88
|
-
}
|
|
89
|
-
ws.on('
|
|
107
|
+
};
|
|
108
|
+
ws.on('close', stop);
|
|
109
|
+
ws.on('message', async (bytes, binary) => {
|
|
110
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
90
111
|
let message;
|
|
91
112
|
try { if (binary) throw new Error(); message = JSON.parse(bytes.toString()); }
|
|
92
113
|
catch { ws.close(1008, 'Invalid JSON'); return; }
|
|
@@ -103,6 +124,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
103
124
|
if (!accepted) {
|
|
104
125
|
ws.close(1008, 'Authentication failed'); return;
|
|
105
126
|
}
|
|
127
|
+
if (message.type === 'device-auth') deviceConnections.set(ws, {
|
|
128
|
+
clientId: message.clientId, credential: message.credential,
|
|
129
|
+
revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
|
|
130
|
+
});
|
|
106
131
|
authenticated = true; clearTimeout(authTimer); send({ type: 'ready', version: 1 }); return;
|
|
107
132
|
}
|
|
108
133
|
const { id, type } = message;
|
|
@@ -186,6 +211,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
186
211
|
});
|
|
187
212
|
});
|
|
188
213
|
return { close() {
|
|
214
|
+
stopWatching?.();
|
|
189
215
|
for (const ws of wss.clients) ws.terminate(); wss.close();
|
|
190
216
|
for (const ws of uploadWss.clients) ws.terminate(); uploadWss.close();
|
|
191
217
|
} };
|
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { adminPage } from './admin-page.mjs';
|
|
4
4
|
import { startAdminCloud } from './admin-cloud.mjs';
|
|
5
|
+
import { MAX_ADMIN_REQUEST_BYTES } from './admin-state.mjs';
|
|
5
6
|
|
|
6
7
|
function reply(response, status, body, type = 'application/json; charset=utf-8') {
|
|
7
8
|
const bytes = typeof body === 'string' ? body : JSON.stringify(body);
|
|
@@ -53,7 +54,7 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
|
|
|
53
54
|
let size = 0;
|
|
54
55
|
for await (const chunk of request) {
|
|
55
56
|
size += chunk.length;
|
|
56
|
-
if (size >
|
|
57
|
+
if (size > MAX_ADMIN_REQUEST_BYTES) { reply(response, 413, { error: 'Admin request too large' }); return; }
|
|
57
58
|
chunks.push(chunk);
|
|
58
59
|
}
|
|
59
60
|
const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
|
|
@@ -2,6 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
|
|
5
|
+
// 80 title + 2000 body UTF-16 units fit even when every unit uses a six-byte JSON escape.
|
|
6
|
+
export const MAX_ADMIN_REQUEST_BYTES = 16 * 1024;
|
|
7
|
+
|
|
5
8
|
export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
|
|
6
9
|
let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
|
|
7
10
|
if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
4
5
|
import { digest, matches } from './wire.mjs';
|
|
5
6
|
import { validId } from './device-store.mjs';
|
|
6
7
|
|
|
7
8
|
const recordPath = stateRoot => path.join(stateRoot, 'lan-device-authorizations.json');
|
|
8
9
|
const validHash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
10
|
+
const authorizationChanges = new EventEmitter();
|
|
11
|
+
const authorizationKey = stateRoot => {
|
|
12
|
+
const resolved = path.resolve(stateRoot);
|
|
13
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function onLanAuthorizationsChanged(stateRoot, listener) {
|
|
17
|
+
const key = authorizationKey(stateRoot);
|
|
18
|
+
authorizationChanges.on(key, listener);
|
|
19
|
+
return () => authorizationChanges.off(key, listener);
|
|
20
|
+
}
|
|
9
21
|
|
|
10
22
|
export function saveLanAuthorizations(stateRoot, hostId, clients, revision) {
|
|
11
23
|
if (!validId(hostId) || !Array.isArray(clients) || clients.length > 16 ||
|
|
@@ -17,6 +29,7 @@ export function saveLanAuthorizations(stateRoot, hostId, clients, revision) {
|
|
|
17
29
|
fs.writeFileSync(temporary, JSON.stringify(record), { mode: 0o600, flag: 'wx' });
|
|
18
30
|
fs.renameSync(temporary, file);
|
|
19
31
|
} finally { fs.rmSync(temporary, { force: true }); }
|
|
32
|
+
authorizationChanges.emit(authorizationKey(stateRoot));
|
|
20
33
|
}
|
|
21
34
|
export function authorizeLanDevice(stateRoot, clientId, credential) {
|
|
22
35
|
if (!validId(clientId)) return false;
|
|
@@ -50,21 +50,9 @@ async function createRelayRuntime(stateRoot) {
|
|
|
50
50
|
let health = await serviceHealth(session());
|
|
51
51
|
if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
|
|
52
52
|
const stored = await credentialStore('load');
|
|
53
|
-
|
|
53
|
+
const identities = stored.record || { version: 2, devices: {} };
|
|
54
54
|
const validIdentity = (id, item) => /^[\w-]{16,128}$/.test(id) && item?.id === id &&
|
|
55
55
|
validSecret(item.secret) && typeof item.registered === 'boolean';
|
|
56
|
-
if (identities.version === 1 && identities.servers && typeof identities.servers === 'object' && !Array.isArray(identities.servers)) {
|
|
57
|
-
const devices = {};
|
|
58
|
-
for (const [url, item] of Object.entries(identities.servers)) {
|
|
59
|
-
relayAddress(url);
|
|
60
|
-
if (!validIdentity(item?.id, item) || devices[item.id]) throw new Error('本机设备身份损坏,未自动覆盖');
|
|
61
|
-
devices[item.id] = item;
|
|
62
|
-
}
|
|
63
|
-
const previous = identities.servers[config.url];
|
|
64
|
-
identities = { version: 2, devices };
|
|
65
|
-
await credentialStore('save', identities);
|
|
66
|
-
if (!config.deviceId && previous) config.deviceId = previous.id;
|
|
67
|
-
}
|
|
68
56
|
if (identities.version !== 2 || !identities.devices || typeof identities.devices !== 'object' || Array.isArray(identities.devices) ||
|
|
69
57
|
Object.entries(identities.devices).some(([id, item]) => !validIdentity(id, item))) throw new Error('本机设备身份损坏,未自动覆盖');
|
|
70
58
|
if (!config.deviceId) {
|
|
@@ -115,7 +103,7 @@ async function createRelayRuntime(stateRoot) {
|
|
|
115
103
|
};
|
|
116
104
|
async function shutdown() {
|
|
117
105
|
if (closing) return;
|
|
118
|
-
closing = true; clearTimeout(retryTimer);
|
|
106
|
+
closing = true; online = false; attachedCount = 0; clearTimeout(retryTimer);
|
|
119
107
|
connector?.events.removeAllListeners(); connector?.close();
|
|
120
108
|
}
|
|
121
109
|
async function saveInvitation(message) {
|
|
@@ -139,7 +127,9 @@ async function createRelayRuntime(stateRoot) {
|
|
|
139
127
|
try {
|
|
140
128
|
const bridge = session();
|
|
141
129
|
await refreshHealth();
|
|
130
|
+
if (closing) return;
|
|
142
131
|
const lanCandidates = await currentLanCandidates();
|
|
132
|
+
if (closing) return;
|
|
143
133
|
connector?.events.removeAllListeners(); connector?.close();
|
|
144
134
|
const current = connector = connectBridgeRelay({ relayUrl: config.url, identity,
|
|
145
135
|
usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
|
|
@@ -162,6 +152,7 @@ async function createRelayRuntime(stateRoot) {
|
|
|
162
152
|
if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
|
|
163
153
|
});
|
|
164
154
|
const registration = await current.ready;
|
|
155
|
+
if (closing) return;
|
|
165
156
|
if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
|
|
166
157
|
current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
|
|
167
158
|
}
|
|
@@ -170,11 +161,14 @@ async function createRelayRuntime(stateRoot) {
|
|
|
170
161
|
identities.devices[identity.id] = registeredIdentity;
|
|
171
162
|
await credentialStore('save', identities); identity = registeredIdentity;
|
|
172
163
|
}
|
|
164
|
+
if (closing) return;
|
|
173
165
|
current.assertConnected();
|
|
174
166
|
online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
|
|
175
167
|
saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
|
|
176
|
-
await saveInvitation(registration);
|
|
168
|
+
await saveInvitation(registration);
|
|
169
|
+
if (!closing) audit('relay-connected', { paired });
|
|
177
170
|
} catch (error) {
|
|
171
|
+
if (closing) return;
|
|
178
172
|
online = false; lastError = error.message; audit('connect-failed', { message: error.message });
|
|
179
173
|
if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
|
|
180
174
|
else { clearTimeout(retryTimer); retryTimer = undefined; }
|
|
@@ -8,7 +8,7 @@ import { WebSocketServer } from 'ws';
|
|
|
8
8
|
import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
|
|
9
9
|
import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
|
|
10
10
|
import { validProbeSignal } from './p2p-probe.mjs';
|
|
11
|
-
import { relayAdminState } from './admin-state.mjs';
|
|
11
|
+
import { MAX_ADMIN_REQUEST_BYTES, relayAdminState } from './admin-state.mjs';
|
|
12
12
|
import { openRelayMysql } from './mysql-state.mjs';
|
|
13
13
|
import { openEmailAccounts } from './email-accounts.mjs';
|
|
14
14
|
import { usageMeter, usageReport } from './usage-meter.mjs';
|
|
@@ -33,6 +33,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
33
33
|
const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
|
|
34
34
|
const meter = accounts ? usageMeter(database.pool) : null;
|
|
35
35
|
const cancelledAccountIds = new Set();
|
|
36
|
+
const accountCancellationWork = new Map();
|
|
36
37
|
const diagnostics = database?.schemaVersions.includes('006_relay_diagnostics')
|
|
37
38
|
? relayDiagnostics(database.pool, error => log({ event: 'relay-diagnostic-error', message: error.message })) : null;
|
|
38
39
|
const adminAccessFile = '/etc/codex-relay/admin-access.json';
|
|
@@ -145,6 +146,38 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
145
146
|
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
146
147
|
response.end(value);
|
|
147
148
|
}
|
|
149
|
+
function cancelAccountConnections(userId, token) {
|
|
150
|
+
const pending = accountCancellationWork.get(userId);
|
|
151
|
+
if (pending) return pending;
|
|
152
|
+
if (cancelledAccountIds.has(userId)) return Promise.resolve();
|
|
153
|
+
cancelledAccountIds.add(userId);
|
|
154
|
+
const work = (async () => {
|
|
155
|
+
try { await accounts.cancelAccount(token); }
|
|
156
|
+
catch (failure) { cancelledAccountIds.delete(userId); throw failure; }
|
|
157
|
+
for (const room of rooms.values()) {
|
|
158
|
+
for (const [key, arm] of room.usageArms || []) if (arm.userId === userId) room.usageArms.delete(key);
|
|
159
|
+
for (const [key, task] of room.usageTasks || []) if (task.userId === userId) room.usageTasks.delete(key);
|
|
160
|
+
for (const [clientId, connection] of room.connections) {
|
|
161
|
+
if (connection.userId !== userId) continue;
|
|
162
|
+
releaseRelay(connection); room.connections.delete(clientId);
|
|
163
|
+
if (room.host) send(room.host, { type: 'detach', session: connection.session });
|
|
164
|
+
connection.ws.close(4431, 'Account cancelled');
|
|
165
|
+
}
|
|
166
|
+
for (const [clientId, observer] of room.observers) {
|
|
167
|
+
if (observer.userId !== userId) continue;
|
|
168
|
+
room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
|
|
169
|
+
}
|
|
170
|
+
for (const [clientId, upload] of room.uploadClients) {
|
|
171
|
+
if (upload.userId !== userId) continue;
|
|
172
|
+
room.uploadClients.delete(clientId);
|
|
173
|
+
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
174
|
+
upload.ws.close(4431, 'Account cancelled');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
})().finally(() => accountCancellationWork.delete(userId));
|
|
178
|
+
accountCancellationWork.set(userId, work);
|
|
179
|
+
return work;
|
|
180
|
+
}
|
|
148
181
|
async function handleAccount(request, response, target) {
|
|
149
182
|
try {
|
|
150
183
|
if (target.pathname === '/account/config' && request.method === 'GET') {
|
|
@@ -160,29 +193,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
160
193
|
}
|
|
161
194
|
if (target.pathname === '/account/me' && request.method === 'DELETE') {
|
|
162
195
|
const user = await accounts.current(bearer);
|
|
163
|
-
|
|
164
|
-
try { await accounts.cancelAccount(bearer); }
|
|
165
|
-
catch (failure) { cancelledAccountIds.delete(user.id); throw failure; }
|
|
166
|
-
for (const room of rooms.values()) {
|
|
167
|
-
for (const [key, arm] of room.usageArms || []) if (arm.userId === user.id) room.usageArms.delete(key);
|
|
168
|
-
for (const [key, task] of room.usageTasks || []) if (task.userId === user.id) room.usageTasks.delete(key);
|
|
169
|
-
for (const [clientId, connection] of room.connections) {
|
|
170
|
-
if (connection.userId !== user.id) continue;
|
|
171
|
-
releaseRelay(connection); room.connections.delete(clientId);
|
|
172
|
-
if (room.host) send(room.host, { type: 'detach', session: connection.session });
|
|
173
|
-
connection.ws.close(4431, 'Account cancelled');
|
|
174
|
-
}
|
|
175
|
-
for (const [clientId, observer] of room.observers) {
|
|
176
|
-
if (observer.userId !== user.id) continue;
|
|
177
|
-
room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
|
|
178
|
-
}
|
|
179
|
-
for (const [clientId, upload] of room.uploadClients) {
|
|
180
|
-
if (upload.userId !== user.id) continue;
|
|
181
|
-
room.uploadClients.delete(clientId);
|
|
182
|
-
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
183
|
-
upload.ws.close(4431, 'Account cancelled');
|
|
184
|
-
}
|
|
185
|
-
}
|
|
196
|
+
await cancelAccountConnections(user.id, bearer);
|
|
186
197
|
accountReply(response, 200, { ok: true }); return;
|
|
187
198
|
}
|
|
188
199
|
if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
|
|
@@ -247,7 +258,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
247
258
|
if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
|
|
248
259
|
request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
|
|
249
260
|
try {
|
|
250
|
-
const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request,
|
|
261
|
+
const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, MAX_ADMIN_REQUEST_BYTES));
|
|
251
262
|
if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
|
|
252
263
|
const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
|
|
253
264
|
log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
|
package/dist/src/server.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { runtimePaths } from './platform/windows/runtime-paths.mjs';
|
|
|
18
18
|
import { openControl } from './service-control.mjs';
|
|
19
19
|
import { writeRecord, readRecord } from './service-lifecycle.mjs';
|
|
20
20
|
import { ExecutionHealth } from './execution-health.mjs';
|
|
21
|
-
import { authorizeLanDevice } from './self-relay/lan-authorizations.mjs';
|
|
21
|
+
import { authorizeLanDevice, onLanAuthorizationsChanged } from './self-relay/lan-authorizations.mjs';
|
|
22
22
|
import { createRelayManager } from './self-relay/lifecycle.mjs';
|
|
23
23
|
import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
|
|
24
24
|
import { desktopHost } from './platform/windows/desktop-host.mjs';
|
|
@@ -521,7 +521,8 @@ async function handleRequest(request, response) {
|
|
|
521
521
|
const server = http.createServer(handleRequest);
|
|
522
522
|
const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
|
|
523
523
|
uploadsRoot,
|
|
524
|
-
authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
|
|
524
|
+
authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
|
|
525
|
+
onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
|
|
525
526
|
});
|
|
526
527
|
|
|
527
528
|
let control;
|
|
@@ -4,36 +4,69 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
6
|
import { startBackground } from './background-process.mjs';
|
|
7
|
-
import { lifecycle, readRecord } from './service-lifecycle.mjs';
|
|
7
|
+
import { lifecycle, readRecord, writeRecord } from './service-lifecycle.mjs';
|
|
8
8
|
import * as host from './platform/windows/service-host.mjs';
|
|
9
|
+
import { desktopHost } from './platform/windows/desktop-host.mjs';
|
|
9
10
|
|
|
10
11
|
const script = fileURLToPath(import.meta.url);
|
|
12
|
+
const sameProcess = (expected, actual) => expected && actual && expected.pid === actual.pid &&
|
|
13
|
+
expected.created === actual.created && expected.executable === actual.executable;
|
|
14
|
+
async function processIdentity(pid) {
|
|
15
|
+
const current = await desktopHost.process(pid);
|
|
16
|
+
return current ? { pid: current.pid, created: current.created, executable: current.executable } : null;
|
|
17
|
+
}
|
|
11
18
|
|
|
12
19
|
export async function requestServiceRestart(stateRoot) {
|
|
13
20
|
const lock = path.join(stateRoot, 'service-restart.lock');
|
|
14
21
|
const operationId = randomUUID();
|
|
15
|
-
|
|
22
|
+
const owner = await processIdentity(process.pid);
|
|
23
|
+
if (!owner) throw new Error('无法确认服务重启请求进程。');
|
|
24
|
+
const previous = readRecord(lock);
|
|
25
|
+
if (previous) {
|
|
26
|
+
const actual = previous.owner?.pid ? await processIdentity(previous.owner.pid) : null;
|
|
27
|
+
if (sameProcess(previous.owner, actual) || readRecord(lock)?.operationId !== previous.operationId) {
|
|
28
|
+
throw new Error('工作站服务已在重启中。');
|
|
29
|
+
}
|
|
30
|
+
fs.rmSync(lock);
|
|
31
|
+
}
|
|
32
|
+
const temporary = `${lock}.${operationId}.tmp`;
|
|
16
33
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
34
|
+
fs.writeFileSync(temporary, JSON.stringify({ operationId, owner, requestedAt: new Date().toISOString() }),
|
|
35
|
+
{ flag: 'wx', mode: 0o600 });
|
|
36
|
+
// Publish a complete record exclusively; a crash cannot leave a half-written lock.
|
|
37
|
+
fs.linkSync(temporary, lock);
|
|
19
38
|
} catch (error) {
|
|
20
39
|
if (error.code === 'EEXIST') throw new Error('工作站服务已在重启中。');
|
|
21
40
|
throw error;
|
|
22
|
-
} finally {
|
|
41
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
23
42
|
try {
|
|
24
43
|
const log = path.join(stateRoot, 'service-restart.log');
|
|
25
|
-
await startBackground(process.execPath, [script, '--worker', stateRoot, operationId], process.env, [log, log]);
|
|
44
|
+
const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, operationId], process.env, [log, log]);
|
|
45
|
+
const worker = await processIdentity(pid);
|
|
46
|
+
if (!worker) throw new Error('服务重启进程未启动。');
|
|
47
|
+
const current = readRecord(lock);
|
|
48
|
+
if (current?.operationId !== operationId) throw new Error('服务重启实例已失效。');
|
|
49
|
+
writeRecord(lock, { ...current, owner: worker });
|
|
26
50
|
return { operationId, status: 'scheduled', message: '工作站服务重启已安排。' };
|
|
27
51
|
} catch (error) {
|
|
28
|
-
|
|
52
|
+
const current = readRecord(lock);
|
|
53
|
+
if (current?.operationId === operationId && sameProcess(current.owner, owner)) fs.rmSync(lock, { force: true });
|
|
54
|
+
throw error;
|
|
29
55
|
}
|
|
30
56
|
}
|
|
31
57
|
|
|
32
58
|
async function run(stateRoot, operationId) {
|
|
33
59
|
const lock = path.join(stateRoot, 'service-restart.lock');
|
|
34
|
-
if (readRecord(lock)?.operationId !== operationId) throw new Error('服务重启实例已失效。');
|
|
35
|
-
const service = lifecycle({ stateRoot, host });
|
|
36
60
|
try {
|
|
61
|
+
const deadline = Date.now() + 30000;
|
|
62
|
+
while (true) {
|
|
63
|
+
const record = readRecord(lock);
|
|
64
|
+
if (record?.operationId !== operationId) throw new Error('服务重启实例已失效。');
|
|
65
|
+
if (record.owner?.pid === process.pid) break;
|
|
66
|
+
if (Date.now() >= deadline) throw new Error('服务重启进程交接超时。');
|
|
67
|
+
await delay(100);
|
|
68
|
+
}
|
|
69
|
+
const service = lifecycle({ stateRoot, host });
|
|
37
70
|
await delay(1000);
|
|
38
71
|
await service.stop();
|
|
39
72
|
await service.start();
|
|
@@ -211,7 +211,11 @@ export class SharedAppServer extends EventEmitter {
|
|
|
211
211
|
this.emit('turn', { thread: record.thread, turn, sequence,
|
|
212
212
|
immediate: ['turn/completed', 'item/completed'].includes(method) });
|
|
213
213
|
}
|
|
214
|
-
if (method === 'turn/completed'
|
|
214
|
+
if (method === 'turn/completed' && record.thread.turns.length > 10) {
|
|
215
|
+
record.thread.turns = record.thread.turns.slice(-10);
|
|
216
|
+
// Native cursors are opaque; obtain a matching boundary before the next snapshot.
|
|
217
|
+
record.historyTrimmed = true;
|
|
218
|
+
}
|
|
215
219
|
if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
|
|
216
220
|
}
|
|
217
221
|
|
|
@@ -220,21 +224,25 @@ export class SharedAppServer extends EventEmitter {
|
|
|
220
224
|
const slot = this.slot(threadId);
|
|
221
225
|
slot.readers++;
|
|
222
226
|
const task = slot.tail.then(async () => {
|
|
223
|
-
let record = this.threads.get(threadId);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
if (!record) {
|
|
227
|
+
let record = this.threads.get(threadId);
|
|
228
|
+
// Snapshot refreshes must not reset the sequence or tokens of live subscribers.
|
|
229
|
+
const previous = record?.readOnly ? null : record;
|
|
230
|
+
if (record?.readOnly || record?.needsReload) this.contexts.delete(threadId);
|
|
231
|
+
if (!record || record.readOnly || record.needsReload || record.historyTrimmed) {
|
|
230
232
|
let result;
|
|
231
233
|
try {
|
|
232
|
-
result = await this.request('thread/resume', { threadId, excludeTurns: true,
|
|
233
|
-
initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
|
|
234
|
-
if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
234
|
+
result = await this.request('thread/resume', { threadId, excludeTurns: true,
|
|
235
|
+
initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
|
|
236
|
+
if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
|
|
237
|
+
const retained = new Map((previous?.thread.turns || []).map(turn => [turn.id, turn]));
|
|
238
|
+
record = { thread: { ...value.thread, turns: [...value.initialTurnsPage.data].reverse().map(turn => ({
|
|
239
|
+
...turn, items: retained.get(turn.id)?.items || [],
|
|
240
|
+
...(retained.get(turn.id)?.itemPage ? { itemPage: retained.get(turn.id).itemPage } : {}),
|
|
241
|
+
})) },
|
|
242
|
+
nextCursor: value.initialTurnsPage.nextCursor ?? null,
|
|
243
|
+
sequence: previous?.sequence ?? 0, recoverAfter: previous?.recoverAfter ?? 0,
|
|
244
|
+
changes: previous?.changes ?? new Map(), changeBytes: previous?.changeBytes ?? 0,
|
|
245
|
+
tokens: previous?.tokens ?? new Map(), hydrating: true };
|
|
238
246
|
this.threads.set(threadId, record);
|
|
239
247
|
return value;
|
|
240
248
|
});
|
|
@@ -346,7 +354,7 @@ export class SharedAppServer extends EventEmitter {
|
|
|
346
354
|
if (!token || token.expires <= Date.now() || token.sequence !== requested.after) token = undefined;
|
|
347
355
|
}
|
|
348
356
|
let snapshot = false, catchup;
|
|
349
|
-
if (!record || !token) {
|
|
357
|
+
if (!record || !token || (record.historyTrimmed && requested.after < record.recoverAfter)) {
|
|
350
358
|
const fresh = await this.timeline(threadId);
|
|
351
359
|
record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
|
|
352
360
|
requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
|