neoctl-web 0.1.13 → 0.1.15
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/README.md +34 -216
- package/chunk-uploads.mjs +125 -0
- package/core-runtime.mjs +1 -0
- package/dist/assets/App-DkTv-X6M.js +105 -0
- package/dist/assets/App-DmtsTXw2.css +1 -0
- package/dist/assets/index-BwXWTT2b.js +18 -0
- package/dist/assets/index-Dgfhk2Ym.css +1 -0
- package/dist/index.html +2 -2
- package/execution-backend.mjs +74 -0
- package/isolation-auth.mjs +225 -0
- package/isolation.example.json +8 -0
- package/isolation.mjs +385 -0
- package/package.json +20 -12
- package/plugins/downloads/README.md +40 -0
- package/plugins/downloads/downloads.mjs +81 -120
- package/plugins/downloads/index.mjs +10 -8
- package/plugins/downloads/neo-plugin.json +2 -2
- package/plugins/video-share/README.md +55 -0
- package/plugins/video-share/http.mjs +86 -0
- package/plugins/video-share/index.mjs +65 -0
- package/plugins/video-share/neo-plugin.json +9 -0
- package/plugins/video-share/presentation.mjs +23 -0
- package/plugins/video-share/store.mjs +76 -0
- package/plugins/xhs-artifact/editor-page.mjs +4 -4
- package/runtime-workspaces.mjs +9 -6
- package/scripts/isolation-user.mjs +68 -0
- package/server.mjs +26 -48
- package/control-protocol.mjs +0 -44
- package/control-sync.mjs +0 -420
- package/dist/assets/index-9hY14np_.css +0 -1
- package/dist/assets/index-BnJuxSfa.js +0 -121
- package/plugins/xhs-artifact/version.test.mjs +0 -151
package/control-sync.mjs
DELETED
|
@@ -1,420 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import { setTimeout as wait } from 'node:timers/promises';
|
|
5
|
-
import { execFile } from 'node:child_process';
|
|
6
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
-
import { seal, open } from './control-protocol.mjs';
|
|
8
|
-
|
|
9
|
-
const FILE = 'transcript.jsonl';
|
|
10
|
-
const MAX_PACKET = 256 * 1024;
|
|
11
|
-
const CHUNK = 32 * 1024;
|
|
12
|
-
const RAW_BUDGET = 128 * 1024;
|
|
13
|
-
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
14
|
-
const validId = (value) => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,160}$/.test(value);
|
|
15
|
-
|
|
16
|
-
// These are the actual envKey -> key definitions in engine/src/web/index.ts.
|
|
17
|
-
export const MODEL_FIELDS = Object.freeze({
|
|
18
|
-
OPENAI_API_KEY: 'apiKey', OPENAI_BASE_URL: 'baseUrl', OPENAI_MODEL: 'model',
|
|
19
|
-
OPENAI_ENDPOINT: 'endpoint', MODEL_REASONING_EFFORT: 'reasoningEffort',
|
|
20
|
-
MODEL_REASONING_SUMMARY: 'reasoningSummary', MODEL_MAX_OUTPUT_TOKENS: 'maxOutputTokens',
|
|
21
|
-
MODEL_TIMEOUT_MS: 'timeoutMs', MODEL_STREAM_IDLE_TIMEOUT_MS: 'streamIdleTimeoutMs',
|
|
22
|
-
MODEL_MAX_RETRIES: 'maxRetries',
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
export function loginProfile(profile) {
|
|
26
|
-
if (!profile || profile.provider !== 'openai' || !profile.values || Array.isArray(profile.values)) throw new Error('PROFILE_INVALID');
|
|
27
|
-
const values = {};
|
|
28
|
-
for (const [key, value] of Object.entries(profile.values)) {
|
|
29
|
-
if (!Object.values(MODEL_FIELDS).includes(key) || typeof value !== 'string' || value.length > 8192 || /[\r\n\0]/.test(value)) throw new Error('PROFILE_INVALID');
|
|
30
|
-
values[key] = value;
|
|
31
|
-
}
|
|
32
|
-
if (!values.apiKey?.trim() || !values.model?.trim()) throw new Error('PROFILE_INVALID');
|
|
33
|
-
return { provider: 'openai', values };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Reuse the existing login HTTP endpoint for shared env persistence/default runtime,
|
|
37
|
-
// then the same Engine saveLogin method for every already-created runtime. No env writer.
|
|
38
|
-
export function createLoginApplier({ runtimeUrl, getActiveRepls = () => [], fetchImpl = fetch, timeoutMs = 10_000 }) {
|
|
39
|
-
const endpoint = new URL('/api/login', runtimeUrl);
|
|
40
|
-
return async (profile, { signal } = {}) => {
|
|
41
|
-
const mapped = loginProfile(profile);
|
|
42
|
-
const combinedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
|
|
43
|
-
// Archives are complete forms, not per-device patches. Clear omitted optional
|
|
44
|
-
// fields so a broadcast cannot inherit different old settings on each device.
|
|
45
|
-
const values = {};
|
|
46
|
-
for (const field of Object.values(MODEL_FIELDS)) values[field] = mapped.values[field] ?? '';
|
|
47
|
-
const response = await fetchImpl(endpoint, {
|
|
48
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
49
|
-
body: JSON.stringify({ provider: 'openai', values }), signal: combinedSignal, redirect: 'error',
|
|
50
|
-
});
|
|
51
|
-
if (!response.ok || (await response.json()).ok !== true) throw new Error('LOGIN_FAILED');
|
|
52
|
-
const applied = new Set();
|
|
53
|
-
// Include runtime creations which completed while another login was applying.
|
|
54
|
-
for (;;) {
|
|
55
|
-
const pending = [...getActiveRepls()].filter((repl) => !applied.has(repl));
|
|
56
|
-
if (!pending.length) break;
|
|
57
|
-
for (const repl of pending) {
|
|
58
|
-
combinedSignal.throwIfAborted();
|
|
59
|
-
if ((await repl.saveLogin('openai', values))?.ok !== true) throw new Error('LOGIN_FAILED');
|
|
60
|
-
applied.add(repl);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function validateControlConfig(value) {
|
|
67
|
-
if (!value || value.enabled !== true || typeof value.key !== 'string') return null;
|
|
68
|
-
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;
|
|
69
|
-
try {
|
|
70
|
-
const url = new URL(value.url);
|
|
71
|
-
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
72
|
-
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && (loopback || value.allowHttp === true))) return null;
|
|
73
|
-
if (url.username || url.password || url.search || url.hash) return null;
|
|
74
|
-
return { url: url.href.replace(/\/$/, ''), allowHttp: value.allowHttp === true, key: value.key, enabled: true };
|
|
75
|
-
} catch { return null; }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function atomicJson(file, value) {
|
|
79
|
-
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
80
|
-
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
81
|
-
let handle;
|
|
82
|
-
try {
|
|
83
|
-
handle = await fs.open(temporary, 'wx', 0o600);
|
|
84
|
-
await handle.writeFile(JSON.stringify(value));
|
|
85
|
-
await handle.sync();
|
|
86
|
-
await handle.close();
|
|
87
|
-
handle = undefined;
|
|
88
|
-
await fs.rename(temporary, file);
|
|
89
|
-
} finally {
|
|
90
|
-
await handle?.close().catch(() => {});
|
|
91
|
-
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function runLocal(file, args) {
|
|
96
|
-
return new Promise((resolve) => {
|
|
97
|
-
execFile(file, args, { windowsHide: true, timeout: 2500, maxBuffer: 16 * 1024, encoding: 'utf8' }, (error, stdout) => resolve(error ? '' : stdout.trim()));
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function deviceIdentity(directory, deviceId) {
|
|
102
|
-
const machineCode = hash(`neo-control-machine-v1:${deviceId}`);
|
|
103
|
-
let model = '';
|
|
104
|
-
if (process.platform === 'win32') model = await runLocal('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).Model']);
|
|
105
|
-
return { machineCode, hostname: os.hostname().slice(0, 160), model: (model || `${os.type()} ${os.arch()}`).slice(0, 160), platform: process.platform };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async function bytesAt(handle, offset, length) {
|
|
109
|
-
const buffer = Buffer.alloc(length);
|
|
110
|
-
const { bytesRead } = await handle.read(buffer, 0, length, offset);
|
|
111
|
-
return buffer.subarray(0, bytesRead);
|
|
112
|
-
}
|
|
113
|
-
async function anchorAt(handle, offset) {
|
|
114
|
-
return hash(await bytesAt(handle, Math.max(0, offset - 64), Math.min(64, offset)));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Engine SessionStore.resolveSessionRoot: AGENT_SESSION_DIR or getNeoctlHome()/sessions.
|
|
118
|
-
export function sessionStoreRoot(env = process.env) {
|
|
119
|
-
return env.AGENT_SESSION_DIR ? path.resolve(env.AGENT_SESSION_DIR) : path.join(os.homedir(), '.neoctl', 'sessions');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function createControlSync(options = {}) {
|
|
123
|
-
// Only the launcher's in-memory opt-in is trusted. Never discover/read pairing
|
|
124
|
-
// files or environment variables. Snapshot prevents target/key swaps at runtime.
|
|
125
|
-
const config = validateControlConfig(options.config);
|
|
126
|
-
const directory = typeof options.dataDir === 'string' && options.dataDir.trim() ? path.resolve(options.dataDir) : '';
|
|
127
|
-
const enabled = Boolean(config && directory);
|
|
128
|
-
const sessionsRoot = options.sessionsRoot || sessionStoreRoot();
|
|
129
|
-
const registryFile = options.registryFile || path.join(directory, 'session-workspaces.json');
|
|
130
|
-
const stateFile = options.stateFile || path.join(directory, 'control-sync-state.json');
|
|
131
|
-
const diagnosticFile = path.join(directory, 'control-sync-diagnostic.json');
|
|
132
|
-
const fetchImpl = options.fetchImpl || fetch;
|
|
133
|
-
const pollMs = Math.min(30_000, Math.max(1, Number(options.pollMs) || 1000));
|
|
134
|
-
const timeoutMs = Math.min(30_000, Math.max(1, Number(options.timeoutMs) || 8000));
|
|
135
|
-
const fastPollMs = Math.min(30_000, Math.max(500, Number(options.fastPollMs) || 500));
|
|
136
|
-
const scanLimit = Math.min(1024, Math.max(2, Math.floor(Number(options.scanLimit) || 64)));
|
|
137
|
-
const indexTtlMs = Math.min(60_000, Math.max(1, Number(options.indexTtlMs) || 30_000));
|
|
138
|
-
let state, pairing, identity, inFlight, controller, timer;
|
|
139
|
-
let registryCache, registryStamp, indexAt = 0, lastSyncAt = 0, backlog = false;
|
|
140
|
-
const active = new Map();
|
|
141
|
-
const activeTtlMs = Math.min(60_000, Math.max(1, Number(options.activeTtlMs) || 30_000));
|
|
142
|
-
let serverReportingBlocked = false, reportingProbed = false;
|
|
143
|
-
let enrolled = false;
|
|
144
|
-
let stopped = false, started = false, failures = 0, rotation = 0, lastDiagnostic = '';
|
|
145
|
-
|
|
146
|
-
async function diagnose(code) {
|
|
147
|
-
// No exception text, URLs, keys, env values, session content, or identifying paths.
|
|
148
|
-
if (lastDiagnostic === code || !enabled) return;
|
|
149
|
-
lastDiagnostic = code;
|
|
150
|
-
await atomicJson(diagnosticFile, { code, at: new Date().toISOString() }).catch(() => {});
|
|
151
|
-
}
|
|
152
|
-
function stillEnabled() { return enabled && !stopped; }
|
|
153
|
-
async function initialize() {
|
|
154
|
-
const idFile = path.join(directory, 'control-device.json');
|
|
155
|
-
let savedDevice;
|
|
156
|
-
try { savedDevice = JSON.parse(await fs.readFile(idFile, 'utf8')); }
|
|
157
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
158
|
-
let deviceId = savedDevice?.deviceId;
|
|
159
|
-
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');
|
|
160
|
-
if (!deviceId) {
|
|
161
|
-
deviceId = randomUUID();
|
|
162
|
-
await atomicJson(idFile, { deviceId });
|
|
163
|
-
}
|
|
164
|
-
const value = { ...config, deviceId };
|
|
165
|
-
const fingerprint = hash(JSON.stringify(value));
|
|
166
|
-
try {
|
|
167
|
-
const saved = JSON.parse(await fs.readFile(stateFile, 'utf8'));
|
|
168
|
-
if (saved.version === 1 && saved.pairing === fingerprint && saved.cursors && typeof saved.cursors === 'object' && !Array.isArray(saved.cursors)) state = saved;
|
|
169
|
-
} catch (error) {
|
|
170
|
-
if (error.code !== 'ENOENT') await diagnose('STATE_UNREADABLE');
|
|
171
|
-
}
|
|
172
|
-
state ||= { version: 1, pairing: fingerprint, cursors: {}, ackCommandId: null };
|
|
173
|
-
identity = options.device || await (options.identityProvider || deviceIdentity)(directory, deviceId);
|
|
174
|
-
pairing = value;
|
|
175
|
-
}
|
|
176
|
-
async function conflict(sessionId) {
|
|
177
|
-
state.cursors[sessionId] = { ...state.cursors[sessionId], blocked: true };
|
|
178
|
-
await atomicJson(stateFile, state);
|
|
179
|
-
await diagnose('TRANSCRIPT_CONFLICT');
|
|
180
|
-
}
|
|
181
|
-
async function sessionIndex() {
|
|
182
|
-
// Stat every cycle, parse/sort on change or TTL. Index registered IDs directly;
|
|
183
|
-
// unrelated Engine/CLI history never adds filesystem scan work.
|
|
184
|
-
try {
|
|
185
|
-
const stat = await fs.stat(registryFile, { bigint: true });
|
|
186
|
-
const stamp = [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':');
|
|
187
|
-
if (!registryCache || stamp !== registryStamp || Date.now() - indexAt >= indexTtlMs) {
|
|
188
|
-
const registry = JSON.parse(await fs.readFile(registryFile, 'utf8'));
|
|
189
|
-
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) throw new Error('REGISTRY_INVALID');
|
|
190
|
-
const ids = Object.keys(registry).filter(validId).sort();
|
|
191
|
-
registryCache = { ids, members: new Set(ids) };
|
|
192
|
-
registryStamp = stamp;
|
|
193
|
-
indexAt = Date.now();
|
|
194
|
-
for (const id of active.keys()) if (!registryCache.members.has(id)) active.delete(id);
|
|
195
|
-
}
|
|
196
|
-
return registryCache.ids;
|
|
197
|
-
} catch {
|
|
198
|
-
registryCache = undefined;
|
|
199
|
-
registryStamp = undefined;
|
|
200
|
-
active.clear();
|
|
201
|
-
return [];
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
async function collect() {
|
|
205
|
-
const ids = await sessionIndex();
|
|
206
|
-
backlog = false;
|
|
207
|
-
if (!ids.length) return [];
|
|
208
|
-
const deltas = [];
|
|
209
|
-
let remaining = RAW_BUDGET;
|
|
210
|
-
// Interleave a rotating active queue and cold sweep, with bounded disk work.
|
|
211
|
-
const visited = new Set();
|
|
212
|
-
let cold = 0;
|
|
213
|
-
const hot = [];
|
|
214
|
-
for (const id of active.keys()) { hot.push(id); if (hot.length >= Math.ceil(scanLimit / 2)) break; }
|
|
215
|
-
let hotIndex = 0;
|
|
216
|
-
for (let index = 0; index < scanLimit && deltas.length < 16 && remaining > 0 && stillEnabled(); index++) {
|
|
217
|
-
let sessionId = ids.length > scanLimit && index % 2 === 0 ? hot[hotIndex++] : undefined;
|
|
218
|
-
if (!sessionId) {
|
|
219
|
-
if (cold >= ids.length) break;
|
|
220
|
-
sessionId = ids[rotation % ids.length];
|
|
221
|
-
rotation = (rotation + 1) % ids.length;
|
|
222
|
-
cold++;
|
|
223
|
-
}
|
|
224
|
-
if (visited.has(sessionId)) continue;
|
|
225
|
-
visited.add(sessionId);
|
|
226
|
-
const activeUntil = active.get(sessionId);
|
|
227
|
-
active.delete(sessionId);
|
|
228
|
-
if (activeUntil > Date.now()) active.set(sessionId, activeUntil);
|
|
229
|
-
const cursor = state.cursors[sessionId] || { offset: 0 };
|
|
230
|
-
if (cursor.blocked) { active.delete(sessionId); continue; }
|
|
231
|
-
if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0) { await conflict(sessionId); continue; }
|
|
232
|
-
const filename = path.join(sessionsRoot, sessionId, FILE);
|
|
233
|
-
let handle;
|
|
234
|
-
try {
|
|
235
|
-
const parent = await fs.lstat(path.dirname(filename));
|
|
236
|
-
if (!parent.isDirectory() || parent.isSymbolicLink()) continue;
|
|
237
|
-
const link = await fs.lstat(filename);
|
|
238
|
-
if (!link.isFile() || link.isSymbolicLink()) continue;
|
|
239
|
-
handle = await fs.open(filename, 'r');
|
|
240
|
-
const stat = await handle.stat();
|
|
241
|
-
if (stat.size < cursor.offset || (cursor.anchor && cursor.anchor !== await anchorAt(handle, cursor.offset))) {
|
|
242
|
-
await conflict(sessionId); continue;
|
|
243
|
-
}
|
|
244
|
-
let data = await bytesAt(handle, cursor.offset, Math.min(CHUNK, remaining, stat.size - cursor.offset));
|
|
245
|
-
// Preserve unfinished tail lines. Very long records are sent as raw byte chunks;
|
|
246
|
-
// receiver stores bytes, not independently decoded UTF-8 strings.
|
|
247
|
-
const newline = data.lastIndexOf(10);
|
|
248
|
-
if (newline >= 0) data = data.subarray(0, newline + 1);
|
|
249
|
-
else if (data.length < CHUNK) data = Buffer.alloc(0);
|
|
250
|
-
if (!data.length) continue; // Empty/unfinished tails consume no delta slots.
|
|
251
|
-
active.set(sessionId, Date.now() + activeTtlMs);
|
|
252
|
-
if (stat.size > cursor.offset + data.length) backlog = true;
|
|
253
|
-
remaining -= data.length;
|
|
254
|
-
deltas.push({ sessionId, file: FILE, offset: cursor.offset, data: data.toString('base64') });
|
|
255
|
-
} catch (error) {
|
|
256
|
-
if (error.code !== 'ENOENT') await diagnose('TRANSCRIPT_READ_FAILED');
|
|
257
|
-
} finally { await handle?.close().catch(() => {}); }
|
|
258
|
-
}
|
|
259
|
-
if (remaining === 0 || deltas.length === 16) backlog = true;
|
|
260
|
-
return deltas;
|
|
261
|
-
}
|
|
262
|
-
async function acceptAcks(acks, deltas) {
|
|
263
|
-
if (!Array.isArray(acks)) throw new Error('ACK_INVALID');
|
|
264
|
-
if (!acks.length) return;
|
|
265
|
-
const next = structuredClone(state);
|
|
266
|
-
const sent = new Map(deltas.map((delta) => [delta.sessionId, delta]));
|
|
267
|
-
const seen = new Set();
|
|
268
|
-
for (const ack of acks) {
|
|
269
|
-
const delta = sent.get(ack?.sessionId);
|
|
270
|
-
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');
|
|
271
|
-
seen.add(ack.sessionId);
|
|
272
|
-
if (state.cursors[ack.sessionId]?.blocked) continue;
|
|
273
|
-
if (ack.error === 'QUOTA_EXCEEDED' && ack.retryable === false) {
|
|
274
|
-
next.cursors[ack.sessionId] = { ...state.cursors[ack.sessionId], offset: state.cursors[ack.sessionId]?.offset ?? delta.offset, blocked: true };
|
|
275
|
-
active.delete(ack.sessionId);
|
|
276
|
-
await diagnose('SESSION_QUOTA_EXCEEDED');
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
if (ack.error) throw new Error('ACK_INVALID');
|
|
280
|
-
if (ack.conflict === true) {
|
|
281
|
-
await conflict(ack.sessionId);
|
|
282
|
-
next.cursors[ack.sessionId] = state.cursors[ack.sessionId];
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
let handle;
|
|
286
|
-
try {
|
|
287
|
-
handle = await fs.open(path.join(sessionsRoot, ack.sessionId, FILE), 'r');
|
|
288
|
-
const stat = await handle.stat();
|
|
289
|
-
const data = Buffer.from(delta.data, 'base64');
|
|
290
|
-
if (stat.size < delta.offset + data.length || !(await bytesAt(handle, delta.offset, data.length)).equals(data)) {
|
|
291
|
-
await conflict(ack.sessionId); next.cursors[ack.sessionId] = state.cursors[ack.sessionId]; continue;
|
|
292
|
-
}
|
|
293
|
-
next.cursors[ack.sessionId] = { offset: ack.offset, anchor: await anchorAt(handle, ack.offset) };
|
|
294
|
-
} finally { await handle?.close().catch(() => {}); }
|
|
295
|
-
}
|
|
296
|
-
if (JSON.stringify(next) !== JSON.stringify(state)) await atomicJson(stateFile, next);
|
|
297
|
-
state = next;
|
|
298
|
-
}
|
|
299
|
-
async function exchange(endpoint, payload) {
|
|
300
|
-
if (!stillEnabled()) throw new Error('STOPPED');
|
|
301
|
-
const body = JSON.stringify({ deviceId: pairing.deviceId, envelope: await seal(pairing.key, pairing.deviceId, 'up', payload) });
|
|
302
|
-
if (Buffer.byteLength(body) > MAX_PACKET) throw new Error('PACKET_LIMIT');
|
|
303
|
-
if (!stillEnabled()) throw new Error('STOPPED');
|
|
304
|
-
controller = new AbortController();
|
|
305
|
-
// All sync requests (including manual ticks and immediate ACK) share spacing.
|
|
306
|
-
if (endpoint === 'sync') {
|
|
307
|
-
const delay = Math.max(0, 500 - (Date.now() - lastSyncAt));
|
|
308
|
-
if (delay) await wait(delay, undefined, { signal: controller.signal });
|
|
309
|
-
if (!stillEnabled()) throw new Error('STOPPED');
|
|
310
|
-
lastSyncAt = Date.now();
|
|
311
|
-
}
|
|
312
|
-
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
|
|
313
|
-
const response = await fetchImpl(`${pairing.url}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal, redirect: 'error' });
|
|
314
|
-
if (!response.ok) {
|
|
315
|
-
await response.body?.cancel().catch(() => {});
|
|
316
|
-
if (endpoint === 'sync' && [401, 403, 404].includes(response.status)) { enrolled = false; reportingProbed = false; }
|
|
317
|
-
throw new Error('CONTROL_HTTP_FAILED');
|
|
318
|
-
}
|
|
319
|
-
// Bounded response reading also covers chunked responses.
|
|
320
|
-
let size = 0;
|
|
321
|
-
const chunks = [];
|
|
322
|
-
for await (const chunk of response.body) {
|
|
323
|
-
size += chunk.length;
|
|
324
|
-
if (size > MAX_PACKET) { controller.abort(); throw new Error('RESPONSE_LIMIT'); }
|
|
325
|
-
chunks.push(chunk);
|
|
326
|
-
}
|
|
327
|
-
const reply = await open(pairing.key, pairing.deviceId, 'down', JSON.parse(Buffer.concat(chunks).toString('utf8')).envelope);
|
|
328
|
-
if (reply?.requestId !== payload.requestId) throw new Error('REQUEST_ID_MISMATCH');
|
|
329
|
-
return { reply, signal };
|
|
330
|
-
}
|
|
331
|
-
async function cycle() {
|
|
332
|
-
if (!stillEnabled()) return false;
|
|
333
|
-
if (!pairing) await initialize();
|
|
334
|
-
if (!enrolled) {
|
|
335
|
-
const { reply } = await exchange('enroll', { requestId: randomUUID(), sentAt: Date.now(), kind: 'enroll', device: identity });
|
|
336
|
-
if (reply.kind !== 'enrolled' || reply.deviceId !== pairing.deviceId) throw new Error('ENROLL_INVALID');
|
|
337
|
-
if (!stillEnabled()) return false;
|
|
338
|
-
enrolled = true;
|
|
339
|
-
}
|
|
340
|
-
// First sync after launch/re-enrollment is control-only. Never inspect local
|
|
341
|
-
// transcripts until an authenticated response establishes reporting policy.
|
|
342
|
-
// A cycle is bounded to probe + regular sync + one immediate ACK (at most 3).
|
|
343
|
-
if (!reportingProbed) {
|
|
344
|
-
const hadPendingAck = state.ackCommandId && state.ackCommandPending !== false;
|
|
345
|
-
const applied = await syncOnce([]);
|
|
346
|
-
if (!stillEnabled()) return false;
|
|
347
|
-
if (applied) { await syncOnce([]); return stillEnabled(); }
|
|
348
|
-
if (serverReportingBlocked || hadPendingAck) return true;
|
|
349
|
-
}
|
|
350
|
-
// Persisted pending ACK has priority over transcript scans after restart.
|
|
351
|
-
if (state.ackCommandId && state.ackCommandPending !== false) {
|
|
352
|
-
const applied = await syncOnce([]);
|
|
353
|
-
if (applied && stillEnabled()) await syncOnce([]);
|
|
354
|
-
} else {
|
|
355
|
-
const deltas = serverReportingBlocked ? [] : await collect();
|
|
356
|
-
if (!stillEnabled()) return false;
|
|
357
|
-
const applied = await syncOnce(deltas);
|
|
358
|
-
// One extra request only; never recurse on commands returned by the ACK.
|
|
359
|
-
if (applied && stillEnabled()) await syncOnce([]);
|
|
360
|
-
}
|
|
361
|
-
return stillEnabled();
|
|
362
|
-
}
|
|
363
|
-
async function syncOnce(deltas) {
|
|
364
|
-
const payload = { requestId: randomUUID(), sentAt: Date.now(), device: identity, deltas };
|
|
365
|
-
if (state.ackCommandId && state.ackCommandPending !== false) payload.ackCommandId = state.ackCommandId;
|
|
366
|
-
const { reply, signal } = await exchange('sync', payload);
|
|
367
|
-
if (!stillEnabled()) return false;
|
|
368
|
-
if (reply.reportingBlocked !== undefined && typeof reply.reportingBlocked !== 'boolean') throw new Error('REPORTING_POLICY_INVALID');
|
|
369
|
-
serverReportingBlocked = reply.reportingBlocked === true; // Legacy servers default false.
|
|
370
|
-
reportingProbed = true;
|
|
371
|
-
if (serverReportingBlocked) backlog = false;
|
|
372
|
-
// In-flight bytes may have been sent before learning the policy. The server
|
|
373
|
-
// discards them; never advance or freeze a cursor using a blocked response.
|
|
374
|
-
if (!serverReportingBlocked) await acceptAcks(reply.acks, deltas);
|
|
375
|
-
if (payload.ackCommandId) {
|
|
376
|
-
const next = { ...state, ackCommandPending: false };
|
|
377
|
-
await atomicJson(stateFile, next);
|
|
378
|
-
state = next;
|
|
379
|
-
}
|
|
380
|
-
if (reply.command) {
|
|
381
|
-
const command = reply.command;
|
|
382
|
-
if (!validId(command.id)) throw new Error('COMMAND_INVALID');
|
|
383
|
-
if (command.id !== state.ackCommandId) {
|
|
384
|
-
loginProfile(command.profile);
|
|
385
|
-
if (!options.applyProfile) throw new Error('LOGIN_UNAVAILABLE');
|
|
386
|
-
if (!stillEnabled()) return false;
|
|
387
|
-
await options.applyProfile(command.profile, { signal });
|
|
388
|
-
if (!stillEnabled()) return false;
|
|
389
|
-
const next = { ...state, ackCommandId: command.id, ackCommandPending: true };
|
|
390
|
-
await atomicJson(stateFile, next);
|
|
391
|
-
state = next;
|
|
392
|
-
return true;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
function tick() {
|
|
398
|
-
if (inFlight) return inFlight;
|
|
399
|
-
if (stopped || !enabled) return Promise.resolve(false);
|
|
400
|
-
inFlight = cycle().then((result) => { failures = 0; return result; }).catch(async () => {
|
|
401
|
-
failures = Math.min(failures + 1, 6);
|
|
402
|
-
await diagnose('SYNC_RETRY');
|
|
403
|
-
return false;
|
|
404
|
-
}).finally(() => { inFlight = undefined; controller = undefined; });
|
|
405
|
-
return inFlight;
|
|
406
|
-
}
|
|
407
|
-
function schedule(delay) {
|
|
408
|
-
if (stopped || !started || !enabled) return;
|
|
409
|
-
timer = setTimeout(async () => {
|
|
410
|
-
await tick();
|
|
411
|
-
schedule(failures ? Math.min(30_000, Math.max(500, pollMs) * 2 ** failures) : backlog ? fastPollMs : pollMs);
|
|
412
|
-
}, delay);
|
|
413
|
-
timer.unref?.();
|
|
414
|
-
}
|
|
415
|
-
return {
|
|
416
|
-
tick,
|
|
417
|
-
start() { if (!started && enabled) { started = true; schedule(0); } return this; },
|
|
418
|
-
async stop() { stopped = true; clearTimeout(timer); controller?.abort(); await inFlight; },
|
|
419
|
-
};
|
|
420
|
-
}
|