machine-bridge-mcp 0.2.4 → 0.3.3
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 +60 -0
- package/README.md +148 -176
- package/SECURITY.md +84 -0
- package/docs/ARCHITECTURE.md +102 -0
- package/package.json +20 -9
- package/src/local/cli.mjs +289 -298
- package/src/local/daemon.mjs +376 -84
- package/src/local/log.mjs +33 -11
- package/src/local/self-test.mjs +173 -186
- package/src/local/service.mjs +43 -5
- package/src/local/shell.mjs +68 -14
- package/src/local/state.mjs +240 -51
- package/src/worker/index.ts +664 -424
- package/wrangler.jsonc +2 -2
- package/src/local/api-server.mjs +0 -368
package/src/local/shell.mjs
CHANGED
|
@@ -5,44 +5,98 @@ import { packageRoot } from "./state.mjs";
|
|
|
5
5
|
|
|
6
6
|
export function run(command, args = [], options = {}) {
|
|
7
7
|
return new Promise((resolve, reject) => {
|
|
8
|
+
const capture = Boolean(options.capture);
|
|
9
|
+
const maxOutputBytes = Number.isFinite(Number(options.maxOutputBytes)) ? Math.max(1024, Number(options.maxOutputBytes)) : 2 * 1024 * 1024;
|
|
8
10
|
const child = spawn(command, args, {
|
|
9
11
|
cwd: options.cwd || process.cwd(),
|
|
10
12
|
env: options.env || process.env,
|
|
11
|
-
stdio:
|
|
13
|
+
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
12
14
|
shell: false,
|
|
15
|
+
windowsHide: true,
|
|
13
16
|
});
|
|
14
17
|
let stdout = "";
|
|
15
18
|
let stderr = "";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
let stdoutTruncated = 0;
|
|
20
|
+
let stderrTruncated = 0;
|
|
21
|
+
let settled = false;
|
|
22
|
+
let timedOut = false;
|
|
23
|
+
let timer = null;
|
|
24
|
+
let killTimer = null;
|
|
25
|
+
const timeoutMs = Number(options.timeoutMs);
|
|
26
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
timedOut = true;
|
|
29
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
30
|
+
killTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 2000);
|
|
31
|
+
killTimer.unref?.();
|
|
32
|
+
}, timeoutMs);
|
|
33
|
+
timer.unref?.();
|
|
19
34
|
}
|
|
20
|
-
|
|
21
|
-
if (
|
|
35
|
+
const finish = callback => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
38
|
+
if (timer) clearTimeout(timer);
|
|
39
|
+
if (killTimer) clearTimeout(killTimer);
|
|
40
|
+
callback();
|
|
41
|
+
};
|
|
42
|
+
if (capture) {
|
|
43
|
+
child.stdout?.on("data", chunk => {
|
|
44
|
+
const next = appendLimited(stdout, chunk, maxOutputBytes);
|
|
45
|
+
stdout = next.value;
|
|
46
|
+
stdoutTruncated += next.truncated;
|
|
47
|
+
});
|
|
48
|
+
child.stderr?.on("data", chunk => {
|
|
49
|
+
const next = appendLimited(stderr, chunk, maxOutputBytes);
|
|
50
|
+
stderr = next.value;
|
|
51
|
+
stderrTruncated += next.truncated;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
child.on("error", error => finish(() => {
|
|
55
|
+
const result = { code: 127, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: finalizeOutput(error.message || stderr, stderrTruncated) };
|
|
56
|
+
if (options.allowFailure) resolve(result);
|
|
22
57
|
else reject(error);
|
|
23
|
-
});
|
|
24
|
-
child.on("close", code => {
|
|
25
|
-
const
|
|
26
|
-
|
|
58
|
+
}));
|
|
59
|
+
child.on("close", code => finish(() => {
|
|
60
|
+
const timeoutMessage = timedOut ? `command timed out after ${timeoutMs}ms` : "";
|
|
61
|
+
const result = {
|
|
62
|
+
code: timedOut ? 124 : code,
|
|
63
|
+
stdout: finalizeOutput(stdout, stdoutTruncated),
|
|
64
|
+
stderr: finalizeOutput([stderr, timeoutMessage].filter(Boolean).join("\n"), stderrTruncated),
|
|
65
|
+
};
|
|
66
|
+
if ((!timedOut && code === 0) || options.allowFailure) resolve(result);
|
|
27
67
|
else {
|
|
28
|
-
const error = new Error((stderr || stdout || `${command} exited ${code}`).trim());
|
|
68
|
+
const error = new Error((result.stderr || result.stdout || `${command} exited ${result.code}`).trim());
|
|
29
69
|
error.result = result;
|
|
30
70
|
reject(error);
|
|
31
71
|
}
|
|
32
|
-
});
|
|
72
|
+
}));
|
|
33
73
|
});
|
|
34
74
|
}
|
|
35
75
|
|
|
76
|
+
function appendLimited(current, chunk, max) {
|
|
77
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
|
|
78
|
+
const budget = Math.max(0, max - Buffer.byteLength(current));
|
|
79
|
+
if (buffer.length <= budget) return { value: current + buffer.toString("utf8"), truncated: 0 };
|
|
80
|
+
const slice = buffer.subarray(0, budget).toString("utf8");
|
|
81
|
+
return { value: current + slice, truncated: buffer.length - Buffer.byteLength(slice) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function finalizeOutput(value, truncated) {
|
|
85
|
+
return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
|
|
86
|
+
}
|
|
87
|
+
|
|
36
88
|
export function findWranglerCommand() {
|
|
37
89
|
const suffix = process.platform === "win32" ? ".cmd" : "";
|
|
38
90
|
const local = path.join(packageRoot, "node_modules", ".bin", `wrangler${suffix}`);
|
|
39
91
|
if (existsSync(local)) return { cmd: local, argsPrefix: [] };
|
|
40
|
-
|
|
92
|
+
throw new Error("Wrangler dependency is not installed. Run `npm install` in the package/source directory and retry.");
|
|
41
93
|
}
|
|
42
94
|
|
|
43
95
|
export async function runWrangler(args, options = {}) {
|
|
44
96
|
const wrangler = findWranglerCommand();
|
|
45
|
-
|
|
97
|
+
const operation = String(args[0] || "");
|
|
98
|
+
const timeoutMs = options.timeoutMs ?? (operation === "login" || operation === "deploy" ? 10 * 60 * 1000 : 2 * 60 * 1000);
|
|
99
|
+
return run(wrangler.cmd, [...wrangler.argsPrefix, ...args], { cwd: packageRoot, timeoutMs, ...options });
|
|
46
100
|
}
|
|
47
101
|
|
|
48
102
|
export function workspaceShellCommand(command) {
|
package/src/local/state.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, chmodSync, realpathSync, rmSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, 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
|
|
|
7
7
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
8
8
|
export const appName = "machine-bridge-mcp";
|
|
9
|
+
const STATE_MARKER = ".machine-bridge-mcp-state";
|
|
9
10
|
|
|
10
11
|
export function expandHome(input = "") {
|
|
11
12
|
if (!input || input === "~") return os.homedir();
|
|
@@ -35,20 +36,14 @@ export function configPath(stateRoot = defaultStateRoot()) {
|
|
|
35
36
|
export function loadGlobalConfig(stateRoot = defaultStateRoot()) {
|
|
36
37
|
const file = configPath(stateRoot);
|
|
37
38
|
if (!existsSync(file)) return {};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
41
|
-
} catch {
|
|
42
|
-
return {};
|
|
43
|
-
}
|
|
39
|
+
ownerOnlyFile(file);
|
|
40
|
+
return readJsonObjectOrBackup(file);
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
|
|
47
|
-
const root = expandHome(stateRoot);
|
|
48
|
-
ensureOwnerOnlyDir(root);
|
|
44
|
+
const root = ensureStateRoot(expandHome(stateRoot));
|
|
49
45
|
const file = configPath(root);
|
|
50
|
-
|
|
51
|
-
ownerOnlyFile(file);
|
|
46
|
+
atomicWriteJson(file, { ...config, updatedAt: new Date().toISOString() });
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
export function setSelectedWorkspace(workspace, stateRoot = defaultStateRoot()) {
|
|
@@ -65,9 +60,18 @@ export function selectedWorkspace(stateRoot = defaultStateRoot()) {
|
|
|
65
60
|
return typeof value === "string" && value.trim() ? value : "";
|
|
66
61
|
}
|
|
67
62
|
|
|
63
|
+
export function validateStateRootForRemoval(stateRoot = defaultStateRoot()) {
|
|
64
|
+
const root = path.resolve(expandHome(stateRoot));
|
|
65
|
+
if (!existsSync(root)) return { exists: false, root };
|
|
66
|
+
const canonical = assertSafeStateRootForRemoval(root);
|
|
67
|
+
return { exists: true, root: canonical };
|
|
68
|
+
}
|
|
69
|
+
|
|
68
70
|
export function removeStateRoot(stateRoot = defaultStateRoot()) {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
+
const validation = validateStateRootForRemoval(stateRoot);
|
|
72
|
+
if (!validation.exists) return false;
|
|
73
|
+
rmSync(validation.root, { recursive: true, force: true });
|
|
74
|
+
return true;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
export function workspaceHash(workspace) {
|
|
@@ -83,15 +87,16 @@ export function statePathForWorkspace(workspace, stateRoot = defaultStateRoot())
|
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
export function loadState(workspace, options = {}) {
|
|
86
|
-
const stateRoot = options.stateDir ? expandHome(options.stateDir) : defaultStateRoot();
|
|
90
|
+
const stateRoot = ensureStateRoot(options.stateDir ? expandHome(options.stateDir) : defaultStateRoot());
|
|
87
91
|
const profileDir = profileDirForWorkspace(workspace, stateRoot);
|
|
88
92
|
const statePath = path.join(profileDir, "state.json");
|
|
89
93
|
ensureOwnerOnlyDir(profileDir);
|
|
90
94
|
let state = {};
|
|
91
95
|
if (existsSync(statePath)) {
|
|
96
|
+
ownerOnlyFile(statePath);
|
|
92
97
|
state = readJsonObjectOrBackup(statePath);
|
|
93
98
|
}
|
|
94
|
-
state.schemaVersion =
|
|
99
|
+
state.schemaVersion = 2;
|
|
95
100
|
state.workspace = {
|
|
96
101
|
path: workspace,
|
|
97
102
|
hash: workspaceHash(workspace),
|
|
@@ -100,39 +105,46 @@ export function loadState(workspace, options = {}) {
|
|
|
100
105
|
state.paths = { stateRoot, profileDir, statePath };
|
|
101
106
|
state.worker ||= {};
|
|
102
107
|
state.policy ||= {};
|
|
103
|
-
|
|
108
|
+
delete state.localApi;
|
|
104
109
|
return state;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
function migrateLocalApiState(state) {
|
|
108
|
-
if (!state.localApi || typeof state.localApi !== "object" || Array.isArray(state.localApi)) return;
|
|
109
|
-
delete state.localApi.upstreamUrl;
|
|
110
|
-
delete state.localApi.upstreamKey;
|
|
111
|
-
delete state.localApi.upstreamModel;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
112
|
export function saveState(state) {
|
|
115
113
|
const statePath = state?.paths?.statePath;
|
|
116
114
|
if (!statePath) throw new Error("state path is missing");
|
|
117
115
|
ensureOwnerOnlyDir(path.dirname(statePath));
|
|
118
|
-
|
|
119
|
-
writeFileSync(statePath, `${JSON.stringify(serializable, null, 2)}\n`, { mode: 0o600 });
|
|
120
|
-
ownerOnlyFile(statePath);
|
|
116
|
+
atomicWriteJson(statePath, { ...state });
|
|
121
117
|
}
|
|
122
118
|
|
|
123
119
|
export function daemonLockPathForState(state) {
|
|
120
|
+
return lockPathForState(state, "daemon.lock");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function startupLockPathForState(state) {
|
|
124
|
+
return lockPathForState(state, "startup.lock");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function lockPathForState(state, name) {
|
|
124
128
|
const profileDir = state?.paths?.profileDir;
|
|
125
129
|
if (!profileDir) throw new Error("state profile dir is missing");
|
|
126
|
-
return path.join(profileDir,
|
|
130
|
+
return path.join(profileDir, name);
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
export function acquireDaemonLock(state) {
|
|
130
|
-
|
|
134
|
+
return acquireProcessLock(daemonLockPathForState(state), state, "daemon");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function acquireStartupLock(state) {
|
|
138
|
+
return acquireProcessLock(startupLockPathForState(state), state, "startup");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function acquireProcessLock(lockPath, state, purpose) {
|
|
131
142
|
ensureOwnerOnlyDir(path.dirname(lockPath));
|
|
132
143
|
const token = randomBytes(16).toString("hex");
|
|
133
144
|
const payload = {
|
|
134
145
|
pid: process.pid,
|
|
135
146
|
token,
|
|
147
|
+
purpose,
|
|
136
148
|
workspace: state?.workspace?.path || "",
|
|
137
149
|
startedAt: new Date().toISOString(),
|
|
138
150
|
entryScript: process.argv[1] || "",
|
|
@@ -143,15 +155,15 @@ export function acquireDaemonLock(state) {
|
|
|
143
155
|
try {
|
|
144
156
|
fd = openSync(lockPath, "wx", 0o600);
|
|
145
157
|
writeFileSync(fd, JSON.stringify(payload, null, 2) + "\n");
|
|
158
|
+
fsyncSync(fd);
|
|
146
159
|
closeSync(fd);
|
|
160
|
+
fd = undefined;
|
|
147
161
|
ownerOnlyFile(lockPath);
|
|
148
162
|
return {
|
|
149
163
|
acquired: true,
|
|
150
164
|
path: lockPath,
|
|
151
165
|
owner: payload,
|
|
152
|
-
release() {
|
|
153
|
-
releaseDaemonLock(lockPath, token);
|
|
154
|
-
},
|
|
166
|
+
release() { releaseProcessLock(lockPath, token); },
|
|
155
167
|
};
|
|
156
168
|
} catch (error) {
|
|
157
169
|
if (fd !== undefined) {
|
|
@@ -169,7 +181,7 @@ export function acquireDaemonLock(state) {
|
|
|
169
181
|
return { acquired: false, path: lockPath, owner, release() {} };
|
|
170
182
|
}
|
|
171
183
|
|
|
172
|
-
function
|
|
184
|
+
function releaseProcessLock(lockPath, token) {
|
|
173
185
|
const owner = readDaemonLockOwner(lockPath);
|
|
174
186
|
if (owner?.token !== token) return;
|
|
175
187
|
try { unlinkSync(lockPath); } catch {}
|
|
@@ -198,14 +210,199 @@ function isPidAlive(pid) {
|
|
|
198
210
|
function readJsonObjectOrBackup(filePath) {
|
|
199
211
|
try {
|
|
200
212
|
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
201
|
-
|
|
213
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("JSON root must be an object");
|
|
214
|
+
return parsed;
|
|
202
215
|
} catch {
|
|
203
216
|
const backupPath = `${filePath}.corrupt-${Date.now()}`;
|
|
204
|
-
try {
|
|
217
|
+
try {
|
|
218
|
+
renameSync(filePath, backupPath);
|
|
219
|
+
ownerOnlyFile(backupPath);
|
|
220
|
+
pruneBackups(filePath, 3);
|
|
221
|
+
} catch {}
|
|
205
222
|
return {};
|
|
206
223
|
}
|
|
207
224
|
}
|
|
208
225
|
|
|
226
|
+
function ensureStateRoot(inputRoot) {
|
|
227
|
+
const root = path.resolve(expandHome(inputRoot));
|
|
228
|
+
if (existsSync(root)) {
|
|
229
|
+
const info = lstatSync(root);
|
|
230
|
+
if (info.isSymbolicLink()) throw new Error(`state root must not be a symbolic link: ${root}`);
|
|
231
|
+
if (!info.isDirectory()) throw new Error(`state root is not a directory: ${root}`);
|
|
232
|
+
} else {
|
|
233
|
+
ensureOwnerOnlyDir(root);
|
|
234
|
+
}
|
|
235
|
+
const marker = path.join(root, STATE_MARKER);
|
|
236
|
+
if (!existsSync(marker)) {
|
|
237
|
+
const entries = readdirSync(root);
|
|
238
|
+
if (entries.length) {
|
|
239
|
+
if (!hasOnlyStateEntries(entries)) {
|
|
240
|
+
throw new Error(`state root must be a dedicated directory; unexpected entries found in ${root}`);
|
|
241
|
+
}
|
|
242
|
+
if (!isRecognizableLegacyStateRoot(root)) {
|
|
243
|
+
throw new Error(`state root is non-empty but does not contain recognizable Machine Bridge state: ${root}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
writeFileSync(marker, `${JSON.stringify({ app: appName, schema: 1 })}\n`, { mode: 0o600, flag: "wx" });
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error?.code !== "EEXIST") throw error;
|
|
250
|
+
assertValidStateMarker(marker, { migrateLegacy: true });
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
assertValidStateMarker(marker, { migrateLegacy: true });
|
|
254
|
+
}
|
|
255
|
+
ensureOwnerOnlyDir(root);
|
|
256
|
+
ownerOnlyFile(marker);
|
|
257
|
+
cleanupStaleAtomicTemps(root);
|
|
258
|
+
return realpathSync(root);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function assertSafeStateRootForRemoval(root) {
|
|
262
|
+
const info = lstatSync(root);
|
|
263
|
+
if (info.isSymbolicLink()) throw new Error(`refusing to remove symbolic-link state root: ${root}`);
|
|
264
|
+
const canonical = realpathSync(root);
|
|
265
|
+
const forbidden = new Set([
|
|
266
|
+
path.parse(canonical).root,
|
|
267
|
+
path.resolve(os.homedir()),
|
|
268
|
+
path.resolve(process.cwd()),
|
|
269
|
+
path.resolve(packageRoot),
|
|
270
|
+
]);
|
|
271
|
+
if (forbidden.has(canonical)) throw new Error(`refusing to remove unsafe state root: ${canonical}`);
|
|
272
|
+
if (looksLikeSourceTree(canonical) || stateRootMatchesRecordedWorkspace(canonical)) {
|
|
273
|
+
throw new Error(`refusing to remove state root that appears to be a workspace: ${canonical}`);
|
|
274
|
+
}
|
|
275
|
+
const entries = readdirSync(canonical);
|
|
276
|
+
if (!hasOnlyStateEntries(entries)) {
|
|
277
|
+
throw new Error(`refusing to remove state root containing unrelated entries: ${canonical}`);
|
|
278
|
+
}
|
|
279
|
+
const marker = path.join(canonical, STATE_MARKER);
|
|
280
|
+
if (existsSync(marker)) {
|
|
281
|
+
assertValidStateMarker(marker);
|
|
282
|
+
return canonical;
|
|
283
|
+
}
|
|
284
|
+
if (isRecognizableLegacyStateRoot(canonical)) return canonical;
|
|
285
|
+
throw new Error(`refusing to remove unrecognized state root without ${STATE_MARKER}: ${canonical}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertValidStateMarker(marker, options = {}) {
|
|
289
|
+
const content = readFileSync(marker, "utf8");
|
|
290
|
+
try {
|
|
291
|
+
const value = JSON.parse(content);
|
|
292
|
+
if (value?.app === appName && value?.schema === 1) return;
|
|
293
|
+
} catch {}
|
|
294
|
+
if (content.trim() === `${appName} state root`) {
|
|
295
|
+
if (options.migrateLegacy) atomicWriteJson(marker, { app: appName, schema: 1 });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`invalid state root marker: ${marker}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function hasOnlyStateEntries(entries) {
|
|
302
|
+
const allowed = new Set([STATE_MARKER, "config.json", "profiles", "logs"]);
|
|
303
|
+
return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+$/.test(entry));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function looksLikeSourceTree(root) {
|
|
307
|
+
return [".git", ".hg", ".svn", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"].some((name) => existsSync(path.join(root, name)));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function stateRootMatchesRecordedWorkspace(root) {
|
|
311
|
+
const profiles = path.join(root, "profiles");
|
|
312
|
+
if (!existsSync(profiles)) return false;
|
|
313
|
+
try {
|
|
314
|
+
for (const entry of readdirSync(profiles, { withFileTypes: true })) {
|
|
315
|
+
if (!entry.isDirectory()) continue;
|
|
316
|
+
const stateFile = path.join(profiles, entry.name, "state.json");
|
|
317
|
+
if (!existsSync(stateFile)) continue;
|
|
318
|
+
const value = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
319
|
+
if (typeof value?.workspace?.path !== "string") continue;
|
|
320
|
+
try { if (realpathSync(value.workspace.path) === root) return true; } catch {}
|
|
321
|
+
}
|
|
322
|
+
} catch {}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isRecognizableLegacyStateRoot(root) {
|
|
327
|
+
const config = path.join(root, "config.json");
|
|
328
|
+
try {
|
|
329
|
+
if (existsSync(config)) {
|
|
330
|
+
const value = JSON.parse(readFileSync(config, "utf8"));
|
|
331
|
+
if (
|
|
332
|
+
value &&
|
|
333
|
+
typeof value.selectedWorkspace === "string" &&
|
|
334
|
+
typeof value.selectedWorkspaceHash === "string" &&
|
|
335
|
+
workspaceHash(value.selectedWorkspace) === value.selectedWorkspaceHash
|
|
336
|
+
) return true;
|
|
337
|
+
}
|
|
338
|
+
} catch {}
|
|
339
|
+
const profiles = path.join(root, "profiles");
|
|
340
|
+
if (!existsSync(profiles)) return false;
|
|
341
|
+
try {
|
|
342
|
+
for (const entry of readdirSync(profiles, { withFileTypes: true })) {
|
|
343
|
+
if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
344
|
+
const stateFile = path.join(profiles, entry.name, "state.json");
|
|
345
|
+
if (!existsSync(stateFile)) continue;
|
|
346
|
+
const value = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
347
|
+
if (value?.workspace?.hash === entry.name && typeof value?.workspace?.path === "string") return true;
|
|
348
|
+
}
|
|
349
|
+
} catch {}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function atomicWriteJson(filePath, value) {
|
|
354
|
+
const dir = path.dirname(filePath);
|
|
355
|
+
ensureOwnerOnlyDir(dir);
|
|
356
|
+
cleanupStaleAtomicTemps(dir, path.basename(filePath));
|
|
357
|
+
const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
358
|
+
let fd;
|
|
359
|
+
try {
|
|
360
|
+
fd = openSync(tempPath, "wx", 0o600);
|
|
361
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
362
|
+
fsyncSync(fd);
|
|
363
|
+
closeSync(fd);
|
|
364
|
+
fd = undefined;
|
|
365
|
+
renameSync(tempPath, filePath);
|
|
366
|
+
ownerOnlyFile(filePath);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (fd !== undefined) {
|
|
369
|
+
try { closeSync(fd); } catch {}
|
|
370
|
+
}
|
|
371
|
+
try { unlinkSync(tempPath); } catch {}
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function cleanupStaleAtomicTemps(dir, baseName = "") {
|
|
377
|
+
const now = Date.now();
|
|
378
|
+
let entries = [];
|
|
379
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
380
|
+
for (const name of entries) {
|
|
381
|
+
if (!/^\..+\.\d+\.[a-f0-9]+\.tmp$/.test(name)) continue;
|
|
382
|
+
if (baseName && !name.startsWith(`.${baseName}.`)) continue;
|
|
383
|
+
const file = path.join(dir, name);
|
|
384
|
+
try {
|
|
385
|
+
if (now - statSync(file).mtimeMs > 60 * 60 * 1000) unlinkSync(file);
|
|
386
|
+
} catch {}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function pruneBackups(filePath, keep) {
|
|
391
|
+
const dir = path.dirname(filePath);
|
|
392
|
+
const prefix = `${path.basename(filePath)}.corrupt-`;
|
|
393
|
+
let backups = [];
|
|
394
|
+
try {
|
|
395
|
+
backups = readdirSync(dir)
|
|
396
|
+
.filter(name => name.startsWith(prefix))
|
|
397
|
+
.map(name => ({ path: path.join(dir, name), mtime: statSync(path.join(dir, name)).mtimeMs }))
|
|
398
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
399
|
+
} catch {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
for (const backup of backups.slice(keep)) {
|
|
403
|
+
try { unlinkSync(backup.path); } catch {}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
209
406
|
|
|
210
407
|
export function ensureWorkerSecrets(state, options = {}) {
|
|
211
408
|
state.worker ||= {};
|
|
@@ -215,14 +412,6 @@ export function ensureWorkerSecrets(state, options = {}) {
|
|
|
215
412
|
if (!state.worker.name || options.workerName) state.worker.name = options.workerName || defaultWorkerName(state.workspace.hash);
|
|
216
413
|
}
|
|
217
414
|
|
|
218
|
-
export function ensureLocalApiKey(state, options = {}) {
|
|
219
|
-
state.localApi ||= {};
|
|
220
|
-
if (options.apiKey && options.apiKey !== true) state.localApi.apiKey = String(options.apiKey);
|
|
221
|
-
if (!state.localApi.apiKey || options.rotateApiKey) state.localApi.apiKey = randomToken("local_api_key");
|
|
222
|
-
state.localApi.updatedAt = new Date().toISOString();
|
|
223
|
-
return state.localApi.apiKey;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
415
|
export function defaultWorkerName(hash) {
|
|
227
416
|
return `mbm-${String(hash || "default").slice(0, 12)}`;
|
|
228
417
|
}
|
|
@@ -250,16 +439,18 @@ export function ownerOnlyFile(filePath) {
|
|
|
250
439
|
|
|
251
440
|
export function redactState(state) {
|
|
252
441
|
const clone = redactHomeInValue(JSON.parse(JSON.stringify(state)));
|
|
253
|
-
if (clone.worker?.oauthPassword) clone.worker.oauthPassword =
|
|
254
|
-
if (clone.worker?.daemonSecret) clone.worker.daemonSecret =
|
|
255
|
-
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion =
|
|
256
|
-
if (clone.localApi?.apiKey) clone.localApi.apiKey = previewSecret(clone.localApi.apiKey);
|
|
442
|
+
if (clone.worker?.oauthPassword) clone.worker.oauthPassword = "<redacted>";
|
|
443
|
+
if (clone.worker?.daemonSecret) clone.worker.daemonSecret = "<redacted>";
|
|
444
|
+
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
|
|
257
445
|
return clone;
|
|
258
446
|
}
|
|
259
447
|
|
|
260
448
|
function redactHomeInValue(value) {
|
|
261
449
|
const home = os.homedir();
|
|
262
|
-
if (typeof value === "string")
|
|
450
|
+
if (typeof value === "string") {
|
|
451
|
+
const insideHome = home && (value === home || value.startsWith(`${home}${path.sep}`));
|
|
452
|
+
return insideHome ? `~${value.slice(home.length)}` : value;
|
|
453
|
+
}
|
|
263
454
|
if (Array.isArray(value)) return value.map(redactHomeInValue);
|
|
264
455
|
if (value && typeof value === "object") {
|
|
265
456
|
for (const key of Object.keys(value)) value[key] = redactHomeInValue(value[key]);
|
|
@@ -267,8 +458,6 @@ function redactHomeInValue(value) {
|
|
|
267
458
|
return value;
|
|
268
459
|
}
|
|
269
460
|
|
|
270
|
-
export function previewSecret(
|
|
271
|
-
|
|
272
|
-
if (text.length <= 12) return "<redacted>";
|
|
273
|
-
return `${text.slice(0, 10)}...${text.slice(-6)}`;
|
|
461
|
+
export function previewSecret(_value) {
|
|
462
|
+
return "<redacted>";
|
|
274
463
|
}
|