machine-bridge-mcp 0.2.5 → 0.4.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/src/local/log.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import process from "node:process";
2
2
 
3
3
  const COLORS = {
4
- dim: "\x1b[2m",
5
4
  reset: "\x1b[0m",
6
5
  blue: "\x1b[34m",
7
6
  green: "\x1b[32m",
@@ -10,10 +9,14 @@ const COLORS = {
10
9
  gray: "\x1b[90m",
11
10
  };
12
11
 
12
+ const SENSITIVE_KEY = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
13
+ const SECRET_VALUE = /\b(?:mcp_password|daemon_secret|token_version|mcp_at|mcp_code)_[A-Za-z0-9_-]+\b/g;
14
+ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
15
+
13
16
  export function createLogger(options = {}) {
14
17
  const quiet = Boolean(options.quiet);
15
18
  const verbose = Boolean(options.verbose);
16
- const component = options.component ? String(options.component) : "cli";
19
+ const component = sanitizeLogText(options.component ? String(options.component) : "cli");
17
20
  const useColor = shouldUseColor(options);
18
21
 
19
22
  const write = (stream, level, label, color, message, fields) => {
@@ -21,7 +24,7 @@ export function createLogger(options = {}) {
21
24
  if (level === "debug" && !verbose) return;
22
25
  const prefix = useColor ? `${color}${label}${COLORS.reset}` : label;
23
26
  const suffix = formatFields(fields);
24
- stream.write(`${prefix} ${component}: ${String(message)}${suffix}\n`);
27
+ stream.write(`${prefix} ${component}: ${sanitizeLogText(message)}${suffix}\n`);
25
28
  };
26
29
 
27
30
  return {
@@ -39,18 +42,37 @@ export function createLogger(options = {}) {
39
42
  }
40
43
 
41
44
  export function redactSecret(value) {
42
- const text = String(value || "");
43
- if (!text) return "<empty>";
44
- if (text.length <= 12) return "<redacted>";
45
- return `${text.slice(0, 6)}...${text.slice(-4)}`;
45
+ return value ? "<redacted>" : "<empty>";
46
46
  }
47
47
 
48
48
  export function formatFields(fields) {
49
49
  if (!fields || typeof fields !== "object" || !Object.keys(fields).length) return "";
50
- return ` ${JSON.stringify(fields, (_key, value) => {
51
- if (value instanceof Error) return value.message;
52
- return value;
53
- })}`;
50
+ return ` ${JSON.stringify(sanitizeLogValue(fields))}`;
51
+ }
52
+
53
+ export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth = 0) {
54
+ if (SENSITIVE_KEY.test(key)) return "<redacted>";
55
+ if (value instanceof Error) return sanitizeLogText(value.message || value.name);
56
+ if (typeof value === "string") return sanitizeLogText(value);
57
+ if (typeof value === "bigint") return value.toString();
58
+ if (value === null || typeof value !== "object") return value;
59
+ if (depth >= 8) return "<max-depth>";
60
+ if (seen.has(value)) return "<circular>";
61
+ seen.add(value);
62
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeLogValue(item, "", seen, depth + 1));
63
+ const out = {};
64
+ for (const [childKey, childValue] of Object.entries(value).slice(0, 100)) {
65
+ out[childKey] = sanitizeLogValue(childValue, childKey, seen, depth + 1);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export function sanitizeLogText(value) {
71
+ return String(value ?? "")
72
+ .replace(SECRET_VALUE, "<redacted-secret>")
73
+ .replace(BEARER_VALUE, "Bearer <redacted>")
74
+ .replace(/[\r\n\t]/g, match => match === "\t" ? "\\t" : "\\n")
75
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "?");
54
76
  }
55
77
 
56
78
  function shouldUseColor(options) {
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { run } from "./shell.mjs";
@@ -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);
@@ -38,11 +38,42 @@ export async function stopAutostart({ logger = console } = {}) {
38
38
  return run("systemctl", ["--user", "stop", "machine-bridge-mcp.service"], { capture: true, allowFailure: true });
39
39
  }
40
40
 
41
- function serviceSpec({ workspace, stateRoot, entryScript, policy = {} }) {
41
+
42
+ export function trimAutostartLogs(stateRoot, options = {}) {
43
+ const root = expandHome(stateRoot);
44
+ const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1024, Number(options.maxBytes)) : 2 * 1024 * 1024;
45
+ const keepBytes = Math.min(maxBytes, Number.isFinite(Number(options.keepBytes)) ? Math.max(1024, Number(options.keepBytes)) : 1024 * 1024);
46
+ const logs = path.join(root, "logs");
47
+ for (const name of ["daemon.out.log", "daemon.err.log"]) {
48
+ const file = path.join(logs, name);
49
+ try {
50
+ const info = statSync(file);
51
+ if (info.size > maxBytes) {
52
+ const fd = openSync(file, "r");
53
+ try {
54
+ const current = fstatSync(fd);
55
+ const length = Math.min(keepBytes, current.size);
56
+ const buffer = Buffer.alloc(length);
57
+ readSync(fd, buffer, 0, length, Math.max(0, current.size - length));
58
+ writeFileSync(file, buffer, { mode: 0o600 });
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ chmodSync(file, 0o600);
64
+ } catch {}
65
+ }
66
+ }
67
+
68
+ function serviceSpec({ workspace, stateRoot, entryScript }) {
42
69
  const root = expandHome(stateRoot);
43
70
  const logs = path.join(root, "logs");
44
71
  ensureOwnerOnlyDir(root);
45
72
  ensureOwnerOnlyDir(logs);
73
+ for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")]) {
74
+ writeFileSync(file, "", { flag: "a", mode: 0o600 });
75
+ try { chmodSync(file, 0o600); } catch {}
76
+ }
46
77
  return {
47
78
  workspace,
48
79
  stateRoot: root,
@@ -50,17 +81,11 @@ function serviceSpec({ workspace, stateRoot, entryScript, policy = {} }) {
50
81
  node: process.execPath,
51
82
  stdout: path.join(logs, "daemon.out.log"),
52
83
  stderr: path.join(logs, "daemon.err.log"),
53
- policy: {
54
- allowWrite: policy.allowWrite !== false,
55
- allowExec: policy.allowExec !== false,
56
- minimalEnv: policy.minimalEnv !== false,
57
- apiEnabled: policy.apiEnabled !== false,
58
- },
59
84
  };
60
85
  }
61
86
 
62
- function daemonArgs(spec) {
63
- const args = [
87
+ export function daemonArgs(spec) {
88
+ return [
64
89
  spec.entryScript,
65
90
  "start",
66
91
  "--daemon-only",
@@ -69,11 +94,6 @@ function daemonArgs(spec) {
69
94
  "--no-print-credentials",
70
95
  "--quiet",
71
96
  ];
72
- if (spec.policy.allowWrite === false) args.push("--no-write");
73
- if (spec.policy.allowExec === false) args.push("--no-exec");
74
- if (spec.policy.minimalEnv === false) args.push("--full-env");
75
- if (spec.policy.apiEnabled === false) args.push("--no-api");
76
- return args;
77
97
  }
78
98
 
79
99
  function launchdPlistPath() {
@@ -220,8 +240,15 @@ function winQuote(value) {
220
240
  return `"${String(value).replaceAll('"', '\\"')}"`;
221
241
  }
222
242
 
223
- function systemdQuote(value) {
224
- return `'${String(value).replaceAll("'", "'\\''")}'`;
243
+ export function systemdQuote(value) {
244
+ const escaped = String(value)
245
+ .replaceAll("\\", "\\\\")
246
+ .replaceAll("\"", "\\\"")
247
+ .replaceAll("%", "%%")
248
+ .replaceAll("\n", "\\n")
249
+ .replaceAll("\r", "\\r")
250
+ .replaceAll("\t", "\\t");
251
+ return `"${escaped}"`;
225
252
  }
226
253
 
227
254
  function escapeXml(value) {