@walkhi/code-relax 0.1.0-beta.24 → 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/bin/self-relay-server.mjs +1 -1
- 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 +68 -46
- 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
|
@@ -56,7 +56,7 @@ const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile,
|
|
|
56
56
|
usageDiagnosticKeyFile: process.env.RELAY_USAGE_DIAGNOSTIC_KEY_FILE,
|
|
57
57
|
publicRelayUrl: process.env.RELAY_PUBLIC_URL || serviceEndpoints.relayUrl, appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || serviceEndpoints.installUrl,
|
|
58
58
|
log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
|
|
59
|
-
console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections:
|
|
59
|
+
console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 128, maxRelaySessions,
|
|
60
60
|
maxFrameMiB: 8, encryption: 'TLS must be supplied by reverse proxy; no end-to-end encryption' }));
|
|
61
61
|
await new Promise(resolve => { process.once('SIGTERM', resolve); process.once('SIGINT', resolve); });
|
|
62
62
|
await relay.close();
|
|
@@ -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';
|
|
@@ -16,7 +16,7 @@ import { relayDiagnostics } from './diagnostics.mjs';
|
|
|
16
16
|
|
|
17
17
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
18
18
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
19
|
-
maxConnections =
|
|
19
|
+
maxConnections = 128, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
20
20
|
maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
|
|
21
21
|
publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
22
22
|
if (!validSecret(hostKey)) throw new Error('Invalid host credential');
|
|
@@ -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';
|
|
@@ -46,18 +47,34 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
46
47
|
let activeRelaySessions = 0;
|
|
47
48
|
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
|
|
48
49
|
let commitTail = Promise.resolve();
|
|
50
|
+
function queueStore(operation) {
|
|
51
|
+
const pending = commitTail.then(operation);
|
|
52
|
+
commitTail = pending.catch(() => {});
|
|
53
|
+
return pending;
|
|
54
|
+
}
|
|
49
55
|
function commit(room, changes) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
return queueStore(async () => {
|
|
57
|
+
changes = typeof changes === 'function' ? changes(room) : changes;
|
|
58
|
+
const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
|
|
59
|
+
if (room.durable) {
|
|
60
|
+
const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
|
|
61
|
+
await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
|
|
62
|
+
}
|
|
63
|
+
Object.assign(room, changes, { updatedAt: next.updatedAt });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function registerRoom(id, durable, credential) {
|
|
67
|
+
return queueStore(async () => {
|
|
68
|
+
// Resolve concurrent first connections before writing the device identity.
|
|
69
|
+
if (durable && rooms.has(id)) return rooms.get(id);
|
|
70
|
+
const now = new Date().toISOString();
|
|
71
|
+
const record = { id, revision: 0, status: 'active', clients: [], createdAt: now, updatedAt: now,
|
|
72
|
+
...(durable ? { hostHash: digest(credential) } : {}) };
|
|
73
|
+
if (durable) await store.save(record);
|
|
74
|
+
const room = { ...record, durable, connections: new Map(), observers: new Map(), uploadClients: new Map() };
|
|
75
|
+
rooms.set(id, room);
|
|
76
|
+
return room;
|
|
58
77
|
});
|
|
59
|
-
commitTail = operation.catch(() => {});
|
|
60
|
-
return operation;
|
|
61
78
|
}
|
|
62
79
|
function invitation(room) {
|
|
63
80
|
const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
|
|
@@ -129,6 +146,38 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
129
146
|
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
130
147
|
response.end(value);
|
|
131
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
|
+
}
|
|
132
181
|
async function handleAccount(request, response, target) {
|
|
133
182
|
try {
|
|
134
183
|
if (target.pathname === '/account/config' && request.method === 'GET') {
|
|
@@ -144,29 +193,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
144
193
|
}
|
|
145
194
|
if (target.pathname === '/account/me' && request.method === 'DELETE') {
|
|
146
195
|
const user = await accounts.current(bearer);
|
|
147
|
-
|
|
148
|
-
try { await accounts.cancelAccount(bearer); }
|
|
149
|
-
catch (failure) { cancelledAccountIds.delete(user.id); throw failure; }
|
|
150
|
-
for (const room of rooms.values()) {
|
|
151
|
-
for (const [key, arm] of room.usageArms || []) if (arm.userId === user.id) room.usageArms.delete(key);
|
|
152
|
-
for (const [key, task] of room.usageTasks || []) if (task.userId === user.id) room.usageTasks.delete(key);
|
|
153
|
-
for (const [clientId, connection] of room.connections) {
|
|
154
|
-
if (connection.userId !== user.id) continue;
|
|
155
|
-
releaseRelay(connection); room.connections.delete(clientId);
|
|
156
|
-
if (room.host) send(room.host, { type: 'detach', session: connection.session });
|
|
157
|
-
connection.ws.close(4431, 'Account cancelled');
|
|
158
|
-
}
|
|
159
|
-
for (const [clientId, observer] of room.observers) {
|
|
160
|
-
if (observer.userId !== user.id) continue;
|
|
161
|
-
room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
|
|
162
|
-
}
|
|
163
|
-
for (const [clientId, upload] of room.uploadClients) {
|
|
164
|
-
if (upload.userId !== user.id) continue;
|
|
165
|
-
room.uploadClients.delete(clientId);
|
|
166
|
-
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
167
|
-
upload.ws.close(4431, 'Account cancelled');
|
|
168
|
-
}
|
|
169
|
-
}
|
|
196
|
+
await cancelAccountConnections(user.id, bearer);
|
|
170
197
|
accountReply(response, 200, { ok: true }); return;
|
|
171
198
|
}
|
|
172
199
|
if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
|
|
@@ -231,7 +258,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
231
258
|
if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
|
|
232
259
|
request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
|
|
233
260
|
try {
|
|
234
|
-
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));
|
|
235
262
|
if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
|
|
236
263
|
const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
|
|
237
264
|
log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
|
|
@@ -298,7 +325,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
298
325
|
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
|
|
299
326
|
}
|
|
300
327
|
if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
|
|
301
|
-
if (wss.clients.size >= maxConnections || ++admissions >
|
|
328
|
+
if (wss.clients.size >= maxConnections || ++admissions > 240) {
|
|
302
329
|
socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
|
|
303
330
|
}
|
|
304
331
|
wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
|
|
@@ -435,18 +462,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
435
462
|
}
|
|
436
463
|
catch { return fail(1008, 'Invalid LAN candidates'); }
|
|
437
464
|
room = durable ? rooms.get(message.deviceId) : undefined;
|
|
438
|
-
if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
439
465
|
if (!room) {
|
|
440
|
-
// Durable computers prove future ownership with their generated device secret.
|
|
441
|
-
// The legacy deployment key remains accepted, but is no longer required for first registration.
|
|
442
466
|
if (!durable && !matches(message.key, hostHash)) return fail();
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
room = { id: durable ? message.deviceId : randomUUID(), durable,
|
|
446
|
-
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
|
|
447
|
-
...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
|
|
448
|
-
await commit(room, {}); rooms.set(room.id, room);
|
|
467
|
+
room = await registerRoom(durable ? message.deviceId : randomUUID(), durable, message.deviceSecret);
|
|
468
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
449
469
|
}
|
|
470
|
+
if (durable && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
450
471
|
const oldHost = room.host;
|
|
451
472
|
room.observers ||= new Map();
|
|
452
473
|
if (oldHost && oldHost !== ws) {
|
|
@@ -490,6 +511,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
490
511
|
await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
|
|
491
512
|
room.pairingHash = undefined;
|
|
492
513
|
} finally { room.pairingInProgress = false; }
|
|
514
|
+
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
493
515
|
send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
|
|
494
516
|
} else {
|
|
495
517
|
clientId = message.clientId;
|
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 };
|