machine-bridge-mcp 0.3.3 → 0.4.2

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,140 @@
1
+ const BEGIN = "*** Begin Patch";
2
+ const END = "*** End Patch";
3
+ const SECTION_PREFIXES = ["*** Add File: ", "*** Update File: ", "*** Delete File: "];
4
+
5
+ export function parsePatchEnvelope(input) {
6
+ const text = String(input ?? "").replaceAll("\r\n", "\n");
7
+ const lines = text.split("\n");
8
+ if (lines[0] !== BEGIN) throw new Error(`patch must start with ${BEGIN}`);
9
+ const endIndex = lines.lastIndexOf(END);
10
+ if (endIndex < 1) throw new Error(`patch must end with ${END}`);
11
+ if (lines.slice(endIndex + 1).some((line) => line !== "")) throw new Error("unexpected content after patch envelope");
12
+
13
+ const operations = [];
14
+ let index = 1;
15
+ while (index < endIndex) {
16
+ if (!lines[index]) {
17
+ index += 1;
18
+ continue;
19
+ }
20
+ const header = lines[index];
21
+ const kind = header.startsWith(SECTION_PREFIXES[0])
22
+ ? "add"
23
+ : header.startsWith(SECTION_PREFIXES[1])
24
+ ? "update"
25
+ : header.startsWith(SECTION_PREFIXES[2])
26
+ ? "delete"
27
+ : "";
28
+ if (!kind) throw new Error(`invalid patch section header at line ${index + 1}`);
29
+ const prefix = SECTION_PREFIXES[kind === "add" ? 0 : kind === "update" ? 1 : 2];
30
+ const path = header.slice(prefix.length).trim();
31
+ if (!path) throw new Error(`patch ${kind} path is empty`);
32
+ index += 1;
33
+
34
+ if (kind === "add") {
35
+ const contentLines = [];
36
+ while (index < endIndex && !isSectionHeader(lines[index])) {
37
+ const line = lines[index];
38
+ if (!line.startsWith("+")) throw new Error(`added file content must start with + at line ${index + 1}`);
39
+ contentLines.push(line.slice(1));
40
+ index += 1;
41
+ }
42
+ operations.push({ kind, path, content: contentLines.length ? `${contentLines.join("\n")}\n` : "" });
43
+ continue;
44
+ }
45
+
46
+ if (kind === "delete") {
47
+ if (index < endIndex && !isSectionHeader(lines[index]) && lines[index] !== "") {
48
+ throw new Error(`delete section must not contain patch body at line ${index + 1}`);
49
+ }
50
+ operations.push({ kind, path });
51
+ continue;
52
+ }
53
+
54
+ let moveTo = "";
55
+ if (lines[index]?.startsWith("*** Move to: ")) {
56
+ moveTo = lines[index].slice("*** Move to: ".length).trim();
57
+ if (!moveTo) throw new Error("move destination is empty");
58
+ index += 1;
59
+ }
60
+ const hunks = [];
61
+ while (index < endIndex && !isSectionHeader(lines[index])) {
62
+ if (!lines[index]) {
63
+ index += 1;
64
+ continue;
65
+ }
66
+ if (!lines[index].startsWith("@@")) throw new Error(`update hunk must start with @@ at line ${index + 1}`);
67
+ const headerText = lines[index].slice(2).trim();
68
+ index += 1;
69
+ const hunkLines = [];
70
+ while (index < endIndex && !isSectionHeader(lines[index]) && !lines[index].startsWith("@@")) {
71
+ const line = lines[index];
72
+ if (!line || ![" ", "+", "-"].includes(line[0])) {
73
+ throw new Error(`invalid update line prefix at line ${index + 1}`);
74
+ }
75
+ hunkLines.push({ type: line[0], text: line.slice(1) });
76
+ index += 1;
77
+ }
78
+ if (!hunkLines.length) throw new Error("empty update hunk");
79
+ if (!hunkLines.some((line) => line.type !== "+")) throw new Error("update hunk needs context or removed lines");
80
+ hunks.push({ header: headerText, lines: hunkLines });
81
+ }
82
+ if (!hunks.length) throw new Error(`update section for ${path} contains no hunks`);
83
+ operations.push({ kind, path, moveTo: moveTo || undefined, hunks });
84
+ }
85
+
86
+ if (!operations.length) throw new Error("patch contains no file operations");
87
+ const touched = new Set();
88
+ for (const operation of operations) {
89
+ for (const path of [operation.path, operation.moveTo].filter(Boolean)) {
90
+ if (touched.has(path)) throw new Error(`patch touches the same path more than once: ${path}`);
91
+ touched.add(path);
92
+ }
93
+ }
94
+ return operations;
95
+ }
96
+
97
+ export function applyUpdateHunks(content, hunks, path = "file") {
98
+ const normalized = String(content).replaceAll("\r\n", "\n");
99
+ const trailingNewline = normalized.endsWith("\n");
100
+ let lines = normalized.split("\n");
101
+ if (trailingNewline) lines.pop();
102
+ let searchFrom = 0;
103
+
104
+ for (const [hunkIndex, hunk] of hunks.entries()) {
105
+ const oldLines = hunk.lines.filter((line) => line.type !== "+").map((line) => line.text);
106
+ const newLines = hunk.lines.filter((line) => line.type !== "-").map((line) => line.text);
107
+ const matches = findSequenceMatches(lines, oldLines, searchFrom);
108
+ if (matches.length === 0) {
109
+ throw new Error(`patch context not found for ${path} hunk ${hunkIndex + 1}`);
110
+ }
111
+ if (matches.length > 1) {
112
+ throw new Error(`patch context is ambiguous for ${path} hunk ${hunkIndex + 1}`);
113
+ }
114
+ const at = matches[0];
115
+ lines.splice(at, oldLines.length, ...newLines);
116
+ searchFrom = at + newLines.length;
117
+ }
118
+
119
+ return `${lines.join("\n")}${trailingNewline ? "\n" : ""}`;
120
+ }
121
+
122
+ function findSequenceMatches(lines, sequence, start) {
123
+ const matches = [];
124
+ if (!sequence.length) return matches;
125
+ for (let index = Math.max(0, start); index <= lines.length - sequence.length; index += 1) {
126
+ let matched = true;
127
+ for (let offset = 0; offset < sequence.length; offset += 1) {
128
+ if (lines[index + offset] !== sequence[offset]) {
129
+ matched = false;
130
+ break;
131
+ }
132
+ }
133
+ if (matched) matches.push(index);
134
+ }
135
+ return matches;
136
+ }
137
+
138
+ function isSectionHeader(line) {
139
+ return SECTION_PREFIXES.some((prefix) => line.startsWith(prefix));
140
+ }
@@ -0,0 +1,352 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { basename } from "node:path";
4
+ import { executionEnv } from "./shell.mjs";
5
+
6
+ export const MAX_COMMAND_BYTES = 64 * 1024;
7
+ export const MAX_ARGV_ITEMS = 256;
8
+ export const MAX_PROCESS_SESSIONS = 8;
9
+ export const MAX_SESSION_OUTPUT_BYTES = 1024 * 1024;
10
+ export const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
11
+ export const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
12
+
13
+ export class ProcessSessionManager {
14
+ constructor({ workspace, policy, runtimeDir, activeProcesses, callProcesses, resolveCwd, displayPath, throwIfCancelled }) {
15
+ this.workspace = workspace;
16
+ this.policy = policy;
17
+ this.runtimeDir = runtimeDir;
18
+ this.activeProcesses = activeProcesses;
19
+ this.callProcesses = callProcesses;
20
+ this.resolveCwd = resolveCwd;
21
+ this.displayPath = displayPath;
22
+ this.throwIfCancelled = throwIfCancelled;
23
+ this.sessions = new Map();
24
+ }
25
+
26
+ status() {
27
+ return {
28
+ active: [...this.sessions.values()].filter((session) => session.closedAt === null).length,
29
+ retained: this.sessions.size,
30
+ maximum: MAX_PROCESS_SESSIONS,
31
+ };
32
+ }
33
+
34
+ clear() {
35
+ this.sessions.clear();
36
+ }
37
+
38
+ notifyCancellation() {
39
+ for (const session of this.sessions.values()) notifySessionWaiters(session);
40
+ }
41
+
42
+ async start(args, context = {}) {
43
+ this.assertEnabled("start_process");
44
+ const argv = validateArgv(args.argv);
45
+ const cwd = await this.resolveCwd(args.cwd || ".");
46
+ this.prune();
47
+ if (this.sessions.size >= MAX_PROCESS_SESSIONS) throw new Error(`process session limit reached (${MAX_PROCESS_SESSIONS})`);
48
+ this.throwIfCancelled(context);
49
+
50
+ const child = spawn(argv[0], argv.slice(1), {
51
+ cwd,
52
+ env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false, runtimeDir: this.runtimeDir }),
53
+ detached: process.platform !== "win32",
54
+ windowsHide: true,
55
+ });
56
+ const session = {
57
+ id: `proc_${randomBytes(24).toString("base64url")}`,
58
+ child,
59
+ argv0: basename(argv[0]),
60
+ cwd,
61
+ stdout: createSessionStream(),
62
+ stderr: createSessionStream(),
63
+ startedAt: Date.now(),
64
+ lastActivity: Date.now(),
65
+ closedAt: null,
66
+ exitCode: null,
67
+ signal: null,
68
+ stdinClosed: false,
69
+ waiters: new Set(),
70
+ };
71
+ this.sessions.set(session.id, session);
72
+ this.trackChild(child, context.callId);
73
+
74
+ child.stdout.on("data", (chunk) => {
75
+ appendSessionStream(session.stdout, chunk);
76
+ session.lastActivity = Date.now();
77
+ notifySessionWaiters(session);
78
+ });
79
+ child.stderr.on("data", (chunk) => {
80
+ appendSessionStream(session.stderr, chunk);
81
+ session.lastActivity = Date.now();
82
+ notifySessionWaiters(session);
83
+ });
84
+ child.on("close", (code, signal) => {
85
+ session.exitCode = Number.isInteger(code) ? code : null;
86
+ session.signal = signal ? String(signal) : null;
87
+ session.closedAt = Date.now();
88
+ session.lastActivity = Date.now();
89
+ session.stdinClosed = true;
90
+ this.untrackChild(child);
91
+ notifySessionWaiters(session);
92
+ });
93
+
94
+ try {
95
+ await waitForSpawn(child);
96
+ } catch (error) {
97
+ this.sessions.delete(session.id);
98
+ this.untrackChild(child);
99
+ throw error;
100
+ }
101
+
102
+ child.on("error", (error) => {
103
+ appendSessionStream(session.stderr, Buffer.from(`${boundedErrorMessage(error)}\n`));
104
+ session.lastActivity = Date.now();
105
+ notifySessionWaiters(session);
106
+ });
107
+ this.throwIfCancelled(context);
108
+ return this.summary(session);
109
+ }
110
+
111
+ async read(args, context = {}) {
112
+ this.assertEnabled("read_process");
113
+ const session = this.get(args.session_id);
114
+ const stdoutOffset = clampInt(args.stdout_offset, 0, 0, Number.MAX_SAFE_INTEGER);
115
+ const stderrOffset = clampInt(args.stderr_offset, 0, 0, Number.MAX_SAFE_INTEGER);
116
+ const maxBytes = clampInt(args.max_bytes, 64 * 1024, 1, 256 * 1024);
117
+ const waitMs = clampInt(args.wait_ms, 0, 0, 30_000);
118
+ this.throwIfCancelled(context);
119
+ const waitForExit = args.wait_for_exit === true;
120
+ if (waitMs > 0 && session.closedAt === null) {
121
+ const deadline = Date.now() + waitMs;
122
+ if (waitForExit) {
123
+ while (session.closedAt === null && Date.now() < deadline) {
124
+ await waitForSessionChange(session, Math.max(1, deadline - Date.now()), () => this.throwIfCancelled(context));
125
+ }
126
+ } else if (!sessionHasOutputAfter(session, stdoutOffset, stderrOffset)) {
127
+ await waitForSessionChange(session, waitMs, () => this.throwIfCancelled(context));
128
+ }
129
+ }
130
+ this.throwIfCancelled(context);
131
+ session.lastActivity = Date.now();
132
+ return {
133
+ ...this.summary(session),
134
+ stdout: readSessionStream(session.stdout, stdoutOffset, maxBytes),
135
+ stderr: readSessionStream(session.stderr, stderrOffset, maxBytes),
136
+ };
137
+ }
138
+
139
+ async write(args, context = {}) {
140
+ this.assertEnabled("write_process");
141
+ const session = this.get(args.session_id);
142
+ if (session.closedAt !== null) throw new Error("process session has already exited");
143
+ const data = String(args.data ?? "");
144
+ if (Buffer.byteLength(data) > MAX_PROCESS_STDIN_BYTES) throw new Error(`stdin data exceeds maximum size (${MAX_PROCESS_STDIN_BYTES} bytes)`);
145
+ if (session.stdinClosed || session.child.stdin.destroyed) throw new Error("process session stdin is closed");
146
+ this.throwIfCancelled(context);
147
+ if (data) {
148
+ await new Promise((resolvePromise, rejectPromise) => {
149
+ session.child.stdin.write(data, (error) => error ? rejectPromise(error) : resolvePromise());
150
+ });
151
+ }
152
+ if (args.close_stdin === true) {
153
+ session.child.stdin.end();
154
+ session.stdinClosed = true;
155
+ }
156
+ session.lastActivity = Date.now();
157
+ return { ...this.summary(session), bytes_written: Buffer.byteLength(data) };
158
+ }
159
+
160
+ async kill(args, context = {}) {
161
+ this.assertEnabled("kill_process");
162
+ const session = this.get(args.session_id);
163
+ this.throwIfCancelled(context);
164
+ const wasRunning = session.closedAt === null;
165
+ if (wasRunning) terminateProcessTree(session.child, args.force === true ? "SIGKILL" : "SIGTERM");
166
+ session.lastActivity = Date.now();
167
+ return { ...this.summary(session), termination_requested: wasRunning, force: args.force === true };
168
+ }
169
+
170
+ get(sessionId) {
171
+ this.prune();
172
+ const id = String(sessionId || "");
173
+ if (!/^proc_[A-Za-z0-9_-]{20,}$/.test(id)) throw new Error("invalid process session id");
174
+ const session = this.sessions.get(id);
175
+ if (!session) throw new Error("process session not found or expired");
176
+ return session;
177
+ }
178
+
179
+ summary(session) {
180
+ return {
181
+ session_id: session.id,
182
+ command: session.argv0,
183
+ cwd: this.displayPath(session.cwd),
184
+ running: session.closedAt === null,
185
+ exit_code: session.exitCode,
186
+ signal: session.signal,
187
+ stdin_closed: session.stdinClosed,
188
+ started_at: new Date(session.startedAt).toISOString(),
189
+ closed_at: session.closedAt ? new Date(session.closedAt).toISOString() : null,
190
+ stdout_offset: session.stdout.totalBytes,
191
+ stderr_offset: session.stderr.totalBytes,
192
+ };
193
+ }
194
+
195
+ prune() {
196
+ const cutoff = Date.now() - PROCESS_SESSION_RETENTION_MS;
197
+ for (const [id, session] of this.sessions) {
198
+ if (session.closedAt !== null && session.lastActivity < cutoff) this.sessions.delete(id);
199
+ }
200
+ if (this.sessions.size < MAX_PROCESS_SESSIONS) return;
201
+ const exited = [...this.sessions.values()]
202
+ .filter((session) => session.closedAt !== null)
203
+ .sort((left, right) => left.lastActivity - right.lastActivity);
204
+ for (const session of exited) {
205
+ if (this.sessions.size < MAX_PROCESS_SESSIONS) break;
206
+ this.sessions.delete(session.id);
207
+ }
208
+ }
209
+
210
+ assertEnabled(tool) {
211
+ if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error(`${tool} is disabled by daemon policy`);
212
+ }
213
+
214
+ trackChild(child, callId) {
215
+ this.activeProcesses.add(child);
216
+ if (!callId) return;
217
+ const set = this.callProcesses.get(callId) || new Set();
218
+ set.add(child);
219
+ this.callProcesses.set(callId, set);
220
+ }
221
+
222
+ untrackChild(child) {
223
+ this.activeProcesses.delete(child);
224
+ for (const [callId, set] of this.callProcesses) {
225
+ set.delete(child);
226
+ if (!set.size) this.callProcesses.delete(callId);
227
+ }
228
+ }
229
+ }
230
+
231
+ export function validateArgv(value) {
232
+ if (!Array.isArray(value) || !value.length || value.length > MAX_ARGV_ITEMS) throw new Error(`argv must contain 1-${MAX_ARGV_ITEMS} strings`);
233
+ const argv = value.map((item) => {
234
+ if (typeof item !== "string" || item.includes("\0")) throw new Error("argv entries must be strings without NUL bytes");
235
+ return item;
236
+ });
237
+ if (!argv[0]) throw new Error("argv[0] must not be empty");
238
+ if (Buffer.byteLength(JSON.stringify(argv)) > MAX_COMMAND_BYTES) throw new Error(`argv exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
239
+ return argv;
240
+ }
241
+
242
+ export function terminateProcessTree(child, signal) {
243
+ if (!child?.pid) return;
244
+ if (process.platform === "win32") {
245
+ try {
246
+ const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
247
+ killer.unref();
248
+ return;
249
+ } catch {}
250
+ }
251
+ try { process.kill(-child.pid, signal); } catch {
252
+ try { child.kill(signal); } catch {}
253
+ }
254
+ }
255
+
256
+ function waitForSpawn(child) {
257
+ return new Promise((resolvePromise, rejectPromise) => {
258
+ const onSpawn = () => { cleanup(); resolvePromise(); };
259
+ const onError = (error) => { cleanup(); rejectPromise(error); };
260
+ const cleanup = () => {
261
+ child.off("spawn", onSpawn);
262
+ child.off("error", onError);
263
+ };
264
+ child.once("spawn", onSpawn);
265
+ child.once("error", onError);
266
+ });
267
+ }
268
+
269
+ function createSessionStream() {
270
+ return { buffer: Buffer.alloc(0), baseOffset: 0, totalBytes: 0 };
271
+ }
272
+
273
+ function appendSessionStream(stream, chunk) {
274
+ const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
275
+ stream.totalBytes += input.length;
276
+ let combined = stream.buffer.length ? Buffer.concat([stream.buffer, input]) : Buffer.from(input);
277
+ if (combined.length > MAX_SESSION_OUTPUT_BYTES) {
278
+ const dropped = combined.length - MAX_SESSION_OUTPUT_BYTES;
279
+ combined = combined.subarray(dropped);
280
+ stream.baseOffset += dropped;
281
+ }
282
+ stream.buffer = combined;
283
+ }
284
+
285
+ function readSessionStream(stream, requestedOffset, maxBytes) {
286
+ const clampedOffset = Math.min(requestedOffset, stream.totalBytes);
287
+ const effectiveOffset = Math.max(clampedOffset, stream.baseOffset);
288
+ const start = effectiveOffset - stream.baseOffset;
289
+ const slice = stream.buffer.subarray(start, Math.min(stream.buffer.length, start + maxBytes));
290
+ let data;
291
+ let dataBase64;
292
+ let encoding = "utf8";
293
+ try {
294
+ data = new TextDecoder("utf-8", { fatal: true }).decode(slice);
295
+ } catch {
296
+ data = new TextDecoder("utf-8").decode(slice);
297
+ dataBase64 = slice.toString("base64");
298
+ encoding = "base64";
299
+ }
300
+ return {
301
+ data,
302
+ ...(dataBase64 ? { data_base64: dataBase64 } : {}),
303
+ encoding,
304
+ requested_offset: requestedOffset,
305
+ start_offset: effectiveOffset,
306
+ next_offset: effectiveOffset + slice.length,
307
+ total_offset: stream.totalBytes,
308
+ truncated_before: requestedOffset < stream.baseOffset,
309
+ truncated_after: effectiveOffset + slice.length < stream.totalBytes,
310
+ };
311
+ }
312
+
313
+ function sessionHasOutputAfter(session, stdoutOffset, stderrOffset) {
314
+ return session.stdout.totalBytes > stdoutOffset || session.stderr.totalBytes > stderrOffset;
315
+ }
316
+
317
+ function notifySessionWaiters(session) {
318
+ for (const waiter of [...session.waiters]) waiter();
319
+ }
320
+
321
+ function waitForSessionChange(session, waitMs, cancellationCheck) {
322
+ return new Promise((resolvePromise, rejectPromise) => {
323
+ let timer;
324
+ const done = () => {
325
+ cleanup();
326
+ try {
327
+ cancellationCheck();
328
+ resolvePromise();
329
+ } catch (error) {
330
+ rejectPromise(error);
331
+ }
332
+ };
333
+ const cleanup = () => {
334
+ if (timer) clearTimeout(timer);
335
+ session.waiters.delete(done);
336
+ };
337
+ session.waiters.add(done);
338
+ timer = setTimeout(done, waitMs);
339
+ timer.unref?.();
340
+ });
341
+ }
342
+
343
+ function boundedErrorMessage(error) {
344
+ const message = error instanceof Error ? error.message : String(error);
345
+ return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "process failed";
346
+ }
347
+
348
+ function clampInt(value, fallback, min, max) {
349
+ const parsed = Number.parseInt(String(value ?? ""), 10);
350
+ const number = Number.isFinite(parsed) ? parsed : fallback;
351
+ return Math.min(Math.max(number, min), max);
352
+ }
@@ -7,8 +7,8 @@ import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
7
7
  const LABEL = "dev.machine-bridge-mcp.daemon";
8
8
  const WINDOWS_TASK = "MachineBridgeMCP";
9
9
 
10
- export async function installAutostart({ workspace, stateRoot, entryScript, policy, logger = console }) {
11
- const spec = serviceSpec({ workspace, stateRoot, entryScript, policy });
10
+ export async function installAutostart({ workspace, stateRoot, entryScript, logger = console }) {
11
+ const spec = serviceSpec({ workspace, stateRoot, entryScript });
12
12
  if (process.platform === "darwin") return installLaunchd(spec, logger);
13
13
  if (process.platform === "win32") return installWindowsTask(spec, logger);
14
14
  return installSystemd(spec, logger);
@@ -65,7 +65,7 @@ export function trimAutostartLogs(stateRoot, options = {}) {
65
65
  }
66
66
  }
67
67
 
68
- function serviceSpec({ workspace, stateRoot, entryScript, policy = {} }) {
68
+ function serviceSpec({ workspace, stateRoot, entryScript }) {
69
69
  const root = expandHome(stateRoot);
70
70
  const logs = path.join(root, "logs");
71
71
  ensureOwnerOnlyDir(root);
@@ -81,17 +81,11 @@ function serviceSpec({ workspace, stateRoot, entryScript, policy = {} }) {
81
81
  node: process.execPath,
82
82
  stdout: path.join(logs, "daemon.out.log"),
83
83
  stderr: path.join(logs, "daemon.err.log"),
84
- policy: {
85
- allowWrite: policy.allowWrite !== false,
86
- allowExec: policy.allowExec !== false,
87
- minimalEnv: policy.minimalEnv !== false,
88
- unrestrictedPaths: policy.unrestrictedPaths === true,
89
- },
90
84
  };
91
85
  }
92
86
 
93
- function daemonArgs(spec) {
94
- const args = [
87
+ export function daemonArgs(spec) {
88
+ return [
95
89
  spec.entryScript,
96
90
  "start",
97
91
  "--daemon-only",
@@ -100,11 +94,6 @@ function daemonArgs(spec) {
100
94
  "--no-print-credentials",
101
95
  "--quiet",
102
96
  ];
103
- if (spec.policy.allowWrite === false) args.push("--no-write");
104
- if (spec.policy.allowExec === false) args.push("--no-exec");
105
- if (spec.policy.minimalEnv === false) args.push("--full-env");
106
- if (spec.policy.unrestrictedPaths === true) args.push("--unrestricted-paths");
107
- return args;
108
97
  }
109
98
 
110
99
  function launchdPlistPath() {
@@ -111,12 +111,29 @@ export function workspaceShellCommand(command) {
111
111
  }
112
112
 
113
113
  export function executionEnv(workspace, options = {}) {
114
- // Keep environment small to avoid accidental dependence on the launching shell,
115
- // but do not hide filesystem contents. Operators can opt into full env when desired.
114
+ // Minimal mode deliberately replaces user home/temp/cache locations so common
115
+ // toolchains do not inherit credential-bearing configuration by accident.
116
116
  if (options.fullEnv || process.env.MBM_PASS_ENV === "true") return { ...process.env, MBM_WORKSPACE: workspace };
117
- const allowed = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TMP", "TEMP", "SystemRoot", "WINDIR"];
118
- const env = { MBM_WORKSPACE: workspace };
119
- for (const key of allowed) if (process.env[key]) env[key] = process.env[key];
117
+ const runtimeDir = options.runtimeDir ? path.resolve(String(options.runtimeDir)) : "";
118
+ if (!runtimeDir) throw new Error("minimal execution environment requires a runtime directory");
119
+ const runtimeHome = path.join(runtimeDir, "home");
120
+ const runtimeTmp = path.join(runtimeDir, "tmp");
121
+ const runtimeCache = path.join(runtimeDir, "cache");
122
+ const env = {
123
+ MBM_WORKSPACE: workspace,
124
+ HOME: runtimeHome,
125
+ USERPROFILE: runtimeHome,
126
+ TMPDIR: runtimeTmp,
127
+ TMP: runtimeTmp,
128
+ TEMP: runtimeTmp,
129
+ XDG_CACHE_HOME: runtimeCache,
130
+ npm_config_cache: path.join(runtimeCache, "npm"),
131
+ PIP_CACHE_DIR: path.join(runtimeCache, "pip"),
132
+ CARGO_HOME: path.join(runtimeCache, "cargo"),
133
+ };
134
+ for (const key of ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"]) {
135
+ if (process.env[key]) env[key] = process.env[key];
136
+ }
120
137
  if (!env.PATH) env.PATH = process.env.PATH || "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
121
138
  return env;
122
139
  }
@@ -96,7 +96,7 @@ export function loadState(workspace, options = {}) {
96
96
  ownerOnlyFile(statePath);
97
97
  state = readJsonObjectOrBackup(statePath);
98
98
  }
99
- state.schemaVersion = 2;
99
+ state.schemaVersion = 3;
100
100
  state.workspace = {
101
101
  path: workspace,
102
102
  hash: workspaceHash(workspace),