machine-bridge-mcp 0.5.0 → 0.6.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.
- package/CHANGELOG.md +68 -0
- package/README.md +113 -8
- package/SECURITY.md +42 -6
- package/docs/ARCHITECTURE.md +50 -9
- package/docs/CLIENTS.md +24 -3
- package/docs/LOGGING.md +12 -19
- package/docs/MANAGED_JOBS.md +282 -0
- package/docs/OPERATIONS.md +79 -10
- package/docs/TESTING.md +11 -2
- package/package.json +13 -6
- package/src/local/atomic-fs.mjs +37 -0
- package/src/local/cli.mjs +254 -7
- package/src/local/daemon.mjs +168 -6
- package/src/local/full-access-test.mjs +206 -0
- package/src/local/job-runner.mjs +471 -0
- package/src/local/managed-jobs.mjs +855 -0
- package/src/local/resource-operations.mjs +66 -0
- package/src/local/ssh-key.mjs +125 -0
- package/src/local/state.mjs +56 -12
- package/src/local/stdio.mjs +4 -6
- package/src/local/tools.mjs +37 -3
- package/src/shared/server-metadata.json +10 -1
- package/src/shared/tool-catalog.json +531 -0
- package/src/worker/index.ts +7 -1
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
8
|
+
|
|
9
|
+
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
10
|
+
const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
|
|
11
|
+
const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
|
|
12
|
+
const MAX_RESOURCE_BYTES = 1024 * 1024;
|
|
13
|
+
const MAX_JOB_RESOURCE_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
const MAX_RESOURCES = 64;
|
|
15
|
+
const MAX_JOBS = 50;
|
|
16
|
+
const JOB_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const MAX_PLAN_BYTES = 1024 * 1024;
|
|
18
|
+
const MAX_TEMPORARY_FILE_BYTES = 512 * 1024;
|
|
19
|
+
const MAX_RECOVERY_ATTEMPTS = 3;
|
|
20
|
+
const RUNNER_PATH = fileURLToPath(new URL("./job-runner.mjs", import.meta.url));
|
|
21
|
+
const ACTIVE_JOB_STATES = new Set(["queued", "running", "cleaning", "interrupted"]);
|
|
22
|
+
const PLAN_RETAINING_STATES = new Set(["staged", ...ACTIVE_JOB_STATES]);
|
|
23
|
+
|
|
24
|
+
export class ManagedJobManager {
|
|
25
|
+
constructor({ jobRoot, workspace, policy, resources = {}, resourceStatePath = "", logger = console, recover = true }) {
|
|
26
|
+
const jobRootInput = resolve(jobRoot);
|
|
27
|
+
ensureOwnerOnlyDir(jobRootInput);
|
|
28
|
+
this.jobRoot = realpathSync.native ? realpathSync.native(jobRootInput) : realpathSync(jobRootInput);
|
|
29
|
+
const workspaceInput = resolve(workspace);
|
|
30
|
+
this.workspace = realpathSync.native ? realpathSync.native(workspaceInput) : realpathSync(workspaceInput);
|
|
31
|
+
this.policy = policy;
|
|
32
|
+
this.resources = normalizeResourceRegistry(resources);
|
|
33
|
+
this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
this.prune();
|
|
36
|
+
if (recover) this.recoverInterruptedJobs();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
status() {
|
|
40
|
+
const jobs = this.list({ limit: MAX_JOBS }).jobs;
|
|
41
|
+
return {
|
|
42
|
+
active: jobs.filter((job) => ACTIVE_JOB_STATES.has(job.status)).length,
|
|
43
|
+
staged: jobs.filter((job) => job.status === "staged").length,
|
|
44
|
+
retained: jobs.length,
|
|
45
|
+
maximum: MAX_JOBS,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
resourceInfo() {
|
|
50
|
+
const resources = this.currentResources();
|
|
51
|
+
return {
|
|
52
|
+
count: Object.keys(resources).length,
|
|
53
|
+
names: Object.keys(resources).sort(),
|
|
54
|
+
values_exposed: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
listResources() {
|
|
59
|
+
const resources = [];
|
|
60
|
+
for (const [name, resource] of Object.entries(this.currentResources()).sort(([a], [b]) => a.localeCompare(b))) {
|
|
61
|
+
try {
|
|
62
|
+
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
|
|
63
|
+
resources.push({ name, kind: "file", available: true, size: inspected.size, mode: inspected.mode });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
resources.push({ name, kind: "file", available: false, error_class: resourceErrorClass(error) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { resources, count: resources.length, values_exposed: false, paths_exposed: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
diagnoseStorage() {
|
|
72
|
+
const probe = join(this.jobRoot, `.probe-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
73
|
+
try {
|
|
74
|
+
writeFileSync(probe, "ok\n", { mode: 0o600, flag: "wx" });
|
|
75
|
+
const content = readBoundedFile(probe, 64).toString("utf8");
|
|
76
|
+
return { ok: content === "ok\n", error_class: content === "ok\n" ? null : "storage_mismatch" };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { ok: false, error_class: resourceErrorClass(error) };
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(probe, { force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
stage(args = {}) {
|
|
86
|
+
if (this.policy.allowWrite !== true) throw new Error("stage_job is disabled by daemon policy");
|
|
87
|
+
return this.createJob(args, { launch: false });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
start(args = {}) {
|
|
91
|
+
this.assertEnabled("start_job");
|
|
92
|
+
return this.createJob(args, { launch: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
approve(args = {}, { localOperator = false } = {}) {
|
|
96
|
+
if (!localOperator) this.assertEnabled("approve_job");
|
|
97
|
+
const dir = this.jobDir(args.job_id);
|
|
98
|
+
const statusFile = join(dir, "status.json");
|
|
99
|
+
const status = readRequiredJson(statusFile, 256 * 1024, "job status");
|
|
100
|
+
if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
|
|
101
|
+
readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
|
|
102
|
+
status.status = "queued";
|
|
103
|
+
status.updated_at = new Date().toISOString();
|
|
104
|
+
status.approved_at = status.updated_at;
|
|
105
|
+
status.approval = localOperator ? "local-operator" : "mcp";
|
|
106
|
+
status.cleanup_guarantee = "best-effort-finally-and-recovery";
|
|
107
|
+
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
108
|
+
try {
|
|
109
|
+
launchRunner(dir);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
failRunnerLaunch(dir, status, error);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
accepted: true,
|
|
116
|
+
job_id: status.job_id,
|
|
117
|
+
name: status.name,
|
|
118
|
+
status: "queued",
|
|
119
|
+
detached: true,
|
|
120
|
+
continues_without_mcp_connection: true,
|
|
121
|
+
approval: status.approval,
|
|
122
|
+
cleanup: {
|
|
123
|
+
resource_copies: "best-effort",
|
|
124
|
+
finally_steps: "best-effort-if-declared",
|
|
125
|
+
restart_recovery: "best-effort-on-next-runtime-or-cli-start",
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
createJob(args, { launch }) {
|
|
131
|
+
this.prune();
|
|
132
|
+
const retained = safeReadDir(this.jobRoot).filter((entry) => entry.isDirectory() && JOB_ID.test(entry.name)).length;
|
|
133
|
+
if (retained >= MAX_JOBS) throw new Error(`managed job limit reached (${MAX_JOBS})`);
|
|
134
|
+
const plan = validatePlan(args, {
|
|
135
|
+
workspace: this.workspace,
|
|
136
|
+
resources: this.currentResources(),
|
|
137
|
+
fullEnv: this.policy.minimalEnv === false,
|
|
138
|
+
unrestrictedPaths: this.policy.unrestrictedPaths === true,
|
|
139
|
+
});
|
|
140
|
+
const planSha256 = createHash("sha256").update(JSON.stringify(plan)).digest("hex");
|
|
141
|
+
const id = `job_${randomBytes(24).toString("base64url")}`;
|
|
142
|
+
const dir = join(this.jobRoot, id);
|
|
143
|
+
ensureOwnerOnlyDir(dir);
|
|
144
|
+
atomicWriteJson(join(dir, "plan.json"), plan, MAX_PLAN_BYTES);
|
|
145
|
+
const now = new Date().toISOString();
|
|
146
|
+
const status = {
|
|
147
|
+
job_id: id,
|
|
148
|
+
name: plan.name,
|
|
149
|
+
status: launch ? "queued" : "staged",
|
|
150
|
+
created_at: now,
|
|
151
|
+
updated_at: now,
|
|
152
|
+
current_phase: null,
|
|
153
|
+
current_step: null,
|
|
154
|
+
runner_pid: null,
|
|
155
|
+
approval: launch ? "mcp" : "pending-local-operator",
|
|
156
|
+
plan_sha256: planSha256,
|
|
157
|
+
cleanup_guarantee: launch ? "best-effort-finally-and-recovery" : "not-started",
|
|
158
|
+
};
|
|
159
|
+
atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
|
|
160
|
+
if (launch) {
|
|
161
|
+
try {
|
|
162
|
+
launchRunner(dir);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
failRunnerLaunch(dir, status, error);
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return launch ? {
|
|
169
|
+
accepted: true,
|
|
170
|
+
job_id: id,
|
|
171
|
+
name: plan.name,
|
|
172
|
+
status: "queued",
|
|
173
|
+
detached: true,
|
|
174
|
+
continues_without_mcp_connection: true,
|
|
175
|
+
approval: "mcp",
|
|
176
|
+
plan_sha256: planSha256,
|
|
177
|
+
cleanup: {
|
|
178
|
+
resource_copies: "best-effort",
|
|
179
|
+
finally_steps: plan.finally_steps.length ? "best-effort" : "none-declared",
|
|
180
|
+
restart_recovery: "best-effort-on-next-runtime-or-cli-start",
|
|
181
|
+
},
|
|
182
|
+
} : {
|
|
183
|
+
staged: true,
|
|
184
|
+
job_id: id,
|
|
185
|
+
name: plan.name,
|
|
186
|
+
status: "staged",
|
|
187
|
+
execution_started: false,
|
|
188
|
+
plan_sha256: planSha256,
|
|
189
|
+
local_inspection_command: `machine-mcp job inspect ${id}`,
|
|
190
|
+
local_approval_command: `machine-mcp job approve ${id}`,
|
|
191
|
+
plan_expires_after_days: 7,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
list(args = {}) {
|
|
197
|
+
this.prune();
|
|
198
|
+
const limit = clampInt(args.limit, 20, 1, MAX_JOBS);
|
|
199
|
+
const jobs = [];
|
|
200
|
+
for (const entry of safeReadDir(this.jobRoot)) {
|
|
201
|
+
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
202
|
+
const dir = join(this.jobRoot, entry.name);
|
|
203
|
+
this.reconcileStatus(dir);
|
|
204
|
+
const status = readJson(join(dir, "status.json"), 256 * 1024);
|
|
205
|
+
if (!status) continue;
|
|
206
|
+
jobs.push(publicStatus(status));
|
|
207
|
+
}
|
|
208
|
+
jobs.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
|
|
209
|
+
return { jobs: jobs.slice(0, limit), retained: jobs.length, maximum: MAX_JOBS };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
read(args = {}) {
|
|
213
|
+
const dir = this.jobDir(args.job_id);
|
|
214
|
+
this.reconcileStatus(dir);
|
|
215
|
+
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
216
|
+
const result = readJson(join(dir, "result.json"), 4 * 1024 * 1024);
|
|
217
|
+
return {
|
|
218
|
+
...publicStatus(status),
|
|
219
|
+
...(result ? { result } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
inspectLocal(args = {}) {
|
|
224
|
+
const dir = this.jobDir(args.job_id);
|
|
225
|
+
this.reconcileStatus(dir);
|
|
226
|
+
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
227
|
+
const plan = readJson(join(dir, "plan.json"), MAX_PLAN_BYTES);
|
|
228
|
+
return {
|
|
229
|
+
...publicStatus(status),
|
|
230
|
+
...(plan ? { review_plan: reviewablePlan(plan) } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
cancel(args = {}) {
|
|
235
|
+
const dir = this.jobDir(args.job_id);
|
|
236
|
+
this.reconcileStatus(dir);
|
|
237
|
+
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
238
|
+
if (status.status === "staged") {
|
|
239
|
+
const now = new Date().toISOString();
|
|
240
|
+
status.status = "cancelled_before_start";
|
|
241
|
+
status.updated_at = now;
|
|
242
|
+
status.finished_at = now;
|
|
243
|
+
status.error_class = "cancelled";
|
|
244
|
+
status.cleanup_guarantee = "not-started";
|
|
245
|
+
atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
|
|
246
|
+
atomicWriteJson(join(dir, "result.json"), {
|
|
247
|
+
job_id: status.job_id,
|
|
248
|
+
name: status.name,
|
|
249
|
+
status: status.status,
|
|
250
|
+
steps: [],
|
|
251
|
+
finally_steps: [],
|
|
252
|
+
error_class: "cancelled",
|
|
253
|
+
cleanup_error_class: null,
|
|
254
|
+
finished_at: now,
|
|
255
|
+
}, 4 * 1024 * 1024);
|
|
256
|
+
scrubFinishedPlan(dir, status);
|
|
257
|
+
return { ...publicStatus(status), cancellation_requested: true, cleanup_will_run: false, execution_started: false };
|
|
258
|
+
}
|
|
259
|
+
if (!ACTIVE_JOB_STATES.has(status.status)) {
|
|
260
|
+
return { ...publicStatus(status), cancellation_requested: false, already_finished: true };
|
|
261
|
+
}
|
|
262
|
+
writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
263
|
+
return {
|
|
264
|
+
...publicStatus(status),
|
|
265
|
+
cancellation_requested: true,
|
|
266
|
+
cancellation_delivery: "runner-poll",
|
|
267
|
+
cleanup_will_run: true,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
currentResources() {
|
|
273
|
+
if (!this.resourceStatePath) return this.resources;
|
|
274
|
+
const state = readJson(this.resourceStatePath, 2 * 1024 * 1024);
|
|
275
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return existsSync(this.resourceStatePath) ? {} : this.resources;
|
|
276
|
+
return normalizeResourceRegistry(state.resources);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
assertEnabled(tool) {
|
|
280
|
+
if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") {
|
|
281
|
+
throw new Error(`${tool} is disabled by daemon policy`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
jobDir(value) {
|
|
286
|
+
const id = String(value || "");
|
|
287
|
+
if (!JOB_ID.test(id)) throw new Error("invalid job id");
|
|
288
|
+
const dir = join(this.jobRoot, id);
|
|
289
|
+
if (!existsSync(dir)) throw new Error("job not found or expired");
|
|
290
|
+
return dir;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
reconcileStatus(dir) {
|
|
294
|
+
const file = join(dir, "status.json");
|
|
295
|
+
const initial = readJson(file, 256 * 1024);
|
|
296
|
+
if (!initial || !ACTIVE_JOB_STATES.has(initial.status)) {
|
|
297
|
+
if (initial) scrubFinishedPlan(dir, initial);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const initialPid = Number(initial.runner_pid) || readRunnerPid(dir);
|
|
301
|
+
if (Number.isInteger(initialPid) && initialPid > 0 && isPidAlive(initialPid)) return;
|
|
302
|
+
const updated = Date.parse(initial.updated_at || initial.created_at || "");
|
|
303
|
+
if (Number.isFinite(updated) && Date.now() - updated < 10_000) return;
|
|
304
|
+
|
|
305
|
+
const recoveryLock = acquireRecoveryLock(dir);
|
|
306
|
+
if (!recoveryLock) return;
|
|
307
|
+
let handedOff = false;
|
|
308
|
+
try {
|
|
309
|
+
const status = readJson(file, 256 * 1024);
|
|
310
|
+
if (!status || !ACTIVE_JOB_STATES.has(status.status)) return;
|
|
311
|
+
const pid = Number(status.runner_pid) || readRunnerPid(dir);
|
|
312
|
+
if (Number.isInteger(pid) && pid > 0 && isPidAlive(pid)) return;
|
|
313
|
+
const recoveryAttempts = Number(status.recovery_attempts || 0);
|
|
314
|
+
if (recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
|
|
315
|
+
const now = new Date().toISOString();
|
|
316
|
+
status.status = "recovery_exhausted";
|
|
317
|
+
status.updated_at = now;
|
|
318
|
+
status.finished_at = now;
|
|
319
|
+
status.error_class = "recovery_exhausted";
|
|
320
|
+
status.current_phase = null;
|
|
321
|
+
status.current_step = null;
|
|
322
|
+
atomicWriteJson(file, status, 256 * 1024);
|
|
323
|
+
atomicWriteJson(join(dir, "result.json"), {
|
|
324
|
+
job_id: status.job_id,
|
|
325
|
+
name: status.name,
|
|
326
|
+
status: status.status,
|
|
327
|
+
recovered: true,
|
|
328
|
+
steps: [],
|
|
329
|
+
finally_steps: [],
|
|
330
|
+
error_class: "recovery_exhausted",
|
|
331
|
+
cleanup_error_class: "recovery_exhausted",
|
|
332
|
+
recovery_attempts: recoveryAttempts,
|
|
333
|
+
finished_at: now,
|
|
334
|
+
}, 4 * 1024 * 1024);
|
|
335
|
+
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
336
|
+
scrubFinishedPlan(dir, status);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
status.status = "interrupted";
|
|
340
|
+
status.updated_at = new Date().toISOString();
|
|
341
|
+
status.finished_at = status.updated_at;
|
|
342
|
+
status.error_class = "runner_interrupted";
|
|
343
|
+
status.recovery_attempts = recoveryAttempts + 1;
|
|
344
|
+
atomicWriteJson(file, status, 256 * 1024);
|
|
345
|
+
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
346
|
+
const runnerPid = launchRunner(dir, true);
|
|
347
|
+
recoveryLock.handoff(runnerPid);
|
|
348
|
+
handedOff = true;
|
|
349
|
+
} finally {
|
|
350
|
+
if (!handedOff) recoveryLock.release();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
recoverInterruptedJobs() {
|
|
355
|
+
for (const entry of safeReadDir(this.jobRoot)) {
|
|
356
|
+
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
357
|
+
this.reconcileStatus(join(this.jobRoot, entry.name));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
prune() {
|
|
362
|
+
const entries = [];
|
|
363
|
+
for (const entry of safeReadDir(this.jobRoot)) {
|
|
364
|
+
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
365
|
+
const dir = join(this.jobRoot, entry.name);
|
|
366
|
+
const status = readJson(join(dir, "status.json"), 256 * 1024);
|
|
367
|
+
const mtime = safeMtime(dir);
|
|
368
|
+
if (!status) {
|
|
369
|
+
const pid = readRunnerPid(dir);
|
|
370
|
+
if ((!pid || !isPidAlive(pid)) && Date.now() - mtime > 60_000) {
|
|
371
|
+
rmSync(dir, { recursive: true, force: true });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (status && !PLAN_RETAINING_STATES.has(status.status)) scrubFinishedPlan(dir, status);
|
|
376
|
+
entries.push({ dir, status, mtime });
|
|
377
|
+
}
|
|
378
|
+
const finished = entries
|
|
379
|
+
.filter(({ status }) => status && !ACTIVE_JOB_STATES.has(status.status))
|
|
380
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
for (const item of finished) {
|
|
383
|
+
if (now - item.mtime > JOB_RETENTION_MS) rmSync(item.dir, { recursive: true, force: true });
|
|
384
|
+
}
|
|
385
|
+
const remaining = entries.filter((item) => existsSync(item.dir));
|
|
386
|
+
if (remaining.length <= MAX_JOBS) return;
|
|
387
|
+
const removable = remaining
|
|
388
|
+
.filter(({ status }) => status && !ACTIVE_JOB_STATES.has(status.status))
|
|
389
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
390
|
+
for (const item of removable) {
|
|
391
|
+
if (safeReadDir(this.jobRoot).filter((entry) => entry.isDirectory() && JOB_ID.test(entry.name)).length <= MAX_JOBS) break;
|
|
392
|
+
rmSync(item.dir, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function activeManagedJobs(jobRoot) {
|
|
398
|
+
const root = resolve(jobRoot);
|
|
399
|
+
const jobs = [];
|
|
400
|
+
for (const entry of safeReadDir(root)) {
|
|
401
|
+
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
402
|
+
const dir = join(root, entry.name);
|
|
403
|
+
const status = readJson(join(dir, "status.json"), 256 * 1024);
|
|
404
|
+
const pid = Number(status?.runner_pid) || readRunnerPid(dir);
|
|
405
|
+
const runnerAlive = Number.isInteger(pid) && pid > 0 && isPidAlive(pid);
|
|
406
|
+
const lifecycleActive = status && ACTIVE_JOB_STATES.has(status.status);
|
|
407
|
+
if (runnerAlive || lifecycleActive) {
|
|
408
|
+
jobs.push({
|
|
409
|
+
job_id: entry.name,
|
|
410
|
+
status: status?.status || "unknown",
|
|
411
|
+
runner_alive: runnerAlive,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return jobs;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function loadManagedJobPlan(inputPath) {
|
|
419
|
+
const path = resolve(String(inputPath || ""));
|
|
420
|
+
const linkInfo = lstatSync(path);
|
|
421
|
+
if (linkInfo.isSymbolicLink()) throw new Error("job plan must not be a symbolic link");
|
|
422
|
+
if (!linkInfo.isFile()) throw new Error("job plan is not a regular file");
|
|
423
|
+
let value;
|
|
424
|
+
try { value = JSON.parse(readBoundedFile(path, MAX_PLAN_BYTES).toString("utf8")); } catch (error) {
|
|
425
|
+
if (/exceeds/.test(String(error?.message || error))) throw error;
|
|
426
|
+
throw new Error("job plan is not valid JSON");
|
|
427
|
+
}
|
|
428
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("job plan must contain a JSON object");
|
|
429
|
+
return value;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function validateResourceName(value) {
|
|
433
|
+
const name = String(value || "").trim();
|
|
434
|
+
if (!RESOURCE_NAME.test(name)) throw new Error("resource name must match [a-z][a-z0-9._-]{0,63}");
|
|
435
|
+
return name;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function inspectResourceFile(inputPath, { allowInsecurePermissions = false, includeHash = false } = {}) {
|
|
439
|
+
const path = resolve(String(inputPath || ""));
|
|
440
|
+
const canonical = realpathFile(path);
|
|
441
|
+
const { buffer: content, info } = readBoundedFileWithInfo(canonical, MAX_RESOURCE_BYTES);
|
|
442
|
+
if (process.platform !== "win32" && !allowInsecurePermissions && (info.mode & 0o077) !== 0) {
|
|
443
|
+
throw new Error("resource file is readable by group or others; restrict permissions or use --allow-insecure-permissions");
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
kind: "file",
|
|
447
|
+
path: canonical,
|
|
448
|
+
size: info.size,
|
|
449
|
+
mode: process.platform === "win32" ? null : `0${(info.mode & 0o777).toString(8)}`,
|
|
450
|
+
updatedAt: new Date().toISOString(),
|
|
451
|
+
allowInsecurePermissions: allowInsecurePermissions === true,
|
|
452
|
+
...(includeHash ? { sha256: createHash("sha256").update(content).digest("hex") } : {}),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function publicResourceRegistry(resources = {}) {
|
|
457
|
+
const normalized = normalizeResourceRegistry(resources);
|
|
458
|
+
return Object.fromEntries(Object.entries(normalized).map(([name, value]) => [name, {
|
|
459
|
+
kind: value.kind,
|
|
460
|
+
path: value.path,
|
|
461
|
+
size: value.size ?? null,
|
|
462
|
+
mode: value.mode ?? null,
|
|
463
|
+
updatedAt: value.updatedAt ?? null,
|
|
464
|
+
allowInsecurePermissions: value.allowInsecurePermissions === true,
|
|
465
|
+
}]));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function validatePlan(args, context) {
|
|
469
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("job arguments must be an object");
|
|
470
|
+
const allowed = new Set(["name", "steps", "finally_steps", "temporary_files"]);
|
|
471
|
+
for (const key of Object.keys(args)) if (!allowed.has(key)) throw new Error(`job contains unknown field: ${key}`);
|
|
472
|
+
const name = String(args.name || "managed job").trim().slice(0, 128) || "managed job";
|
|
473
|
+
const steps = validateSteps(args.steps, "steps", context);
|
|
474
|
+
const finallySteps = validateSteps(args.finally_steps || [], "finally_steps", context, true);
|
|
475
|
+
const temporaryFiles = validateTemporaryFiles(args.temporary_files || []);
|
|
476
|
+
if (!steps.length) throw new Error("steps must contain at least one step");
|
|
477
|
+
return {
|
|
478
|
+
version: 1,
|
|
479
|
+
name,
|
|
480
|
+
workspace: context.workspace,
|
|
481
|
+
full_env: context.fullEnv,
|
|
482
|
+
resources: referencedResources([...steps, ...finallySteps], context.resources),
|
|
483
|
+
temporary_files: temporaryFiles,
|
|
484
|
+
steps,
|
|
485
|
+
finally_steps: finallySteps,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function validateTemporaryFiles(value) {
|
|
490
|
+
if (!Array.isArray(value) || value.length > 16) throw new Error("temporary_files must contain 0-16 files");
|
|
491
|
+
const seen = new Set();
|
|
492
|
+
let totalBytes = 0;
|
|
493
|
+
return value.map((item, index) => {
|
|
494
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`temporary_files[${index}] must be an object`);
|
|
495
|
+
for (const key of Object.keys(item)) if (!["name", "content", "executable"].includes(key)) throw new Error(`temporary_files[${index}] contains unknown field: ${key}`);
|
|
496
|
+
const name = validateResourceName(item.name);
|
|
497
|
+
if (seen.has(name)) throw new Error(`duplicate temporary file name: ${name}`);
|
|
498
|
+
seen.add(name);
|
|
499
|
+
const content = boundedString(item.content, 256 * 1024, `temporary_files[${index}].content`);
|
|
500
|
+
totalBytes += Buffer.byteLength(content);
|
|
501
|
+
if (totalBytes > MAX_TEMPORARY_FILE_BYTES) throw new Error(`temporary file contents exceed ${MAX_TEMPORARY_FILE_BYTES} bytes`);
|
|
502
|
+
return { name, content, executable: item.executable === true };
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function validateSteps(value, label, context, allowEmpty = false) {
|
|
507
|
+
if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || value.length > 16) {
|
|
508
|
+
throw new Error(`${label} must contain ${allowEmpty ? "0-16" : "1-16"} steps`);
|
|
509
|
+
}
|
|
510
|
+
return value.map((input, index) => {
|
|
511
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`${label}[${index}] must be an object`);
|
|
512
|
+
const allowed = new Set(["name", "argv", "cwd", "env", "env_resources", "stdin", "stdin_resource", "timeout_seconds", "allow_failure", "capture_output"]);
|
|
513
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`${label}[${index}] contains unknown field: ${key}`);
|
|
514
|
+
if (!Array.isArray(input.argv) || !input.argv.length || input.argv.length > 256) throw new Error(`${label}[${index}].argv must contain 1-256 strings`);
|
|
515
|
+
const argv = input.argv.map((item) => boundedString(item, 16 * 1024, `${label}[${index}].argv`));
|
|
516
|
+
if (Buffer.byteLength(JSON.stringify(argv)) > 64 * 1024) throw new Error(`${label}[${index}].argv exceeds 64 KiB`);
|
|
517
|
+
const cwd = input.cwd === undefined ? context.workspace : resolveJobCwd(input.cwd, context.workspace, context.unrestrictedPaths);
|
|
518
|
+
const env = validateEnv(input.env, `${label}[${index}].env`);
|
|
519
|
+
const envResources = validateEnvResources(input.env_resources, `${label}[${index}].env_resources`);
|
|
520
|
+
for (const key of Object.keys(envResources)) if (Object.prototype.hasOwnProperty.call(env, key)) throw new Error(`${label}[${index}] duplicates ${key} in env and env_resources`);
|
|
521
|
+
const stdin = input.stdin === undefined ? null : boundedString(input.stdin, 256 * 1024, `${label}[${index}].stdin`);
|
|
522
|
+
const stdinResource = input.stdin_resource === undefined ? null : validateResourceName(input.stdin_resource);
|
|
523
|
+
if (stdin !== null && stdinResource !== null) throw new Error(`${label}[${index}] cannot combine stdin and stdin_resource`);
|
|
524
|
+
return {
|
|
525
|
+
name: String(input.name || basename(argv[0]) || `step ${index + 1}`).slice(0, 128),
|
|
526
|
+
argv,
|
|
527
|
+
cwd,
|
|
528
|
+
env,
|
|
529
|
+
env_resources: envResources,
|
|
530
|
+
stdin,
|
|
531
|
+
stdin_resource: stdinResource,
|
|
532
|
+
timeout_seconds: clampInt(input.timeout_seconds, 600, 1, 3600),
|
|
533
|
+
allow_failure: input.allow_failure === true,
|
|
534
|
+
capture_output: input.capture_output === "discard" ? "discard" : "redacted",
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function validateEnv(value, label) {
|
|
540
|
+
if (value === undefined) return {};
|
|
541
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
542
|
+
const entries = Object.entries(value);
|
|
543
|
+
if (entries.length > 64) throw new Error(`${label} has too many entries`);
|
|
544
|
+
const out = {};
|
|
545
|
+
for (const [key, raw] of entries) {
|
|
546
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) throw new Error(`${label} contains invalid variable name: ${key}`);
|
|
547
|
+
out[key] = boundedString(raw, 16 * 1024, `${label}.${key}`);
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function validateEnvResources(value, label) {
|
|
553
|
+
if (value === undefined) return {};
|
|
554
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
555
|
+
const entries = Object.entries(value);
|
|
556
|
+
if (entries.length > 32) throw new Error(`${label} has too many entries`);
|
|
557
|
+
const out = {};
|
|
558
|
+
for (const [key, raw] of entries) {
|
|
559
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) throw new Error(`${label} contains invalid variable name: ${key}`);
|
|
560
|
+
out[key] = validateResourceName(raw);
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function referencedResources(steps, registry) {
|
|
566
|
+
const names = new Set();
|
|
567
|
+
for (const step of steps) {
|
|
568
|
+
if (step.stdin_resource) names.add(step.stdin_resource);
|
|
569
|
+
for (const name of Object.values(step.env_resources || {})) names.add(name);
|
|
570
|
+
for (const value of [...step.argv, ...Object.values(step.env)]) {
|
|
571
|
+
for (const match of String(value).matchAll(RESOURCE_TOKEN)) names.add(match[1]);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (names.size > MAX_RESOURCES) throw new Error(`job references more than ${MAX_RESOURCES} local resources`);
|
|
575
|
+
const out = {};
|
|
576
|
+
let totalBytes = 0;
|
|
577
|
+
for (const name of names) {
|
|
578
|
+
const resource = registry[name];
|
|
579
|
+
if (!resource) throw new Error(`unknown local resource: ${name}`);
|
|
580
|
+
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
|
|
581
|
+
totalBytes += inspected.size;
|
|
582
|
+
if (totalBytes > MAX_JOB_RESOURCE_BYTES) throw new Error(`job resources exceed ${MAX_JOB_RESOURCE_BYTES} bytes`);
|
|
583
|
+
out[name] = { ...inspected, allowInsecurePermissions: resource.allowInsecurePermissions === true };
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function normalizeResourceRegistry(resources) {
|
|
589
|
+
const out = {};
|
|
590
|
+
if (!resources || typeof resources !== "object" || Array.isArray(resources)) return out;
|
|
591
|
+
for (const [rawName, rawValue] of Object.entries(resources).slice(0, MAX_RESOURCES)) {
|
|
592
|
+
const name = validateResourceName(rawName);
|
|
593
|
+
if (!rawValue || rawValue.kind !== "file" || typeof rawValue.path !== "string") continue;
|
|
594
|
+
out[name] = {
|
|
595
|
+
kind: "file",
|
|
596
|
+
path: resolve(rawValue.path),
|
|
597
|
+
size: Number.isFinite(Number(rawValue.size)) ? Number(rawValue.size) : null,
|
|
598
|
+
mode: rawValue.mode ?? null,
|
|
599
|
+
updatedAt: rawValue.updatedAt ?? null,
|
|
600
|
+
allowInsecurePermissions: rawValue.allowInsecurePermissions === true,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function resolveJobCwd(value, workspace, unrestrictedPaths) {
|
|
607
|
+
const raw = boundedString(value, 4096, "cwd");
|
|
608
|
+
const candidate = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
|
|
609
|
+
const canonical = realpathSync.native ? realpathSync.native(candidate) : realpathSync(candidate);
|
|
610
|
+
const info = statSync(canonical);
|
|
611
|
+
if (!info.isDirectory()) throw new Error("managed job cwd is not a directory");
|
|
612
|
+
if (!unrestrictedPaths) {
|
|
613
|
+
const rel = relative(workspace, canonical);
|
|
614
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("managed job cwd is outside the configured workspace");
|
|
615
|
+
}
|
|
616
|
+
return canonical;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function failRunnerLaunch(dir, status, error) {
|
|
620
|
+
const now = new Date().toISOString();
|
|
621
|
+
const failed = {
|
|
622
|
+
...status,
|
|
623
|
+
status: "runner_launch_failed",
|
|
624
|
+
updated_at: now,
|
|
625
|
+
finished_at: now,
|
|
626
|
+
error_class: resourceErrorClass(error),
|
|
627
|
+
cleanup_guarantee: "not-started",
|
|
628
|
+
};
|
|
629
|
+
atomicWriteJson(join(dir, "status.json"), failed, 256 * 1024);
|
|
630
|
+
atomicWriteJson(join(dir, "result.json"), {
|
|
631
|
+
job_id: failed.job_id,
|
|
632
|
+
name: failed.name,
|
|
633
|
+
status: failed.status,
|
|
634
|
+
steps: [],
|
|
635
|
+
finally_steps: [],
|
|
636
|
+
error_class: failed.error_class,
|
|
637
|
+
cleanup_error_class: null,
|
|
638
|
+
finished_at: now,
|
|
639
|
+
}, 4 * 1024 * 1024);
|
|
640
|
+
scrubFinishedPlan(dir, failed);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function acquireRecoveryLock(dir) {
|
|
644
|
+
const file = join(dir, "recovery.lock");
|
|
645
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
646
|
+
try {
|
|
647
|
+
writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
|
|
648
|
+
return {
|
|
649
|
+
handoff(pid) {
|
|
650
|
+
if (Number.isInteger(pid) && pid > 0) writeFileSync(file, `${pid}\n`, { mode: 0o600 });
|
|
651
|
+
},
|
|
652
|
+
release() { rmSync(file, { force: true }); },
|
|
653
|
+
};
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (error?.code !== "EEXIST") throw error;
|
|
656
|
+
let owner = 0;
|
|
657
|
+
try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
|
|
658
|
+
const age = Date.now() - safeMtime(file);
|
|
659
|
+
if ((owner > 0 && isPidAlive(owner)) || age < 60_000) return null;
|
|
660
|
+
rmSync(file, { force: true });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function launchRunner(dir, recover = false) {
|
|
667
|
+
const args = [RUNNER_PATH, "--job-dir", dir];
|
|
668
|
+
if (recover) args.push("--recover");
|
|
669
|
+
const stdoutFile = join(dir, "runner.out.log");
|
|
670
|
+
const stderrFile = join(dir, "runner.err.log");
|
|
671
|
+
trimDiagnosticFile(stdoutFile);
|
|
672
|
+
trimDiagnosticFile(stderrFile);
|
|
673
|
+
const stdoutFd = openSync(stdoutFile, "a", 0o600);
|
|
674
|
+
const stderrFd = openSync(stderrFile, "a", 0o600);
|
|
675
|
+
let child;
|
|
676
|
+
try {
|
|
677
|
+
child = spawn(process.execPath, args, {
|
|
678
|
+
detached: true,
|
|
679
|
+
stdio: ["ignore", stdoutFd, stderrFd],
|
|
680
|
+
windowsHide: true,
|
|
681
|
+
});
|
|
682
|
+
} finally {
|
|
683
|
+
closeSync(stdoutFd);
|
|
684
|
+
closeSync(stderrFd);
|
|
685
|
+
}
|
|
686
|
+
ownerOnlyFile(stdoutFile);
|
|
687
|
+
ownerOnlyFile(stderrFile);
|
|
688
|
+
child.unref();
|
|
689
|
+
return child.pid;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
function readRunnerPid(dir) {
|
|
694
|
+
try {
|
|
695
|
+
const value = Number.parseInt(readBoundedFile(join(dir, "runner.pid"), 64).toString("utf8").trim(), 10);
|
|
696
|
+
return Number.isInteger(value) && value > 0 ? value : 0;
|
|
697
|
+
} catch {
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function scrubFinishedPlan(dir, status) {
|
|
703
|
+
if (PLAN_RETAINING_STATES.has(status.status)) return;
|
|
704
|
+
rmSync(join(dir, "plan.json"), { force: true });
|
|
705
|
+
rmSync(join(dir, "runner.pid"), { force: true });
|
|
706
|
+
rmSync(join(dir, "recovery.lock"), { force: true });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function reviewablePlan(plan) {
|
|
710
|
+
return {
|
|
711
|
+
version: plan.version,
|
|
712
|
+
name: plan.name,
|
|
713
|
+
workspace: plan.workspace,
|
|
714
|
+
full_env: plan.full_env === true,
|
|
715
|
+
resources: Object.fromEntries(Object.entries(plan.resources || {}).map(([name, value]) => [name, {
|
|
716
|
+
kind: value.kind,
|
|
717
|
+
size: value.size ?? null,
|
|
718
|
+
mode: value.mode ?? null,
|
|
719
|
+
allow_insecure_permissions: value.allowInsecurePermissions === true,
|
|
720
|
+
}])),
|
|
721
|
+
temporary_files: plan.temporary_files || [],
|
|
722
|
+
steps: plan.steps || [],
|
|
723
|
+
finally_steps: plan.finally_steps || [],
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function publicStatus(status) {
|
|
728
|
+
return {
|
|
729
|
+
job_id: status.job_id,
|
|
730
|
+
name: status.name,
|
|
731
|
+
status: status.status,
|
|
732
|
+
created_at: status.created_at,
|
|
733
|
+
started_at: status.started_at ?? null,
|
|
734
|
+
finished_at: status.finished_at ?? null,
|
|
735
|
+
current_phase: status.current_phase ?? null,
|
|
736
|
+
current_step: status.current_step ?? null,
|
|
737
|
+
approval: status.approval ?? null,
|
|
738
|
+
plan_sha256: status.plan_sha256 ?? null,
|
|
739
|
+
cleanup_guarantee: status.cleanup_guarantee ?? "best-effort-finally-and-recovery",
|
|
740
|
+
error_class: status.error_class ?? null,
|
|
741
|
+
recovery_attempts: Number(status.recovery_attempts || 0),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function atomicWriteJson(file, value, maxBytes) {
|
|
746
|
+
ensureOwnerOnlyDir(dirname(file));
|
|
747
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
748
|
+
if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
|
|
749
|
+
const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
750
|
+
writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
|
|
751
|
+
replaceFileSync(temp, file);
|
|
752
|
+
ownerOnlyFile(file);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function readJson(file, maxBytes) {
|
|
756
|
+
try { return JSON.parse(readBoundedFile(file, maxBytes).toString("utf8")); } catch { return null; }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function readRequiredJson(file, maxBytes, label) {
|
|
760
|
+
const value = readJson(file, maxBytes);
|
|
761
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is unavailable or invalid`);
|
|
762
|
+
return value;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function readBoundedFile(file, maxBytes) {
|
|
766
|
+
return readBoundedFileWithInfo(file, maxBytes).buffer;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function readBoundedFileWithInfo(file, maxBytes) {
|
|
770
|
+
const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
|
|
771
|
+
const fd = openSync(file, flags);
|
|
772
|
+
try {
|
|
773
|
+
const info = fstatSync(fd);
|
|
774
|
+
if (!info.isFile()) throw new Error("path is not a regular file");
|
|
775
|
+
if (info.size > maxBytes) throw new Error(`file exceeds ${maxBytes} bytes`);
|
|
776
|
+
const buffer = Buffer.alloc(info.size);
|
|
777
|
+
let offset = 0;
|
|
778
|
+
while (offset < buffer.length) {
|
|
779
|
+
const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
780
|
+
if (!count) break;
|
|
781
|
+
offset += count;
|
|
782
|
+
}
|
|
783
|
+
return { buffer: buffer.subarray(0, offset), info };
|
|
784
|
+
} finally {
|
|
785
|
+
closeSync(fd);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
790
|
+
let fd;
|
|
791
|
+
try {
|
|
792
|
+
const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
|
|
793
|
+
fd = openSync(file, flags);
|
|
794
|
+
const info = fstatSync(fd);
|
|
795
|
+
if (!info.isFile() || info.size <= maxBytes) return;
|
|
796
|
+
const length = Math.min(keepBytes, info.size);
|
|
797
|
+
const buffer = Buffer.alloc(length);
|
|
798
|
+
let offset = 0;
|
|
799
|
+
while (offset < length) {
|
|
800
|
+
const count = readSync(fd, buffer, offset, length - offset, info.size - length + offset);
|
|
801
|
+
if (!count) break;
|
|
802
|
+
offset += count;
|
|
803
|
+
}
|
|
804
|
+
let tail = buffer.subarray(0, offset);
|
|
805
|
+
const newline = tail.indexOf(0x0a);
|
|
806
|
+
if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
|
|
807
|
+
closeSync(fd);
|
|
808
|
+
fd = undefined;
|
|
809
|
+
writeFileSync(file, tail, { mode: 0o600 });
|
|
810
|
+
} catch {
|
|
811
|
+
} finally {
|
|
812
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function realpathFile(path) {
|
|
817
|
+
const input = resolve(path);
|
|
818
|
+
const linkInfo = lstatSync(input);
|
|
819
|
+
if (!linkInfo.isFile() && !linkInfo.isSymbolicLink()) throw new Error("resource path is not a regular file");
|
|
820
|
+
return realpathSync.native ? realpathSync.native(input) : realpathSync(input);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function resourceErrorClass(error) {
|
|
824
|
+
const message = String(error?.message || error || "");
|
|
825
|
+
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
826
|
+
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
827
|
+
if (/symbolic link/i.test(message)) return "symbolic_link_denied";
|
|
828
|
+
if (/readable by group|permissions/i.test(message)) return "insecure_permissions";
|
|
829
|
+
if (/exceeds/i.test(message)) return "size_limit";
|
|
830
|
+
return "resource_unavailable";
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function safeReadDir(dir) {
|
|
834
|
+
try { return readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function safeMtime(path) {
|
|
838
|
+
try { return statSync(path).mtimeMs; } catch { return 0; }
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function isPidAlive(pid) {
|
|
842
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function boundedString(value, maxBytes, label) {
|
|
846
|
+
if (typeof value !== "string" || value.includes("\0")) throw new Error(`${label} must be a string without NUL bytes`);
|
|
847
|
+
if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
|
|
848
|
+
return value;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function clampInt(value, fallback, min, max) {
|
|
852
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
853
|
+
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
854
|
+
return Math.min(Math.max(number, min), max);
|
|
855
|
+
}
|