machine-bridge-mcp 0.11.1 → 0.12.0
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/CHANGELOG.md +26 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +5 -5
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +14 -8
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +3 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +12 -4
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +11 -8
- package/package.json +7 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/app-automation.mjs +3 -1
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +165 -81
- package/src/local/daemon-process.mjs +3 -67
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +53 -25
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/worker/index.ts +1 -1
package/src/local/state.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { closeSync, constants as fsConstants, existsSync, fstatSync,
|
|
2
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, chmodSync, realpathSync, rmSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
7
7
|
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
8
|
+
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
9
|
+
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
8
10
|
|
|
9
11
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
10
12
|
export const appName = String(serverMetadata.name);
|
|
@@ -12,6 +14,9 @@ const STATE_MARKER = ".machine-bridge-mcp-state";
|
|
|
12
14
|
const MAX_STATE_JSON_BYTES = 2 * 1024 * 1024;
|
|
13
15
|
const MAX_LOCK_BYTES = 64 * 1024;
|
|
14
16
|
const MAX_MARKER_BYTES = 4096;
|
|
17
|
+
const STARTUP_LOCK_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
18
|
+
const MALFORMED_LOCK_GRACE_MS = 60_000;
|
|
19
|
+
const MAINTENANCE_LOCK_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
15
20
|
|
|
16
21
|
export function expandHome(input = "") {
|
|
17
22
|
if (!input || input === "~") return os.homedir();
|
|
@@ -39,14 +44,19 @@ function configPath(stateRoot = defaultStateRoot()) {
|
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export function loadGlobalConfig(stateRoot = defaultStateRoot()) {
|
|
42
|
-
const
|
|
47
|
+
const root = path.resolve(expandHome(stateRoot));
|
|
48
|
+
assertNoForeignMaintenance(root);
|
|
49
|
+
const file = configPath(root);
|
|
43
50
|
if (!existsSync(file)) return {};
|
|
44
51
|
ownerOnlyFile(file);
|
|
45
52
|
return readJsonObjectOrBackup(file);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
|
|
49
|
-
const
|
|
56
|
+
const requestedRoot = path.resolve(expandHome(stateRoot));
|
|
57
|
+
assertNoForeignMaintenance(requestedRoot);
|
|
58
|
+
const root = ensureStateRoot(requestedRoot);
|
|
59
|
+
assertNoForeignMaintenance(root);
|
|
50
60
|
const file = configPath(root);
|
|
51
61
|
atomicWriteJson(file, { ...config, updatedAt: new Date().toISOString() });
|
|
52
62
|
}
|
|
@@ -91,6 +101,32 @@ function profileDirForWorkspace(workspace, stateRoot = defaultStateRoot()) {
|
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
|
|
104
|
+
|
|
105
|
+
function canonicalizePotentialPath(input) {
|
|
106
|
+
let existing = path.resolve(input);
|
|
107
|
+
const suffix = [];
|
|
108
|
+
while (!existsSync(existing)) {
|
|
109
|
+
const parent = path.dirname(existing);
|
|
110
|
+
if (parent === existing) break;
|
|
111
|
+
suffix.unshift(path.basename(existing));
|
|
112
|
+
existing = parent;
|
|
113
|
+
}
|
|
114
|
+
const canonicalExisting = realpathSync.native ? realpathSync.native(existing) : realpathSync(existing);
|
|
115
|
+
return path.join(canonicalExisting, ...suffix);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function assertStateRootSeparatedFromWorkspace(stateRoot, workspace) {
|
|
119
|
+
const canonicalStateRoot = canonicalizePotentialPath(stateRoot);
|
|
120
|
+
const stateIdentity = process.platform === "win32" ? canonicalStateRoot.toLowerCase() : canonicalStateRoot;
|
|
121
|
+
const workspaceIdentity = process.platform === "win32" ? workspace.toLowerCase() : workspace;
|
|
122
|
+
const relativeWorkspace = path.relative(canonicalStateRoot, workspace);
|
|
123
|
+
const relativeState = path.relative(workspace, canonicalStateRoot);
|
|
124
|
+
const contains = (value) => value === "" || (!value.startsWith(`..${path.sep}`) && value !== ".." && !path.isAbsolute(value));
|
|
125
|
+
if (stateIdentity === workspaceIdentity || contains(relativeWorkspace) || contains(relativeState)) {
|
|
126
|
+
throw new Error("state root and selected workspace must be separate, non-overlapping directories");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
94
130
|
function matchingLegacyProfileDir(canonicalWorkspace, stateRoot) {
|
|
95
131
|
const profilesRoot = path.join(stateRoot, "profiles");
|
|
96
132
|
if (!existsSync(profilesRoot)) return "";
|
|
@@ -123,7 +159,11 @@ function sameWorkspaceIdentity(left, right) {
|
|
|
123
159
|
|
|
124
160
|
export function loadState(workspace, options = {}) {
|
|
125
161
|
const canonicalWorkspace = resolveWorkspace(workspace);
|
|
126
|
-
const
|
|
162
|
+
const requestedStateRoot = path.resolve(options.stateDir ? expandHome(options.stateDir) : defaultStateRoot());
|
|
163
|
+
assertStateRootSeparatedFromWorkspace(requestedStateRoot, canonicalWorkspace);
|
|
164
|
+
assertNoForeignMaintenance(requestedStateRoot);
|
|
165
|
+
const stateRoot = ensureStateRoot(requestedStateRoot);
|
|
166
|
+
assertNoForeignMaintenance(stateRoot);
|
|
127
167
|
const canonicalProfileDir = profileDirForWorkspace(canonicalWorkspace, stateRoot);
|
|
128
168
|
const profileDir = existsSync(canonicalProfileDir)
|
|
129
169
|
? canonicalProfileDir
|
|
@@ -150,6 +190,7 @@ export function loadState(workspace, options = {}) {
|
|
|
150
190
|
}
|
|
151
191
|
|
|
152
192
|
export function saveState(state) {
|
|
193
|
+
assertNoForeignMaintenance(state?.paths?.stateRoot);
|
|
153
194
|
const statePath = state?.paths?.statePath;
|
|
154
195
|
if (!statePath) throw new Error("state path is missing");
|
|
155
196
|
ensureOwnerOnlyDir(path.dirname(statePath));
|
|
@@ -170,18 +211,87 @@ function lockPathForState(state, name) {
|
|
|
170
211
|
return path.join(profileDir, name);
|
|
171
212
|
}
|
|
172
213
|
|
|
214
|
+
export function acquireMaintenanceLock(stateRoot, metadata = {}) {
|
|
215
|
+
const root = path.resolve(expandHome(stateRoot));
|
|
216
|
+
if (!existsSync(root)) throw new Error("cannot acquire maintenance lock for a missing state root");
|
|
217
|
+
const operation = typeof metadata.operation === "string" && /^[a-z][a-z0-9._-]{0,63}$/.test(metadata.operation)
|
|
218
|
+
? metadata.operation
|
|
219
|
+
: "maintenance";
|
|
220
|
+
return acquireProcessLock(path.join(root, "maintenance.lock"), {
|
|
221
|
+
workspace: { path: "" },
|
|
222
|
+
paths: { profileDir: root, stateRoot: root },
|
|
223
|
+
}, "maintenance", { operation }, { maxAgeMs: MAINTENANCE_LOCK_MAX_AGE_MS });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function assertStateMaintenanceAvailable(stateRoot) {
|
|
227
|
+
assertNoForeignMaintenance(stateRoot);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function assertNoForeignMaintenance(stateRoot) {
|
|
231
|
+
if (!stateRoot) return;
|
|
232
|
+
const file = path.join(path.resolve(expandHome(stateRoot)), "maintenance.lock");
|
|
233
|
+
if (!existsSync(file)) return;
|
|
234
|
+
const snapshot = readProcessLockSnapshot(file);
|
|
235
|
+
if (!snapshot) return;
|
|
236
|
+
if (!snapshot.owner) {
|
|
237
|
+
const ageMs = Date.now() - snapshot.info.mtimeMs;
|
|
238
|
+
if (ageMs < MALFORMED_LOCK_GRACE_MS) throw new Error("state maintenance lock is recent but unreadable");
|
|
239
|
+
if (!removeLockSnapshot(file, snapshot)) {
|
|
240
|
+
throw new Error("state maintenance lock changed during inspection; retry after checking the owning process");
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (snapshot.owner.purpose !== "maintenance") throw new Error("state maintenance lock contains mismatched purpose metadata");
|
|
245
|
+
const identity = inspectProcessInstance(snapshot.owner, { maxAgeMs: MAINTENANCE_LOCK_MAX_AGE_MS });
|
|
246
|
+
if (identity.current) {
|
|
247
|
+
if (Number(snapshot.owner.pid) === process.pid) return;
|
|
248
|
+
throw new Error(`state maintenance is active in another process (pid ${snapshot.owner.pid})`);
|
|
249
|
+
}
|
|
250
|
+
if (!identity.reclaimable) throw new Error(`state maintenance lock cannot be verified safely (${identity.reason})`);
|
|
251
|
+
if (!removeLockSnapshot(file, snapshot)) {
|
|
252
|
+
throw new Error("state maintenance lock changed during inspection; retry after checking the owning process");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
173
256
|
export function acquireDaemonLock(state, metadata = {}) {
|
|
257
|
+
assertNoForeignMaintenance(state?.paths?.stateRoot);
|
|
174
258
|
const details = {};
|
|
175
259
|
if (metadata?.mode === "foreground" || metadata?.mode === "service") details.mode = metadata.mode;
|
|
176
260
|
if (typeof metadata?.version === "string" && /^[0-9A-Za-z.+_-]{1,64}$/.test(metadata.version)) details.version = metadata.version;
|
|
177
|
-
return acquireProcessLock(daemonLockPathForState(state), state, "daemon", details);
|
|
261
|
+
return acquireProcessLock(daemonLockPathForState(state), state, "daemon", details, { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
178
262
|
}
|
|
179
263
|
|
|
180
|
-
export function acquireStartupLock(state) {
|
|
181
|
-
|
|
264
|
+
export function acquireStartupLock(state, metadata = {}) {
|
|
265
|
+
assertNoForeignMaintenance(state?.paths?.stateRoot);
|
|
266
|
+
const details = {};
|
|
267
|
+
if (typeof metadata?.operation === "string" && /^[a-z][a-z0-9._-]{0,63}$/.test(metadata.operation)) details.operation = metadata.operation;
|
|
268
|
+
return acquireProcessLock(startupLockPathForState(state), state, "startup", details, { maxAgeMs: STARTUP_LOCK_MAX_AGE_MS });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function acquireStartupLockWithWait(state, options = {}) {
|
|
272
|
+
const timeoutMs = boundedPositiveInteger(options.timeoutMs, 30_000);
|
|
273
|
+
const pollMs = boundedPositiveInteger(options.pollMs, 100);
|
|
274
|
+
const logger = options.logger || { info() {} };
|
|
275
|
+
const metadata = { operation: options.operation };
|
|
276
|
+
let lock = acquireStartupLock(state, metadata);
|
|
277
|
+
if (lock.acquired) return lock;
|
|
278
|
+
const ownerPid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "another process";
|
|
279
|
+
logger.info?.(`waiting for ${ownerPid} to finish the current startup/state operation`);
|
|
280
|
+
const deadline = Date.now() + timeoutMs;
|
|
281
|
+
while (Date.now() < deadline) {
|
|
282
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, Math.min(pollMs, Math.max(1, deadline - Date.now()))));
|
|
283
|
+
lock = acquireStartupLock(state, metadata);
|
|
284
|
+
if (lock.acquired) {
|
|
285
|
+
logger.info?.("the previous startup/state operation finished; continuing");
|
|
286
|
+
return lock;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
|
|
290
|
+
const operation = typeof lock.owner?.operation === "string" ? ` (${lock.owner.operation})` : "";
|
|
291
|
+
throw new Error(`another startup/state operation did not finish within ${Math.ceil(timeoutMs / 1000)} seconds (${pid}${operation}); inspect the process before retrying`);
|
|
182
292
|
}
|
|
183
293
|
|
|
184
|
-
function acquireProcessLock(lockPath, state, purpose, details = {}) {
|
|
294
|
+
function acquireProcessLock(lockPath, state, purpose, details = {}, options = {}) {
|
|
185
295
|
ensureOwnerOnlyDir(path.dirname(lockPath));
|
|
186
296
|
const token = randomBytes(16).toString("hex");
|
|
187
297
|
const payload = {
|
|
@@ -190,18 +300,15 @@ function acquireProcessLock(lockPath, state, purpose, details = {}) {
|
|
|
190
300
|
purpose,
|
|
191
301
|
workspace: state?.workspace?.path || "",
|
|
192
302
|
startedAt: new Date().toISOString(),
|
|
303
|
+
processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
|
|
193
304
|
entryScript: process.argv[1] || "",
|
|
194
305
|
...details,
|
|
195
306
|
};
|
|
196
307
|
|
|
197
|
-
for (let attempt = 0; attempt <
|
|
198
|
-
let fd;
|
|
308
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
199
309
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
fsyncSync(fd);
|
|
203
|
-
closeSync(fd);
|
|
204
|
-
fd = undefined;
|
|
310
|
+
createExclusiveFileSync(lockPath, `${JSON.stringify(payload, null, 2)}
|
|
311
|
+
`, { mode: 0o600 });
|
|
205
312
|
ownerOnlyFile(lockPath);
|
|
206
313
|
return {
|
|
207
314
|
acquired: true,
|
|
@@ -210,25 +317,85 @@ function acquireProcessLock(lockPath, state, purpose, details = {}) {
|
|
|
210
317
|
release() { releaseProcessLock(lockPath, token); },
|
|
211
318
|
};
|
|
212
319
|
} catch (error) {
|
|
213
|
-
if (fd !== undefined) {
|
|
214
|
-
try { closeSync(fd); } catch {}
|
|
215
|
-
}
|
|
216
320
|
if (error?.code !== "EEXIST") throw error;
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
|
|
321
|
+
const snapshot = readProcessLockSnapshot(lockPath);
|
|
322
|
+
if (!snapshot) continue;
|
|
323
|
+
if (!snapshot.owner) {
|
|
324
|
+
const ageMs = Date.now() - snapshot.info.mtimeMs;
|
|
325
|
+
if (ageMs < MALFORMED_LOCK_GRACE_MS) {
|
|
326
|
+
return { acquired: false, path: lockPath, owner: null, reason: "recent_invalid_lock", release() {} };
|
|
327
|
+
}
|
|
328
|
+
if (!removeLockSnapshot(lockPath, snapshot)) continue;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (snapshot.owner.purpose !== purpose) throw new Error(`${purpose} lock contains mismatched purpose metadata`);
|
|
332
|
+
if (snapshot.owner.workspace && state?.workspace?.path && !sameWorkspaceIdentity(snapshot.owner.workspace, state.workspace.path)) {
|
|
333
|
+
throw new Error(`${purpose} lock belongs to a different workspace`);
|
|
334
|
+
}
|
|
335
|
+
const identity = inspectProcessInstance(snapshot.owner, { maxAgeMs: options.maxAgeMs });
|
|
336
|
+
if (identity.current || !identity.reclaimable) {
|
|
337
|
+
return { acquired: false, path: lockPath, owner: snapshot.owner, reason: identity.reason, release() {} };
|
|
220
338
|
}
|
|
221
|
-
|
|
339
|
+
if (!removeLockSnapshot(lockPath, snapshot)) continue;
|
|
222
340
|
}
|
|
223
341
|
}
|
|
224
342
|
const owner = readDaemonLockOwner(lockPath);
|
|
225
343
|
return { acquired: false, path: lockPath, owner, release() {} };
|
|
226
344
|
}
|
|
227
345
|
|
|
346
|
+
function readProcessLockSnapshot(lockPath) {
|
|
347
|
+
let info;
|
|
348
|
+
try { info = lstatSync(lockPath); } catch (error) {
|
|
349
|
+
if (error?.code === "ENOENT") return null;
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
if (info.isSymbolicLink()) throw new Error(`process lock must not be a symbolic link: ${lockPath}`);
|
|
353
|
+
if (!info.isFile()) throw new Error(`process lock is not a regular file: ${lockPath}`);
|
|
354
|
+
let owner = null;
|
|
355
|
+
try {
|
|
356
|
+
const parsed = JSON.parse(readBoundedUtf8(lockPath, MAX_LOCK_BYTES, "lock file"));
|
|
357
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error?.code === "ENOENT") return null;
|
|
360
|
+
}
|
|
361
|
+
return { owner, info: lockIdentity(info) };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function removeLockSnapshot(lockPath, snapshot) {
|
|
365
|
+
let current;
|
|
366
|
+
try { current = lstatSync(lockPath); } catch (error) { return error?.code === "ENOENT"; }
|
|
367
|
+
if (current.isSymbolicLink() || !current.isFile()) return false;
|
|
368
|
+
if (!sameLockIdentity(snapshot.info, lockIdentity(current))) return false;
|
|
369
|
+
if (snapshot.owner?.token) {
|
|
370
|
+
const currentOwner = readDaemonLockOwner(lockPath);
|
|
371
|
+
if (currentOwner?.token !== snapshot.owner.token) return false;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
unlinkSync(lockPath);
|
|
375
|
+
return true;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
return error?.code === "ENOENT";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function lockIdentity(info) {
|
|
382
|
+
return {
|
|
383
|
+
dev: Number(info.dev),
|
|
384
|
+
ino: Number(info.ino),
|
|
385
|
+
size: Number(info.size),
|
|
386
|
+
mtimeMs: Number(info.mtimeMs),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function sameLockIdentity(left, right) {
|
|
391
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
|
392
|
+
}
|
|
393
|
+
|
|
228
394
|
function releaseProcessLock(lockPath, token) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
395
|
+
let snapshot;
|
|
396
|
+
try { snapshot = readProcessLockSnapshot(lockPath); } catch { return; }
|
|
397
|
+
if (!snapshot || snapshot.owner?.token !== token) return;
|
|
398
|
+
removeLockSnapshot(lockPath, snapshot);
|
|
232
399
|
}
|
|
233
400
|
|
|
234
401
|
export function readDaemonLockOwner(lockPath) {
|
|
@@ -240,15 +407,9 @@ export function readDaemonLockOwner(lockPath) {
|
|
|
240
407
|
}
|
|
241
408
|
}
|
|
242
409
|
|
|
243
|
-
function
|
|
244
|
-
const parsed = Number(
|
|
245
|
-
|
|
246
|
-
try {
|
|
247
|
-
process.kill(parsed, 0);
|
|
248
|
-
return true;
|
|
249
|
-
} catch (error) {
|
|
250
|
-
return error?.code === "EPERM";
|
|
251
|
-
}
|
|
410
|
+
function boundedPositiveInteger(value, fallback) {
|
|
411
|
+
const parsed = Number(value);
|
|
412
|
+
return Number.isFinite(parsed) ? Math.max(1, Math.floor(parsed)) : fallback;
|
|
252
413
|
}
|
|
253
414
|
|
|
254
415
|
function readBoundedUtf8(filePath, maxBytes, label) {
|
|
@@ -267,24 +428,28 @@ function readBoundedUtf8(filePath, maxBytes, label) {
|
|
|
267
428
|
if (count === 0) break;
|
|
268
429
|
offset += count;
|
|
269
430
|
}
|
|
270
|
-
|
|
431
|
+
try {
|
|
432
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
|
433
|
+
} catch {
|
|
434
|
+
throw new Error(`${label} is not valid UTF-8`);
|
|
435
|
+
}
|
|
271
436
|
} finally {
|
|
272
437
|
closeSync(fd);
|
|
273
438
|
}
|
|
274
439
|
}
|
|
275
440
|
|
|
276
441
|
function readJsonObjectOrBackup(filePath) {
|
|
442
|
+
const text = readBoundedUtf8(filePath, MAX_STATE_JSON_BYTES, "state JSON");
|
|
443
|
+
let parsed;
|
|
277
444
|
try {
|
|
278
|
-
|
|
445
|
+
parsed = JSON.parse(text);
|
|
279
446
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("JSON root must be an object");
|
|
280
447
|
return parsed;
|
|
281
448
|
} catch {
|
|
282
449
|
const backupPath = `${filePath}.corrupt-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
pruneBackups(filePath, 3);
|
|
287
|
-
} catch {}
|
|
450
|
+
replaceFileSync(filePath, backupPath);
|
|
451
|
+
ownerOnlyFile(backupPath);
|
|
452
|
+
pruneBackups(filePath, 3);
|
|
288
453
|
return {};
|
|
289
454
|
}
|
|
290
455
|
}
|
|
@@ -310,7 +475,7 @@ function ensureStateRoot(inputRoot) {
|
|
|
310
475
|
}
|
|
311
476
|
}
|
|
312
477
|
try {
|
|
313
|
-
|
|
478
|
+
createExclusiveFileSync(marker, `${JSON.stringify({ app: appName, schema: 1 })}\n`, { mode: 0o600 });
|
|
314
479
|
} catch (error) {
|
|
315
480
|
if (error?.code !== "EEXIST") throw error;
|
|
316
481
|
assertValidStateMarker(marker, { migrateLegacy: true });
|
|
@@ -365,7 +530,7 @@ function assertValidStateMarker(marker, options = {}) {
|
|
|
365
530
|
}
|
|
366
531
|
|
|
367
532
|
function hasOnlyStateEntries(entries) {
|
|
368
|
-
const allowed = new Set([STATE_MARKER, "config.json", "browser-bridge.json", "profiles", "logs"]);
|
|
533
|
+
const allowed = new Set([STATE_MARKER, "config.json", "browser-bridge.json", "maintenance.lock", "profiles", "logs"]);
|
|
369
534
|
return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+(?:-[a-f0-9]{8})?$/.test(entry));
|
|
370
535
|
}
|
|
371
536
|
|
|
@@ -374,16 +539,28 @@ function looksLikeSourceTree(root) {
|
|
|
374
539
|
}
|
|
375
540
|
|
|
376
541
|
function stateRootMatchesRecordedWorkspace(root) {
|
|
542
|
+
const config = path.join(root, "config.json");
|
|
543
|
+
if (existsSync(config)) {
|
|
544
|
+
try {
|
|
545
|
+
const value = JSON.parse(readBoundedUtf8(config, MAX_STATE_JSON_BYTES, "config JSON"));
|
|
546
|
+
if (typeof value?.selectedWorkspace === "string" && sameWorkspaceIdentity(value.selectedWorkspace, root)) return true;
|
|
547
|
+
} catch {}
|
|
548
|
+
}
|
|
377
549
|
const profiles = path.join(root, "profiles");
|
|
378
550
|
if (!existsSync(profiles)) return false;
|
|
379
551
|
try {
|
|
380
552
|
for (const entry of readdirSync(profiles, { withFileTypes: true })) {
|
|
381
553
|
if (!entry.isDirectory()) continue;
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
554
|
+
const profileDir = path.join(profiles, entry.name);
|
|
555
|
+
const stateFile = path.join(profileDir, "state.json");
|
|
556
|
+
if (existsSync(stateFile)) {
|
|
557
|
+
try {
|
|
558
|
+
const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
|
|
559
|
+
if (typeof value?.workspace?.path === "string" && sameWorkspaceIdentity(value.workspace.path, root)) return true;
|
|
560
|
+
} catch {}
|
|
561
|
+
}
|
|
562
|
+
const owner = readDaemonLockOwner(path.join(profileDir, "daemon.lock"));
|
|
563
|
+
if (typeof owner?.workspace === "string" && sameWorkspaceIdentity(owner.workspace, root)) return true;
|
|
387
564
|
}
|
|
388
565
|
} catch {}
|
|
389
566
|
return false;
|
|
@@ -420,25 +597,10 @@ function atomicWriteJson(filePath, value) {
|
|
|
420
597
|
const dir = path.dirname(filePath);
|
|
421
598
|
ensureOwnerOnlyDir(dir);
|
|
422
599
|
cleanupStaleAtomicTemps(dir, path.basename(filePath));
|
|
423
|
-
const serialized = `${JSON.stringify(value, null, 2)}
|
|
600
|
+
const serialized = `${JSON.stringify(value, null, 2)}
|
|
601
|
+
`;
|
|
424
602
|
if (Buffer.byteLength(serialized) > MAX_STATE_JSON_BYTES) throw new Error(`state JSON exceeds ${MAX_STATE_JSON_BYTES} bytes`);
|
|
425
|
-
|
|
426
|
-
let fd;
|
|
427
|
-
try {
|
|
428
|
-
fd = openSync(tempPath, "wx", 0o600);
|
|
429
|
-
writeFileSync(fd, serialized, "utf8");
|
|
430
|
-
fsyncSync(fd);
|
|
431
|
-
closeSync(fd);
|
|
432
|
-
fd = undefined;
|
|
433
|
-
replaceFileSync(tempPath, filePath);
|
|
434
|
-
ownerOnlyFile(filePath);
|
|
435
|
-
} catch (error) {
|
|
436
|
-
if (fd !== undefined) {
|
|
437
|
-
try { closeSync(fd); } catch {}
|
|
438
|
-
}
|
|
439
|
-
try { unlinkSync(tempPath); } catch {}
|
|
440
|
-
throw error;
|
|
441
|
-
}
|
|
603
|
+
replaceFileAtomicallySync(filePath, serialized, { mode: 0o600 });
|
|
442
604
|
}
|
|
443
605
|
|
|
444
606
|
function cleanupStaleAtomicTemps(dir, baseName = "") {
|
package/src/worker/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import toolCatalog from "../shared/tool-catalog.json";
|
|
|
3
3
|
import serverMetadata from "../shared/server-metadata.json";
|
|
4
4
|
|
|
5
5
|
const SERVER_NAME = String(serverMetadata.name);
|
|
6
|
-
const SERVER_VERSION = "0.
|
|
6
|
+
const SERVER_VERSION = "0.12.0";
|
|
7
7
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
8
8
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
9
9
|
const JSONRPC_VERSION = "2.0";
|