neoctl-web 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ // Browser + Node >=20 native Web Crypto; no Node-only imports.
2
+ const encoder = new TextEncoder();
3
+ const decoder = new TextDecoder('utf-8', { fatal: true });
4
+ const directions = new Set(['up', 'down']);
5
+ export function decodeBase64(value) {
6
+ if (typeof value !== 'string' || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('Invalid base64');
7
+ const binary = atob(value);
8
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
9
+ if (encodeBase64(bytes) !== value) throw new Error('Invalid base64');
10
+ return bytes;
11
+ }
12
+ export function encodeBase64(bytes) {
13
+ let binary = '';
14
+ for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
15
+ return btoa(binary);
16
+ }
17
+ function aad(deviceId, direction) {
18
+ if (typeof deviceId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(deviceId) || !directions.has(direction)) throw new Error('Invalid context');
19
+ return encoder.encode(JSON.stringify([deviceId, direction]));
20
+ }
21
+ async function importKey(keyBase64, usage) {
22
+ const bytes = decodeBase64(keyBase64);
23
+ if (bytes.byteLength !== 32) throw new Error('Key must be 32 bytes');
24
+ return crypto.subtle.importKey('raw', bytes, 'AES-GCM', false, [usage]);
25
+ }
26
+ export async function seal(keyBase64, deviceId, direction, payload) {
27
+ const additionalData = aad(deviceId, direction);
28
+ const key = await importKey(keyBase64, 'encrypt');
29
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
30
+ const plaintext = JSON.stringify(payload);
31
+ if (plaintext === undefined) throw new Error('Payload must be JSON');
32
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 }, key, encoder.encode(plaintext));
33
+ return { v: 1, nonce: encodeBase64(nonce), ciphertext: encodeBase64(new Uint8Array(encrypted)) };
34
+ }
35
+ export async function open(keyBase64, deviceId, direction, envelope) {
36
+ const additionalData = aad(deviceId, direction);
37
+ if (!envelope || envelope.v !== 1) throw new Error('Invalid envelope');
38
+ const nonce = decodeBase64(envelope.nonce);
39
+ const ciphertext = decodeBase64(envelope.ciphertext);
40
+ if (nonce.length !== 12 || ciphertext.length < 16) throw new Error('Invalid envelope');
41
+ const key = await importKey(keyBase64, 'decrypt');
42
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 }, key, ciphertext);
43
+ return JSON.parse(decoder.decode(plaintext));
44
+ }
@@ -0,0 +1,326 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { seal, open } from './control-protocol.mjs';
7
+
8
+ const FILE = 'transcript.jsonl';
9
+ const MAX_PACKET = 256 * 1024;
10
+ const CHUNK = 32 * 1024;
11
+ const RAW_BUDGET = 128 * 1024;
12
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
13
+ const validId = (value) => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,160}$/.test(value);
14
+
15
+ // These are the actual envKey -> key definitions in engine/src/web/index.ts.
16
+ export const MODEL_FIELDS = Object.freeze({
17
+ OPENAI_API_KEY: 'apiKey', OPENAI_BASE_URL: 'baseUrl', OPENAI_MODEL: 'model',
18
+ OPENAI_ENDPOINT: 'endpoint', MODEL_REASONING_EFFORT: 'reasoningEffort',
19
+ MODEL_REASONING_SUMMARY: 'reasoningSummary', MODEL_MAX_OUTPUT_TOKENS: 'maxOutputTokens',
20
+ MODEL_TIMEOUT_MS: 'timeoutMs', MODEL_STREAM_IDLE_TIMEOUT_MS: 'streamIdleTimeoutMs',
21
+ MODEL_MAX_RETRIES: 'maxRetries',
22
+ });
23
+
24
+ export function loginProfile(profile) {
25
+ if (!profile || profile.provider !== 'openai' || !profile.values || Array.isArray(profile.values)) throw new Error('PROFILE_INVALID');
26
+ const values = {};
27
+ for (const [key, value] of Object.entries(profile.values)) {
28
+ if (!Object.values(MODEL_FIELDS).includes(key) || typeof value !== 'string' || value.length > 8192 || /[\r\n\0]/.test(value)) throw new Error('PROFILE_INVALID');
29
+ values[key] = value;
30
+ }
31
+ if (!values.apiKey?.trim() || !values.model?.trim()) throw new Error('PROFILE_INVALID');
32
+ return { provider: 'openai', values };
33
+ }
34
+
35
+ // Reuse the existing login HTTP endpoint for shared env persistence/default runtime,
36
+ // then the same Engine saveLogin method for every already-created runtime. No env writer.
37
+ export function createLoginApplier({ runtimeUrl, getActiveRepls = () => [], fetchImpl = fetch, timeoutMs = 10_000 }) {
38
+ const endpoint = new URL('/api/login', runtimeUrl);
39
+ return async (profile, { signal } = {}) => {
40
+ const mapped = loginProfile(profile);
41
+ const combinedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
42
+ // Archives are complete forms, not per-device patches. Clear omitted optional
43
+ // fields so a broadcast cannot inherit different old settings on each device.
44
+ const values = {};
45
+ for (const field of Object.values(MODEL_FIELDS)) values[field] = mapped.values[field] ?? '';
46
+ const response = await fetchImpl(endpoint, {
47
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ provider: 'openai', values }), signal: combinedSignal, redirect: 'error',
49
+ });
50
+ if (!response.ok || (await response.json()).ok !== true) throw new Error('LOGIN_FAILED');
51
+ const applied = new Set();
52
+ // Include runtime creations which completed while another login was applying.
53
+ for (;;) {
54
+ const pending = [...getActiveRepls()].filter((repl) => !applied.has(repl));
55
+ if (!pending.length) break;
56
+ for (const repl of pending) {
57
+ combinedSignal.throwIfAborted();
58
+ if ((await repl.saveLogin('openai', values))?.ok !== true) throw new Error('LOGIN_FAILED');
59
+ applied.add(repl);
60
+ }
61
+ }
62
+ };
63
+ }
64
+
65
+ export function validateControlConfig(value) {
66
+ if (!value || value.enabled !== true || typeof value.key !== 'string') return null;
67
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(value.key) || Buffer.from(value.key, 'base64').length !== 32 || Buffer.from(value.key, 'base64').toString('base64') !== value.key) return null;
68
+ try {
69
+ const url = new URL(value.url);
70
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
71
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && (loopback || value.allowHttp === true))) return null;
72
+ if (url.username || url.password || url.search || url.hash) return null;
73
+ return { url: url.href.replace(/\/$/, ''), allowHttp: value.allowHttp === true, key: value.key, enabled: true };
74
+ } catch { return null; }
75
+ }
76
+
77
+ async function atomicJson(file, value) {
78
+ await fs.mkdir(path.dirname(file), { recursive: true });
79
+ const temporary = `${file}.${randomUUID()}.tmp`;
80
+ let handle;
81
+ try {
82
+ handle = await fs.open(temporary, 'wx', 0o600);
83
+ await handle.writeFile(JSON.stringify(value));
84
+ await handle.sync();
85
+ await handle.close();
86
+ handle = undefined;
87
+ await fs.rename(temporary, file);
88
+ } finally {
89
+ await handle?.close().catch(() => {});
90
+ await fs.rm(temporary, { force: true }).catch(() => {});
91
+ }
92
+ }
93
+
94
+ function runLocal(file, args) {
95
+ return new Promise((resolve) => {
96
+ execFile(file, args, { windowsHide: true, timeout: 2500, maxBuffer: 16 * 1024, encoding: 'utf8' }, (error, stdout) => resolve(error ? '' : stdout.trim()));
97
+ });
98
+ }
99
+
100
+ export async function deviceIdentity(directory, deviceId) {
101
+ const machineCode = hash(`neo-control-machine-v1:${deviceId}`);
102
+ let model = '';
103
+ if (process.platform === 'win32') model = await runLocal('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).Model']);
104
+ return { machineCode, hostname: os.hostname().slice(0, 160), model: (model || `${os.type()} ${os.arch()}`).slice(0, 160), platform: process.platform };
105
+ }
106
+
107
+ async function bytesAt(handle, offset, length) {
108
+ const buffer = Buffer.alloc(length);
109
+ const { bytesRead } = await handle.read(buffer, 0, length, offset);
110
+ return buffer.subarray(0, bytesRead);
111
+ }
112
+ async function anchorAt(handle, offset) {
113
+ return hash(await bytesAt(handle, Math.max(0, offset - 64), Math.min(64, offset)));
114
+ }
115
+
116
+ // Engine SessionStore.resolveSessionRoot: AGENT_SESSION_DIR or getNeoctlHome()/sessions.
117
+ export function sessionStoreRoot(env = process.env) {
118
+ return env.AGENT_SESSION_DIR ? path.resolve(env.AGENT_SESSION_DIR) : path.join(os.homedir(), '.neoctl', 'sessions');
119
+ }
120
+
121
+ export function createControlSync(options = {}) {
122
+ // Only the launcher's in-memory opt-in is trusted. Never discover/read pairing
123
+ // files or environment variables. Snapshot prevents target/key swaps at runtime.
124
+ const config = validateControlConfig(options.config);
125
+ const directory = typeof options.dataDir === 'string' && options.dataDir.trim() ? path.resolve(options.dataDir) : '';
126
+ const enabled = Boolean(config && directory);
127
+ const sessionsRoot = options.sessionsRoot || sessionStoreRoot();
128
+ const registryFile = options.registryFile || path.join(directory, 'session-workspaces.json');
129
+ const stateFile = options.stateFile || path.join(directory, 'control-sync-state.json');
130
+ const diagnosticFile = path.join(directory, 'control-sync-diagnostic.json');
131
+ const fetchImpl = options.fetchImpl || fetch;
132
+ const pollMs = Math.min(30_000, Math.max(1, Number(options.pollMs) || 1000));
133
+ const timeoutMs = Math.min(30_000, Math.max(1, Number(options.timeoutMs) || 8000));
134
+ let state, pairing, identity, inFlight, controller, timer;
135
+ let enrolled = false;
136
+ let stopped = false, started = false, failures = 0, rotation = 0, lastDiagnostic = '';
137
+
138
+ async function diagnose(code) {
139
+ // No exception text, URLs, keys, env values, session content, or identifying paths.
140
+ if (lastDiagnostic === code || !enabled) return;
141
+ lastDiagnostic = code;
142
+ await atomicJson(diagnosticFile, { code, at: new Date().toISOString() }).catch(() => {});
143
+ }
144
+ function stillEnabled() { return enabled && !stopped; }
145
+ async function initialize() {
146
+ const idFile = path.join(directory, 'control-device.json');
147
+ let savedDevice;
148
+ try { savedDevice = JSON.parse(await fs.readFile(idFile, 'utf8')); }
149
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
150
+ let deviceId = savedDevice?.deviceId;
151
+ if (deviceId !== undefined && (typeof deviceId !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(deviceId))) throw new Error('DEVICE_ID_INVALID');
152
+ if (!deviceId) {
153
+ deviceId = randomUUID();
154
+ await atomicJson(idFile, { deviceId });
155
+ }
156
+ const value = { ...config, deviceId };
157
+ const fingerprint = hash(JSON.stringify(value));
158
+ try {
159
+ const saved = JSON.parse(await fs.readFile(stateFile, 'utf8'));
160
+ if (saved.version === 1 && saved.pairing === fingerprint && saved.cursors && typeof saved.cursors === 'object' && !Array.isArray(saved.cursors)) state = saved;
161
+ } catch (error) {
162
+ if (error.code !== 'ENOENT') await diagnose('STATE_UNREADABLE');
163
+ }
164
+ state ||= { version: 1, pairing: fingerprint, cursors: {}, ackCommandId: null };
165
+ identity = options.device || await (options.identityProvider || deviceIdentity)(directory, deviceId);
166
+ pairing = value;
167
+ }
168
+ async function conflict(sessionId) {
169
+ state.cursors[sessionId] = { ...state.cursors[sessionId], blocked: true };
170
+ await atomicJson(stateFile, state);
171
+ await diagnose('TRANSCRIPT_CONFLICT');
172
+ }
173
+ async function collect() {
174
+ let entries;
175
+ try { entries = await fs.readdir(sessionsRoot, { withFileTypes: true }); }
176
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
177
+ // Fail closed: shared Engine storage may contain unrelated CLI history.
178
+ let registry;
179
+ try { registry = JSON.parse(await fs.readFile(registryFile, 'utf8')); } catch { return []; }
180
+ if (!registry || typeof registry !== 'object' || Array.isArray(registry)) return [];
181
+ const ids = entries.filter((entry) => entry.isDirectory() && validId(entry.name) && Object.hasOwn(registry, entry.name)).map((entry) => entry.name).sort();
182
+ if (!ids.length) return [];
183
+ const deltas = [];
184
+ let remaining = RAW_BUDGET;
185
+ const start = rotation % ids.length;
186
+ for (let index = 0; index < ids.length && deltas.length < 16 && remaining > 0; index++) {
187
+ const position = (start + index) % ids.length;
188
+ rotation = position + 1;
189
+ const sessionId = ids[position];
190
+ const cursor = state.cursors[sessionId] || { offset: 0 };
191
+ if (cursor.blocked) continue;
192
+ if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0) { await conflict(sessionId); continue; }
193
+ const filename = path.join(sessionsRoot, sessionId, FILE);
194
+ let handle;
195
+ try {
196
+ const link = await fs.lstat(filename);
197
+ if (!link.isFile() || link.isSymbolicLink()) continue;
198
+ handle = await fs.open(filename, 'r');
199
+ const stat = await handle.stat();
200
+ if (stat.size < cursor.offset || (cursor.anchor && cursor.anchor !== await anchorAt(handle, cursor.offset))) {
201
+ await conflict(sessionId); continue;
202
+ }
203
+ let data = await bytesAt(handle, cursor.offset, Math.min(CHUNK, remaining, stat.size - cursor.offset));
204
+ // Preserve unfinished tail lines. Very long records are sent as raw byte chunks;
205
+ // receiver stores bytes, not independently decoded UTF-8 strings.
206
+ const newline = data.lastIndexOf(10);
207
+ if (newline >= 0) data = data.subarray(0, newline + 1);
208
+ else if (data.length < CHUNK) data = Buffer.alloc(0);
209
+ remaining -= data.length;
210
+ deltas.push({ sessionId, file: FILE, offset: cursor.offset, data: data.toString('base64') });
211
+ } catch (error) {
212
+ if (error.code !== 'ENOENT') await diagnose('TRANSCRIPT_READ_FAILED');
213
+ } finally { await handle?.close().catch(() => {}); }
214
+ }
215
+ return deltas;
216
+ }
217
+ async function acceptAcks(acks, deltas) {
218
+ if (!Array.isArray(acks)) throw new Error('ACK_INVALID');
219
+ const next = structuredClone(state);
220
+ const sent = new Map(deltas.map((delta) => [delta.sessionId, delta]));
221
+ const seen = new Set();
222
+ for (const ack of acks) {
223
+ const delta = sent.get(ack?.sessionId);
224
+ if (!delta || ack.file !== FILE || seen.has(ack.sessionId) || !Number.isSafeInteger(ack.offset) || ack.offset < 0 || ack.offset > delta.offset + Buffer.from(delta.data, 'base64').length) throw new Error('ACK_INVALID');
225
+ seen.add(ack.sessionId);
226
+ if (state.cursors[ack.sessionId]?.blocked) continue;
227
+ if (ack.conflict === true) {
228
+ await conflict(ack.sessionId);
229
+ next.cursors[ack.sessionId] = state.cursors[ack.sessionId];
230
+ continue;
231
+ }
232
+ let handle;
233
+ try {
234
+ handle = await fs.open(path.join(sessionsRoot, ack.sessionId, FILE), 'r');
235
+ const stat = await handle.stat();
236
+ const data = Buffer.from(delta.data, 'base64');
237
+ if (stat.size < delta.offset + data.length || !(await bytesAt(handle, delta.offset, data.length)).equals(data)) {
238
+ await conflict(ack.sessionId); next.cursors[ack.sessionId] = state.cursors[ack.sessionId]; continue;
239
+ }
240
+ next.cursors[ack.sessionId] = { offset: ack.offset, anchor: await anchorAt(handle, ack.offset) };
241
+ } finally { await handle?.close().catch(() => {}); }
242
+ }
243
+ await atomicJson(stateFile, next);
244
+ state = next;
245
+ }
246
+ async function exchange(endpoint, payload) {
247
+ if (!stillEnabled()) throw new Error('STOPPED');
248
+ const body = JSON.stringify({ deviceId: pairing.deviceId, envelope: await seal(pairing.key, pairing.deviceId, 'up', payload) });
249
+ if (Buffer.byteLength(body) > MAX_PACKET) throw new Error('PACKET_LIMIT');
250
+ if (!stillEnabled()) throw new Error('STOPPED');
251
+ controller = new AbortController();
252
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
253
+ const response = await fetchImpl(`${pairing.url}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal, redirect: 'error' });
254
+ if (!response.ok) {
255
+ await response.body?.cancel().catch(() => {});
256
+ if (endpoint === 'sync' && [401, 403, 404].includes(response.status)) enrolled = false;
257
+ throw new Error('CONTROL_HTTP_FAILED');
258
+ }
259
+ // Bounded response reading also covers chunked responses.
260
+ let size = 0;
261
+ const chunks = [];
262
+ for await (const chunk of response.body) {
263
+ size += chunk.length;
264
+ if (size > MAX_PACKET) { controller.abort(); throw new Error('RESPONSE_LIMIT'); }
265
+ chunks.push(chunk);
266
+ }
267
+ const reply = await open(pairing.key, pairing.deviceId, 'down', JSON.parse(Buffer.concat(chunks).toString('utf8')).envelope);
268
+ if (reply?.requestId !== payload.requestId) throw new Error('REQUEST_ID_MISMATCH');
269
+ return { reply, signal };
270
+ }
271
+ async function cycle() {
272
+ if (!stillEnabled()) return false;
273
+ if (!pairing) await initialize();
274
+ if (!enrolled) {
275
+ const { reply } = await exchange('enroll', { requestId: randomUUID(), sentAt: Date.now(), kind: 'enroll', device: identity });
276
+ if (reply.kind !== 'enrolled' || reply.deviceId !== pairing.deviceId) throw new Error('ENROLL_INVALID');
277
+ if (!stillEnabled()) return false;
278
+ enrolled = true;
279
+ }
280
+ const deltas = await collect();
281
+ if (!stillEnabled()) return false;
282
+ const payload = { requestId: randomUUID(), sentAt: Date.now(), device: identity, deltas };
283
+ if (state.ackCommandId) payload.ackCommandId = state.ackCommandId;
284
+ const { reply, signal } = await exchange('sync', payload);
285
+ if (!await stillEnabled()) return false;
286
+ await acceptAcks(reply.acks, deltas);
287
+ if (reply.command) {
288
+ const command = reply.command;
289
+ if (!validId(command.id)) throw new Error('COMMAND_INVALID');
290
+ if (command.id !== state.ackCommandId) {
291
+ loginProfile(command.profile);
292
+ if (!options.applyProfile) throw new Error('LOGIN_UNAVAILABLE');
293
+ if (!await stillEnabled()) return false;
294
+ await options.applyProfile(command.profile, { signal });
295
+ if (!await stillEnabled()) return false;
296
+ const next = { ...state, ackCommandId: command.id };
297
+ await atomicJson(stateFile, next);
298
+ state = next;
299
+ }
300
+ }
301
+ return true;
302
+ }
303
+ function tick() {
304
+ if (inFlight) return inFlight;
305
+ if (stopped || !enabled) return Promise.resolve(false);
306
+ inFlight = cycle().then((result) => { failures = 0; return result; }).catch(async () => {
307
+ failures = Math.min(failures + 1, 6);
308
+ await diagnose('SYNC_RETRY');
309
+ return false;
310
+ }).finally(() => { inFlight = undefined; controller = undefined; });
311
+ return inFlight;
312
+ }
313
+ function schedule(delay) {
314
+ if (stopped || !started || !enabled) return;
315
+ timer = setTimeout(async () => {
316
+ await tick();
317
+ schedule(Math.min(30_000, pollMs * 2 ** failures));
318
+ }, delay);
319
+ timer.unref?.();
320
+ }
321
+ return {
322
+ tick,
323
+ start() { if (!started && enabled) { started = true; schedule(0); } return this; },
324
+ async stop() { stopped = true; clearTimeout(timer); controller?.abort(); await inFlight; },
325
+ };
326
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoctl-web",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Neo browser workspace with an embedded agent runtime.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -15,6 +15,7 @@
15
15
  "test:runtime": "node --test runtime-router-cleanup.test.mjs runtime-workspaces.test.mjs",
16
16
  "test:plugins": "npm --prefix ../engine run build && node --test plugins.test.mjs plugin-settings.test.mjs tool-settings.test.mjs",
17
17
  "test:monitoring": "node --test cpa-quota.test.mjs memory-monitor.test.mjs",
18
+ "test:control": "node --test control-sync.test.mjs control-transcript.test.mjs",
18
19
  "build": "vite build",
19
20
  "test:cli": "node --test neow.test.mjs",
20
21
  "prestart": "npm run build",
@@ -46,6 +47,8 @@
46
47
  "dist",
47
48
  "plugins",
48
49
  "server.mjs",
50
+ "control-sync.mjs",
51
+ "control-protocol.mjs",
49
52
  "core-runtime.mjs",
50
53
  "plugins.mjs",
51
54
  "plugin-settings.mjs",
package/server.mjs CHANGED
@@ -3,16 +3,30 @@ import fs from 'node:fs';
3
3
  import fsp from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { coreRuntimeInfo, createWebRuntime, loadNeoPlugins, runWebServer } from './core-runtime.mjs';
7
- import { createWebPluginHost } from './plugins.mjs';
8
- import { createWebPluginSettings } from './plugin-settings.mjs';
9
- import { createWebToolSettings } from './tool-settings.mjs';
10
- import { createWorkspaceRuntimeManager } from './runtime-workspaces.mjs';
11
- import { installRuntimeRouterIdleCleanup } from './runtime-router-cleanup.mjs';
12
- import { createCpaQuotaMonitor } from './cpa-quota.mjs';
13
- import { createMemoryMonitor } from './memory-monitor.mjs';
14
- import { resolveWebStorage } from './platform-paths.mjs';
6
+ // Consume the private launcher secret before importing application/runtime modules.
7
+ // Imports can initialize plugins or spawn children; none may inherit this value.
8
+ let desktopControlConfig;
9
+ {
10
+ const raw = process.env.NEO_DESKTOP_CONTROL_CONFIG;
11
+ delete process.env.NEO_DESKTOP_CONTROL_CONFIG;
12
+ try { desktopControlConfig = raw ? JSON.parse(raw) : undefined; } catch {}
13
+ }
14
+
15
+ const { coreRuntimeInfo, createWebRuntime, loadNeoPlugins, runWebServer } = await import('./core-runtime.mjs');
16
+ const { createWebPluginHost } = await import('./plugins.mjs');
17
+ const { createWebPluginSettings } = await import('./plugin-settings.mjs');
18
+ const { createWebToolSettings } = await import('./tool-settings.mjs');
19
+ const { createWorkspaceRuntimeManager } = await import('./runtime-workspaces.mjs');
20
+ const { installRuntimeRouterIdleCleanup } = await import('./runtime-router-cleanup.mjs');
21
+ const { createCpaQuotaMonitor } = await import('./cpa-quota.mjs');
22
+ const { createMemoryMonitor } = await import('./memory-monitor.mjs');
23
+ const { resolveWebStorage } = await import('./platform-paths.mjs');
24
+ const { createControlSync, createLoginApplier, validateControlConfig } = await import('./control-sync.mjs');
15
25
 
26
+ const controlConfig = validateControlConfig(desktopControlConfig);
27
+ desktopControlConfig = undefined;
28
+ const controlEnabled = Boolean(controlConfig);
29
+
16
30
  installRuntimeRouterIdleCleanup();
17
31
  console.log(`neo core: ${coreRuntimeInfo.source} ${coreRuntimeInfo.version} (${coreRuntimeInfo.location})`);
18
32
  process.env.NEO_CORE_VERSION = coreRuntimeInfo.version;
@@ -53,6 +67,17 @@ const memoryMonitor = createMemoryMonitor({
53
67
  maxPersistedSamples: process.env.NEO_MEMORY_MAX_PERSISTED_SAMPLES,
54
68
  maxPersistedBytes: process.env.NEO_MEMORY_MAX_PERSISTED_BYTES,
55
69
  });
70
+ // Weak references do not defeat the existing router's idle-session cleanup.
71
+ const controlRepls = new Set();
72
+ function activeControlRepls() {
73
+ const active = [];
74
+ for (const reference of controlRepls) {
75
+ const repl = reference.deref();
76
+ if (repl) active.push(repl);
77
+ else controlRepls.delete(reference);
78
+ }
79
+ return active;
80
+ }
56
81
  const workspaceRuntime = createWorkspaceRuntimeManager({
57
82
  projectRoot: process.cwd(),
58
83
  workspaceRoot,
@@ -106,12 +131,26 @@ await new Promise((resolve, reject) => {
106
131
  });
107
132
  });
108
133
 
134
+ // Optional Desktop-only background work starts after HTTP listen and never delays UI.
135
+ if (controlEnabled) {
136
+ const controlSync = createControlSync({
137
+ config: controlConfig,
138
+ dataDir: dataRoot,
139
+ applyProfile: embedRuntime ? createLoginApplier({ runtimeUrl: runtimeTarget, getActiveRepls: activeControlRepls }) : undefined,
140
+ }).start();
141
+ server.once('close', () => { void controlSync.stop(); });
142
+ }
143
+
109
144
  async function startEmbeddedRuntime() {
110
145
  const runtimeHost = runtimeTarget.hostname || '127.0.0.1';
111
146
  const runtimePort = runtimeTarget.port || '3101';
112
147
  await runWebServer(['--host', runtimeHost, '--port', runtimePort], {
113
148
  createRuntime: workspaceRuntime.createRuntime,
114
- createRepl: workspaceRuntime.createRepl,
149
+ createRepl(runtime) {
150
+ const repl = workspaceRuntime.createRepl(runtime);
151
+ if (controlEnabled) controlRepls.add(new WeakRef(repl));
152
+ return repl;
153
+ },
115
154
  });
116
155
  }
117
156