machine-bridge-mcp 0.4.2 → 0.6.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.
@@ -0,0 +1,470 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { executionEnv } from "./shell.mjs";
6
+ import { terminateProcessTree } from "./process-sessions.mjs";
7
+
8
+ const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
9
+ const TEMP_TOKEN = /\{\{temp:([a-z][a-z0-9._-]{0,63})\}\}/g;
10
+ const MAX_RESOURCE_BYTES = 1024 * 1024;
11
+ const MAX_OUTPUT_BYTES = 64 * 1024;
12
+ const MAX_JOB_CAPTURE_BYTES = 256 * 1024;
13
+ const MAX_RESULT_BYTES = 4 * 1024 * 1024;
14
+ const MAX_STATUS_BYTES = 256 * 1024;
15
+
16
+ const options = parseArgs(process.argv.slice(2));
17
+ const jobDir = resolve(options.jobDir || "");
18
+ if (!jobDir) throw new Error("--job-dir is required");
19
+ const recover = options.recover === true;
20
+ const planFile = join(jobDir, "plan.json");
21
+ const statusFile = join(jobDir, "status.json");
22
+ const resultFile = join(jobDir, "result.json");
23
+ const cancelFile = join(jobDir, "cancel");
24
+ const runtimeDir = join(jobDir, "runtime");
25
+ const resourcesDir = join(runtimeDir, "resources");
26
+ const temporaryFilesDir = join(runtimeDir, "files");
27
+ const runnerPidFile = join(jobDir, "runner.pid");
28
+
29
+ class JobCancelledError extends Error {
30
+ constructor() {
31
+ super("job cancellation requested");
32
+ this.name = "JobCancelledError";
33
+ }
34
+ }
35
+
36
+
37
+ let activeChild = null;
38
+ let activeChildCancellationAware = false;
39
+ let cancellationEscalation = null;
40
+ let cancelRequested = false;
41
+ for (const signal of ["SIGTERM", "SIGINT"]) {
42
+ process.on(signal, () => requestCancellation());
43
+ }
44
+
45
+ writeFileSync(runnerPidFile, `${process.pid}\n`, { mode: 0o600 });
46
+ if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
47
+ try {
48
+ await main();
49
+ } catch (error) {
50
+ recordFatalRunnerError(error);
51
+ }
52
+
53
+ async function main() {
54
+ const plan = readJson(planFile, 1024 * 1024);
55
+ const initial = readJson(statusFile, MAX_STATUS_BYTES);
56
+ const status = {
57
+ ...initial,
58
+ runner_pid: process.pid,
59
+ started_at: initial.started_at || new Date().toISOString(),
60
+ updated_at: new Date().toISOString(),
61
+ status: recover ? "cleaning" : "running",
62
+ current_phase: recover ? "recovery-cleanup" : "steps",
63
+ current_step: null,
64
+ cleanup_guarantee: "best-effort-finally-and-recovery",
65
+ };
66
+ writeJson(statusFile, status, MAX_STATUS_BYTES);
67
+
68
+ const mainResults = [];
69
+ const cleanupResults = [];
70
+ const captureBudget = { remaining: MAX_JOB_CAPTURE_BYTES };
71
+ let mainError = null;
72
+ let cleanupError = null;
73
+ let resourceContext = { paths: {}, bytes: {}, redactions: {}, temporaryPaths: {} };
74
+
75
+ try {
76
+ resourceContext = materializeResources(plan.resources || {});
77
+ resourceContext.temporaryPaths = materializeTemporaryFiles(plan.temporary_files || []);
78
+ if (!recover) {
79
+ for (let index = 0; index < plan.steps.length; index += 1) {
80
+ if (isCancellationRequested()) throw new JobCancelledError();
81
+ updateStatus(status, { status: "running", current_phase: "steps", current_step: index });
82
+ const result = await runStep(plan.steps[index], index, "steps", plan, resourceContext, true, captureBudget);
83
+ mainResults.push(result);
84
+ if (result.timed_out && !plan.steps[index].allow_failure) throw new Error(`step ${index + 1} timed out`);
85
+ if (result.code !== 0 && !plan.steps[index].allow_failure) throw new Error(`step ${index + 1} exited ${result.code}`);
86
+ }
87
+ }
88
+ } catch (error) {
89
+ mainError = error;
90
+ } finally {
91
+ updateStatus(status, { status: "cleaning", current_phase: recover ? "recovery-cleanup" : "finally_steps", current_step: null });
92
+ try {
93
+ for (let index = 0; index < plan.finally_steps.length; index += 1) {
94
+ updateStatus(status, { status: "cleaning", current_phase: recover ? "recovery-cleanup" : "finally_steps", current_step: index });
95
+ const result = await runStep(plan.finally_steps[index], index, "finally_steps", plan, resourceContext, false, captureBudget);
96
+ cleanupResults.push(result);
97
+ if (result.timed_out && !plan.finally_steps[index].allow_failure && !cleanupError) cleanupError = new Error(`cleanup step ${index + 1} timed out`);
98
+ if (result.code !== 0 && !plan.finally_steps[index].allow_failure && !cleanupError) cleanupError = new Error(`cleanup step ${index + 1} exited ${result.code}`);
99
+ }
100
+ } catch (error) {
101
+ cleanupError ||= error;
102
+ }
103
+ rmSync(runtimeDir, { recursive: true, force: true });
104
+ }
105
+
106
+ const cancelled = mainError instanceof JobCancelledError || existsSync(cancelFile);
107
+ let finalStatus;
108
+ if (recover) finalStatus = cleanupError ? "recovery_failed" : "recovered";
109
+ else if (cancelled) finalStatus = cleanupError ? "cancelled_cleanup_failed" : "cancelled";
110
+ else if (mainError) finalStatus = cleanupError ? "failed_cleanup_failed" : "failed";
111
+ else finalStatus = cleanupError ? "succeeded_cleanup_failed" : "succeeded";
112
+
113
+ const result = {
114
+ job_id: status.job_id,
115
+ name: plan.name,
116
+ status: finalStatus,
117
+ recovered: recover,
118
+ steps: mainResults,
119
+ finally_steps: cleanupResults,
120
+ error_class: classifyError(mainError),
121
+ cleanup_error_class: classifyError(cleanupError),
122
+ capture_limit_bytes: MAX_JOB_CAPTURE_BYTES,
123
+ capture_remaining_bytes: captureBudget.remaining,
124
+ finished_at: new Date().toISOString(),
125
+ };
126
+ writeJson(resultFile, result, MAX_RESULT_BYTES);
127
+ updateStatus(status, {
128
+ status: finalStatus,
129
+ current_phase: null,
130
+ current_step: null,
131
+ finished_at: result.finished_at,
132
+ error_class: result.error_class || result.cleanup_error_class,
133
+ });
134
+ try { rmSync(planFile, { force: true }); } catch {}
135
+ try { rmSync(runnerPidFile, { force: true }); } catch {}
136
+ try { rmSync(cancelFile, { force: true }); } catch {}
137
+ }
138
+
139
+ function recordFatalRunnerError(error) {
140
+ rmSync(runtimeDir, { recursive: true, force: true });
141
+ const now = new Date().toISOString();
142
+ let status = {};
143
+ try { status = readJson(statusFile, MAX_STATUS_BYTES); } catch {}
144
+ const finalStatus = recover ? "recovery_failed" : "runner_failed";
145
+ const result = {
146
+ job_id: status.job_id ?? null,
147
+ name: status.name ?? "managed job",
148
+ status: finalStatus,
149
+ recovered: recover,
150
+ steps: [],
151
+ finally_steps: [],
152
+ error_class: classifyError(error),
153
+ cleanup_error_class: recover ? classifyError(error) : null,
154
+ finished_at: now,
155
+ };
156
+ try { writeJson(resultFile, result, MAX_RESULT_BYTES); } catch {}
157
+ try {
158
+ writeJson(statusFile, {
159
+ ...status,
160
+ status: finalStatus,
161
+ current_phase: null,
162
+ current_step: null,
163
+ runner_pid: process.pid,
164
+ updated_at: now,
165
+ finished_at: now,
166
+ error_class: result.error_class,
167
+ cleanup_guarantee: "best-effort-finally-and-recovery",
168
+ }, MAX_STATUS_BYTES);
169
+ } catch {}
170
+ try { rmSync(planFile, { force: true }); } catch {}
171
+ try { rmSync(runnerPidFile, { force: true }); } catch {}
172
+ try { rmSync(cancelFile, { force: true }); } catch {}
173
+ }
174
+
175
+ async function runStep(step, index, phase, plan, resourceContext, cancellationAware, captureBudget) {
176
+ const argv = step.argv.map((value) => substitute(value, plan, resourceContext));
177
+ const envOverrides = Object.fromEntries(Object.entries(step.env || {}).map(([key, value]) => [key, substitute(value, plan, resourceContext)]));
178
+ const envResourceValues = Object.fromEntries(Object.entries(step.env_resources || {}).map(([key, name]) => [key, resourceEnvValue(name, resourceContext.bytes)]));
179
+ const env = {
180
+ ...executionEnv(plan.workspace, { fullEnv: plan.full_env === true, runtimeDir }),
181
+ ...envOverrides,
182
+ ...envResourceValues,
183
+ };
184
+ const input = step.stdin_resource
185
+ ? readResourceBytes(step.stdin_resource, resourceContext.paths)
186
+ : step.stdin === null || step.stdin === undefined
187
+ ? null
188
+ : Buffer.from(step.stdin, "utf8");
189
+ const started = Date.now();
190
+ const raw = await spawnStep(argv, {
191
+ cwd: step.cwd,
192
+ env,
193
+ input,
194
+ timeoutMs: Number(step.timeout_seconds) * 1000,
195
+ cancellationAware,
196
+ captureOutput: step.capture_output !== "discard",
197
+ captureBudget,
198
+ });
199
+ return {
200
+ index,
201
+ phase,
202
+ name: step.name,
203
+ command: basename(argv[0]),
204
+ code: raw.code,
205
+ signal: raw.signal,
206
+ timed_out: raw.timedOut,
207
+ duration_ms: Date.now() - started,
208
+ stdout: step.capture_output === "discard" ? "" : redactOutput(raw.stdout, resourceContext),
209
+ stderr: step.capture_output === "discard" ? "" : redactOutput(raw.stderr, resourceContext),
210
+ output_discarded: step.capture_output === "discard",
211
+ stdout_truncated_bytes: step.capture_output === "discard" ? 0 : raw.stdoutTruncated,
212
+ stderr_truncated_bytes: step.capture_output === "discard" ? 0 : raw.stderrTruncated,
213
+ stdout_omitted_bytes: step.capture_output === "discard" ? raw.stdoutTruncated : 0,
214
+ stderr_omitted_bytes: step.capture_output === "discard" ? raw.stderrTruncated : 0,
215
+ };
216
+ }
217
+
218
+ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captureOutput, captureBudget }) {
219
+ return new Promise((resolvePromise, rejectPromise) => {
220
+ if (cancellationAware && isCancellationRequested()) return rejectPromise(new JobCancelledError());
221
+ const child = spawn(argv[0], argv.slice(1), {
222
+ cwd,
223
+ env,
224
+ detached: process.platform !== "win32",
225
+ windowsHide: true,
226
+ stdio: ["pipe", "pipe", "pipe"],
227
+ });
228
+ activeChild = child;
229
+ activeChildCancellationAware = cancellationAware;
230
+ let stdout = Buffer.alloc(0);
231
+ let stderr = Buffer.alloc(0);
232
+ let stdoutTruncated = 0;
233
+ let stderrTruncated = 0;
234
+ let timedOut = false;
235
+ let closed = false;
236
+ const timer = setTimeout(() => {
237
+ timedOut = true;
238
+ terminateProcessTree(child, "SIGTERM");
239
+ const kill = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
240
+ kill.unref?.();
241
+ }, timeoutMs);
242
+ timer.unref?.();
243
+ const cancellationPoll = setInterval(() => {
244
+ if (!cancellationAware || !isCancellationRequested()) return;
245
+ requestCancellation();
246
+ }, 250);
247
+ cancellationPoll.unref?.();
248
+
249
+ child.stdout.on("data", (chunk) => {
250
+ if (!captureOutput) { stdoutTruncated += chunk.length; return; }
251
+ const next = appendLimited(stdout, chunk, MAX_OUTPUT_BYTES, captureBudget);
252
+ stdout = next.buffer;
253
+ stdoutTruncated += next.truncated;
254
+ });
255
+ child.stderr.on("data", (chunk) => {
256
+ if (!captureOutput) { stderrTruncated += chunk.length; return; }
257
+ const next = appendLimited(stderr, chunk, MAX_OUTPUT_BYTES, captureBudget);
258
+ stderr = next.buffer;
259
+ stderrTruncated += next.truncated;
260
+ });
261
+ child.on("error", (error) => finish(() => rejectPromise(error)));
262
+ child.on("close", (code, signal) => finish(() => {
263
+ if (cancellationAware && isCancellationRequested()) return rejectPromise(new JobCancelledError());
264
+ resolvePromise({
265
+ code: Number.isInteger(code) ? code : 1,
266
+ signal: signal ? String(signal) : null,
267
+ timedOut,
268
+ stdout,
269
+ stderr,
270
+ stdoutTruncated,
271
+ stderrTruncated,
272
+ });
273
+ }));
274
+ if (input && input.length) child.stdin.end(input);
275
+ else child.stdin.end();
276
+
277
+ function finish(callback) {
278
+ if (closed) return;
279
+ closed = true;
280
+ clearTimeout(timer);
281
+ clearInterval(cancellationPoll);
282
+ activeChild = null;
283
+ activeChildCancellationAware = false;
284
+ if (cancellationEscalation) clearTimeout(cancellationEscalation);
285
+ cancellationEscalation = null;
286
+ callback();
287
+ }
288
+ });
289
+ }
290
+
291
+ function materializeResources(resources) {
292
+ mkdirSync(resourcesDir, { recursive: true, mode: 0o700 });
293
+ chmodSync(resourcesDir, 0o700);
294
+ const paths = {};
295
+ const bytes = {};
296
+ const redactions = {};
297
+ for (const [name, resource] of Object.entries(resources)) {
298
+ if (!resource || resource.kind !== "file") throw new Error(`unsupported resource kind: ${name}`);
299
+ const data = readBoundedFile(resource.path, MAX_RESOURCE_BYTES);
300
+ const actualHash = createHash("sha256").update(data).digest("hex");
301
+ if (!resource.sha256 || actualHash !== resource.sha256) throw new Error(`local resource changed after job submission: ${name}`);
302
+ const target = join(resourcesDir, name);
303
+ writeFileSync(target, data, { mode: 0o600, flag: "wx" });
304
+ paths[name] = target;
305
+ bytes[name] = data;
306
+ const patterns = [];
307
+ try {
308
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(data);
309
+ if (text.length > 0) patterns.push(text);
310
+ const trimmed = text.replace(/[\r\n]+$/, "");
311
+ if (trimmed.length > 0 && trimmed !== text) patterns.push(trimmed);
312
+ } catch {}
313
+ if (data.length > 0 && data.length <= 64 * 1024) {
314
+ patterns.push(data.toString("base64"), data.toString("hex"));
315
+ }
316
+ redactions[name] = [...new Set(patterns.filter((value) => value.length > 0))].sort((a, b) => b.length - a.length);
317
+ }
318
+ return { paths, bytes, redactions };
319
+ }
320
+
321
+ function materializeTemporaryFiles(files) {
322
+ mkdirSync(temporaryFilesDir, { recursive: true, mode: 0o700 });
323
+ chmodSync(temporaryFilesDir, 0o700);
324
+ const paths = {};
325
+ for (const file of files) {
326
+ const target = join(temporaryFilesDir, file.name);
327
+ writeFileSync(target, file.content, { mode: file.executable ? 0o700 : 0o600, flag: "wx" });
328
+ paths[file.name] = target;
329
+ }
330
+ return paths;
331
+ }
332
+
333
+ function readResourceBytes(name, paths) {
334
+ const path = paths[name];
335
+ if (!path) throw new Error(`resource was not materialized: ${name}`);
336
+ return readBoundedFile(path, MAX_RESOURCE_BYTES);
337
+ }
338
+
339
+ function resourceEnvValue(name, bytes) {
340
+ const data = bytes[name];
341
+ if (!Buffer.isBuffer(data)) throw new Error(`resource was not materialized: ${name}`);
342
+ if (data.length > 64 * 1024) throw new Error(`resource is too large for an environment variable: ${name}`);
343
+ let value;
344
+ try { value = new TextDecoder("utf-8", { fatal: true }).decode(data); } catch {
345
+ throw new Error(`resource is not UTF-8 text for environment injection: ${name}`);
346
+ }
347
+ value = value.replace(/[\r\n]+$/, "");
348
+ if (value.includes("\0")) throw new Error(`resource contains a NUL byte and cannot be used as an environment variable: ${name}`);
349
+ return value;
350
+ }
351
+
352
+ function substitute(value, plan, context) {
353
+ return String(value)
354
+ .replaceAll("{{job:runtime}}", runtimeDir)
355
+ .replaceAll("{{job:workspace}}", plan.workspace)
356
+ .replace(RESOURCE_TOKEN, (_, name) => {
357
+ const path = context.paths[name];
358
+ if (!path) throw new Error(`resource was not materialized: ${name}`);
359
+ return path;
360
+ })
361
+ .replace(TEMP_TOKEN, (_, name) => {
362
+ const path = context.temporaryPaths[name];
363
+ if (!path) throw new Error(`temporary file was not materialized: ${name}`);
364
+ return path;
365
+ });
366
+ }
367
+
368
+ function redactOutput(buffer, context) {
369
+ let text = new TextDecoder("utf-8").decode(buffer);
370
+ for (const [name, path] of Object.entries(context.paths)) {
371
+ text = text.split(path).join(`<resource:${name}>`);
372
+ }
373
+ for (const [name, path] of Object.entries(context.temporaryPaths)) {
374
+ text = text.split(path).join(`<temp:${name}>`);
375
+ }
376
+ text = text.split(runtimeDir).join("<job-runtime>");
377
+ for (const [name, patterns] of Object.entries(context.redactions)) {
378
+ for (const value of patterns) text = text.split(value).join(`<redacted-resource:${name}>`);
379
+ }
380
+ return text;
381
+ }
382
+
383
+ function updateStatus(status, changes) {
384
+ Object.assign(status, changes, { runner_pid: process.pid, updated_at: new Date().toISOString() });
385
+ writeJson(statusFile, status, MAX_STATUS_BYTES);
386
+ }
387
+
388
+ function requestCancellation() {
389
+ cancelRequested = true;
390
+ const child = activeChild;
391
+ if (!child || !activeChildCancellationAware) return;
392
+ terminateProcessTree(child, "SIGTERM");
393
+ if (cancellationEscalation) return;
394
+ cancellationEscalation = setTimeout(() => {
395
+ if (activeChild === child && activeChildCancellationAware) terminateProcessTree(child, "SIGKILL");
396
+ }, 2000);
397
+ cancellationEscalation.unref?.();
398
+ }
399
+
400
+ function isCancellationRequested() {
401
+ return cancelRequested || existsSync(cancelFile);
402
+ }
403
+
404
+ function appendLimited(current, chunk, limit, budget) {
405
+ const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
406
+ const streamRemaining = Math.max(0, limit - current.length);
407
+ const jobRemaining = Math.max(0, Number(budget?.remaining || 0));
408
+ const acceptedLength = Math.min(input.length, streamRemaining, jobRemaining);
409
+ const accepted = input.subarray(0, acceptedLength);
410
+ if (budget) budget.remaining = Math.max(0, jobRemaining - acceptedLength);
411
+ return {
412
+ buffer: accepted.length ? (current.length ? Buffer.concat([current, accepted]) : Buffer.from(accepted)) : current,
413
+ truncated: input.length - accepted.length,
414
+ };
415
+ }
416
+
417
+ function writeJson(file, value, maxBytes) {
418
+ const text = `${JSON.stringify(value, null, 2)}\n`;
419
+ if (Buffer.byteLength(text) > maxBytes) throw new Error(`job JSON exceeds ${maxBytes} bytes`);
420
+ const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
421
+ writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
422
+ renameSync(temp, file);
423
+ chmodSync(file, 0o600);
424
+ }
425
+
426
+ function readJson(file, maxBytes) {
427
+ return JSON.parse(readBoundedFile(file, maxBytes).toString("utf8"));
428
+ }
429
+
430
+ function readBoundedFile(file, maxBytes) {
431
+ const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
432
+ const fd = openSync(file, flags);
433
+ try {
434
+ const info = fstatSync(fd);
435
+ if (!info.isFile()) throw new Error("path is not a regular file");
436
+ if (info.size > maxBytes) throw new Error(`file exceeds ${maxBytes} bytes`);
437
+ const buffer = Buffer.alloc(info.size);
438
+ let offset = 0;
439
+ while (offset < buffer.length) {
440
+ const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
441
+ if (!count) break;
442
+ offset += count;
443
+ }
444
+ return buffer.subarray(0, offset);
445
+ } finally {
446
+ closeSync(fd);
447
+ }
448
+ }
449
+
450
+ function classifyError(error) {
451
+ if (!error) return null;
452
+ if (error instanceof JobCancelledError) return "cancelled";
453
+ const message = String(error?.message || error);
454
+ if (/timed out/i.test(message)) return "timeout";
455
+ if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
456
+ if (/not found|ENOENT/i.test(message)) return "not_found";
457
+ if (/resource/i.test(message)) return "resource_error";
458
+ return "execution_failed";
459
+ }
460
+
461
+ function parseArgs(argv) {
462
+ const out = {};
463
+ for (let index = 0; index < argv.length; index += 1) {
464
+ const value = argv[index];
465
+ if (value === "--recover") out.recover = true;
466
+ else if (value === "--job-dir") out.jobDir = argv[++index];
467
+ else throw new Error(`unknown runner option: ${value}`);
468
+ }
469
+ return out;
470
+ }
package/src/local/log.mjs CHANGED
@@ -9,6 +9,11 @@ const COLORS = {
9
9
  gray: "\x1b[90m",
10
10
  };
11
11
 
12
+ const MAX_LOG_MESSAGE_CHARS = 2048;
13
+ const MAX_LOG_FIELD_CHARS = 4096;
14
+ const MAX_LOG_ARRAY_ITEMS = 32;
15
+ const MAX_LOG_OBJECT_KEYS = 48;
16
+ const LEVEL_RANK = Object.freeze({ debug: 10, info: 20, success: 20, warn: 30, error: 40 });
12
17
  const SENSITIVE_KEY = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
13
18
  const SECRET_VALUE = /\b(?:mcp_password|daemon_secret|token_version|mcp_at|mcp_code)_[A-Za-z0-9_-]+\b/g;
14
19
  const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
@@ -16,23 +21,26 @@ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
16
21
  export function createLogger(options = {}) {
17
22
  const quiet = Boolean(options.quiet);
18
23
  const verbose = Boolean(options.verbose);
19
- const component = sanitizeLogText(options.component ? String(options.component) : "cli");
24
+ const minimumLevel = normalizeLogLevel(options.level || (quiet ? "error" : verbose ? "debug" : "info"));
25
+ const component = sanitizeLogText(options.component ? String(options.component) : "cli", 128);
20
26
  const useColor = shouldUseColor(options);
27
+ const stdout = options.stderrOnly ? process.stderr : process.stdout;
21
28
 
22
29
  const write = (stream, level, label, color, message, fields) => {
23
- if (quiet && level !== "error") return;
24
- if (level === "debug" && !verbose) return;
30
+ if (LEVEL_RANK[level] < LEVEL_RANK[minimumLevel]) return;
25
31
  const prefix = useColor ? `${color}${label}${COLORS.reset}` : label;
26
32
  const suffix = formatFields(fields);
27
- stream.write(`${prefix} ${component}: ${sanitizeLogText(message)}${suffix}\n`);
33
+ stream.write(`${prefix} ${component}: ${sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS)}${suffix}\n`);
28
34
  };
29
35
 
30
36
  return {
37
+ verbose: minimumLevel === "debug",
38
+ level: minimumLevel,
31
39
  child(childComponent) {
32
40
  return createLogger({ ...options, component: childComponent });
33
41
  },
34
- info(message, fields) { write(process.stdout, "info", "[info]", COLORS.blue, message, fields); },
35
- success(message, fields) { write(process.stdout, "success", "[ok]", COLORS.green, message, fields); },
42
+ info(message, fields) { write(stdout, "info", "[info]", COLORS.blue, message, fields); },
43
+ success(message, fields) { write(stdout, "success", "[ok]", COLORS.green, message, fields); },
36
44
  warn(message, fields) { write(process.stderr, "warn", "[warn]", COLORS.yellow, message, fields); },
37
45
  error(message, fields) { write(process.stderr, "error", "[error]", COLORS.red, message, fields); },
38
46
  debug(message, fields) { write(process.stderr, "debug", "[debug]", COLORS.gray, message, fields); },
@@ -41,13 +49,41 @@ export function createLogger(options = {}) {
41
49
  };
42
50
  }
43
51
 
44
- export function redactSecret(value) {
45
- return value ? "<redacted>" : "<empty>";
52
+ export function normalizeLogLevel(value = "info") {
53
+ const level = String(value || "info").trim().toLowerCase();
54
+ if (!Object.prototype.hasOwnProperty.call(LEVEL_RANK, level) || level === "success") {
55
+ throw new Error("log level must be one of: error, warn, info, debug");
56
+ }
57
+ return level;
58
+ }
59
+
60
+
61
+ export function classifyOperationalError(error) {
62
+ const message = error instanceof Error ? error.message : String(error ?? "");
63
+ if (/cancel/i.test(message)) return "cancelled";
64
+ if (/timed out/i.test(message)) return "timeout";
65
+ if (/outside the configured workspace/i.test(message)) return "path_boundary";
66
+ if (/disabled|requires .* mode/i.test(message)) return "policy_denied";
67
+ if (/not found|ENOENT/i.test(message)) return "not_found";
68
+ if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
69
+ if (/maximum|exceeds|max_bytes|too many/i.test(message)) return "limit_exceeded";
70
+ if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
71
+ return "execution_failed";
46
72
  }
47
73
 
48
74
  export function formatFields(fields) {
49
- if (!fields || typeof fields !== "object" || !Object.keys(fields).length) return "";
50
- return ` ${JSON.stringify(sanitizeLogValue(fields))}`;
75
+ try {
76
+ if (!fields || typeof fields !== "object" || !Object.keys(fields).length) return "";
77
+ const sanitized = sanitizeLogValue(fields);
78
+ const json = JSON.stringify(sanitized);
79
+ if (json.length <= MAX_LOG_FIELD_CHARS) return ` ${json}`;
80
+ return ` ${JSON.stringify({
81
+ fields_truncated: true,
82
+ field_names: Object.keys(fields).slice(0, MAX_LOG_OBJECT_KEYS),
83
+ })}`;
84
+ } catch {
85
+ return ' {"fields_unavailable":true}';
86
+ }
51
87
  }
52
88
 
53
89
  export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth = 0) {
@@ -56,23 +92,28 @@ export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth =
56
92
  if (typeof value === "string") return sanitizeLogText(value);
57
93
  if (typeof value === "bigint") return value.toString();
58
94
  if (value === null || typeof value !== "object") return value;
59
- if (depth >= 8) return "<max-depth>";
95
+ if (depth >= 6) return "<max-depth>";
60
96
  if (seen.has(value)) return "<circular>";
61
97
  seen.add(value);
62
- if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeLogValue(item, "", seen, depth + 1));
98
+ if (Array.isArray(value)) return value.slice(0, MAX_LOG_ARRAY_ITEMS).map(item => sanitizeLogValue(item, "", seen, depth + 1));
63
99
  const out = {};
64
- for (const [childKey, childValue] of Object.entries(value).slice(0, 100)) {
100
+ for (const [childKey, childValue] of Object.entries(value).slice(0, MAX_LOG_OBJECT_KEYS)) {
65
101
  out[childKey] = sanitizeLogValue(childValue, childKey, seen, depth + 1);
66
102
  }
67
103
  return out;
68
104
  }
69
105
 
70
- export function sanitizeLogText(value) {
71
- return String(value ?? "")
106
+ export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
107
+ let raw;
108
+ try { raw = String(value ?? ""); } catch { raw = "<unprintable>"; }
109
+ const sanitized = raw
72
110
  .replace(SECRET_VALUE, "<redacted-secret>")
73
111
  .replace(BEARER_VALUE, "Bearer <redacted>")
74
112
  .replace(/[\r\n\t]/g, match => match === "\t" ? "\\t" : "\\n")
75
113
  .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "?");
114
+ if (!Number.isFinite(Number(maxChars)) || Number(maxChars) <= 0) return "";
115
+ const limit = Math.max(16, Number(maxChars));
116
+ return sanitized.length > limit ? `${sanitized.slice(0, limit - 1)}…` : sanitized;
76
117
  }
77
118
 
78
119
  function shouldUseColor(options) {