jules-orchestrator-kit 0.9.3 → 0.20.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/state.mjs CHANGED
@@ -1,15 +1,18 @@
1
1
  import {
2
2
  readFileSync,
3
- appendFileSync,
4
3
  existsSync,
5
4
  mkdirSync,
5
+ rmdirSync,
6
6
  readdirSync,
7
7
  openSync,
8
8
  writeSync,
9
9
  closeSync,
10
+ fsyncSync,
10
11
  unlinkSync,
11
12
  } from "node:fs";
12
13
  import { join } from "node:path";
14
+ import { createHash } from "node:crypto";
15
+ import { hostname } from "node:os";
13
16
  import { resolveRoot } from "./config.mjs";
14
17
 
15
18
  export function ensureDir(dirPath) {
@@ -43,17 +46,111 @@ export function getDailyLedgerPath(rootOrOpts = resolveRoot()) {
43
46
  }
44
47
 
45
48
  /**
46
- * Appends a structured audit event to the daily session ledger.
49
+ * Executes a function inside a kernel-level VFS directory mutex for strict linearizability.
50
+ */
51
+ export function withVfsMutex(mutexDir, fn, opts = {}) {
52
+ const maxRetries = opts.maxRetries || 50;
53
+ const retryDelayMs = opts.retryDelayMs || 10;
54
+
55
+ for (let i = 0; i < maxRetries; i++) {
56
+ try {
57
+ mkdirSync(mutexDir);
58
+ try {
59
+ return fn();
60
+ } finally {
61
+ try {
62
+ rmdirSync(mutexDir);
63
+ } catch (_) {}
64
+ }
65
+ } catch (err) {
66
+ if (err.code === "EEXIST") {
67
+ const deadline = Date.now() + retryDelayMs;
68
+ while (Date.now() < deadline) {}
69
+ continue;
70
+ }
71
+ throw err;
72
+ }
73
+ }
74
+ return fn();
75
+ }
76
+
77
+ /**
78
+ * Appends a structured audit event to the daily session ledger with SHA-256 hash-chain (VFS Mutex Linearized).
47
79
  */
48
80
  export function appendLedger(entry, rootOrOpts = resolveRoot()) {
49
81
  const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
50
82
  const filePath = getDailyLedgerPath(root);
51
- const payload = {
52
- timestamp: new Date().toISOString(),
53
- ...entry,
54
- };
55
- appendFileSync(filePath, JSON.stringify(payload) + "\n", "utf-8");
56
- return payload;
83
+ const mutexDir = join(getStateDir(root), ".ledger.mutex");
84
+
85
+ return withVfsMutex(mutexDir, () => {
86
+ let prevHash = "0".repeat(64);
87
+ if (existsSync(filePath)) {
88
+ try {
89
+ const raw = readFileSync(filePath, "utf-8");
90
+ const lines = raw.split("\n").filter(Boolean);
91
+ for (let i = lines.length - 1; i >= 0; i--) {
92
+ try {
93
+ const lastObj = JSON.parse(lines[i]);
94
+ if (lastObj.hash) {
95
+ prevHash = lastObj.hash;
96
+ break;
97
+ }
98
+ } catch (_) {}
99
+ }
100
+ } catch (_) {}
101
+ }
102
+
103
+ const timestamp = new Date().toISOString();
104
+ const rawPayload = { timestamp, ...entry, prevHash };
105
+ const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
106
+ const payload = { ...rawPayload, hash };
107
+
108
+ const fd = openSync(filePath, "a");
109
+ try {
110
+ writeSync(fd, JSON.stringify(payload) + "\n", "utf-8");
111
+ fsyncSync(fd);
112
+ } finally {
113
+ closeSync(fd);
114
+ }
115
+
116
+ return payload;
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Verifies the SHA-256 cryptographic hash-chain integrity of a ledger file.
122
+ */
123
+ export function verifyLedgerIntegrity(filePath) {
124
+ if (!filePath || !existsSync(filePath)) return { ok: false, count: 0, error: "FILE_NOT_FOUND" };
125
+ try {
126
+ const raw = readFileSync(filePath, "utf-8");
127
+ const lines = raw.split("\n").filter(Boolean);
128
+ let expectedPrevHash = "0".repeat(64);
129
+
130
+ for (let i = 0; i < lines.length; i++) {
131
+ let obj;
132
+ try {
133
+ obj = JSON.parse(lines[i]);
134
+ } catch (err) {
135
+ return { ok: false, line: i + 1, error: "TORN_WRITE_CORRUPTION", detail: err.message };
136
+ }
137
+ if (!obj.hash || !obj.prevHash) {
138
+ return { ok: false, line: i + 1, error: "MISSING_HASH_FIELDS" };
139
+ }
140
+ if (obj.prevHash !== expectedPrevHash) {
141
+ return { ok: false, line: i + 1, error: "BROKEN_PREV_HASH", expected: expectedPrevHash, actual: obj.prevHash };
142
+ }
143
+ const { hash, ...rest } = obj;
144
+ const recomputed = createHash("sha256").update(JSON.stringify(rest)).digest("hex");
145
+ if (recomputed !== hash) {
146
+ return { ok: false, line: i + 1, error: "CORRUPTED_ENTRY_HASH", expected: recomputed, actual: hash };
147
+ }
148
+ expectedPrevHash = hash;
149
+ }
150
+ return { ok: true, count: lines.length, lastHash: expectedPrevHash };
151
+ } catch (err) {
152
+ return { ok: false, count: 0, error: err.message };
153
+ }
57
154
  }
58
155
 
59
156
  export function readLedger(filePath) {
@@ -72,9 +169,6 @@ export function readLedger(filePath) {
72
169
  }
73
170
  }
74
171
 
75
- /**
76
- * Enforces a daily token budget.
77
- */
78
172
  export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
79
173
  let root = typeof arg1 === "string" ? arg1 : resolveRoot();
80
174
  let limit = typeof arg1 === "number" ? arg1 : typeof arg2 === "number" ? arg2 : 300;
@@ -98,17 +192,43 @@ export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
98
192
  }
99
193
  }
100
194
 
101
- export async function withBudget(fn, root = resolveRoot(), limit = 300) {
195
+ export class BudgetError extends Error {
196
+ constructor(message) {
197
+ super(message);
198
+ this.name = "BudgetError";
199
+ this.code = 7;
200
+ }
201
+ }
202
+
203
+ export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300) {
204
+ const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
102
205
  const budget = checkDailyBudget(root, limit);
103
206
  if (!budget.ok) {
104
- throw new Error(`Daily budget exhausted (${budget.used}/${budget.budget} tasks executed)`);
207
+ throw new BudgetError(`Daily budget exhausted (${budget.used}/${budget.budget} tasks executed)`);
208
+ }
209
+
210
+ const reservationId = `res-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
211
+ appendLedger({ event: "budget_reserved", reservationId, budget: limit }, root);
212
+ return { ok: true, reservationId, remaining: Math.max(0, budget.remaining - 1) };
213
+ }
214
+
215
+ export function commitBudgetReservation(rootOrOpts = resolveRoot(), reservationId = "") {
216
+ const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
217
+ return appendLedger({ event: "budget_committed", reservationId }, root);
218
+ }
219
+
220
+ export async function withBudget(fn, root = resolveRoot(), limit = 300) {
221
+ const reservation = reserveBudget(root, limit);
222
+ try {
223
+ const result = await fn();
224
+ commitBudgetReservation(root, reservation.reservationId);
225
+ return result;
226
+ } catch (err) {
227
+ appendLedger({ event: "budget_reservation_failed", reservationId: reservation.reservationId, error: err.message }, root);
228
+ throw err;
105
229
  }
106
- return fn();
107
230
  }
108
231
 
109
- /**
110
- * Atomic Advisory Lock File Manager with PID Vitality Check & Stale Lock Reaper (CWE-367 Fix).
111
- */
112
232
  export function getLockDir(rootOrOpts = resolveRoot()) {
113
233
  const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
114
234
  const dir = join(getStateDir(root), "locks");
@@ -116,14 +236,39 @@ export function getLockDir(rootOrOpts = resolveRoot()) {
116
236
  return dir;
117
237
  }
118
238
 
119
- export function isPidAlive(pid) {
239
+ /**
240
+ * Checks if a PID is alive with optional PID-recycling start time validation on Linux.
241
+ */
242
+ export function isPidAlive(pid, expectedStartTime = null) {
120
243
  if (!pid || typeof pid !== "number") return false;
121
244
  try {
122
245
  process.kill(pid, 0);
123
- return true;
124
246
  } catch (_) {
125
247
  return false;
126
248
  }
249
+
250
+ if (expectedStartTime && process.platform === "linux") {
251
+ try {
252
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
253
+ const parts = stat.split(" ");
254
+ const startTime = parts[21];
255
+ if (startTime && String(startTime) !== String(expectedStartTime)) {
256
+ return false;
257
+ }
258
+ } catch (_) {}
259
+ }
260
+
261
+ return true;
262
+ }
263
+
264
+ function getProcessStartTime(pid) {
265
+ if (process.platform === "linux") {
266
+ try {
267
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
268
+ return stat.split(" ")[21] || null;
269
+ } catch (_) {}
270
+ }
271
+ return null;
127
272
  }
128
273
 
129
274
  export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveRoot()) {
@@ -131,15 +276,13 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
131
276
  const lockDir = getLockDir(root);
132
277
  const lockFile = join(lockDir, `${taskId}.json`);
133
278
 
134
- // Stale Lock Reaper Check
135
279
  if (existsSync(lockFile)) {
136
280
  try {
137
281
  const existing = JSON.parse(readFileSync(lockFile, "utf-8"));
138
- const isAlive = isPidAlive(existing.pid);
282
+ const isAlive = isPidAlive(existing.pid, existing.processStartTime);
139
283
  const isExpired = existing.acquiredAt && Date.now() - new Date(existing.acquiredAt).getTime() > 7200000;
140
284
 
141
285
  if (!isAlive || isExpired) {
142
- // Dead or expired holder; safely reap lock
143
286
  try { unlinkSync(lockFile); } catch (_) {}
144
287
  } else {
145
288
  return { ok: false, holder: existing.agent, taskId, pid: existing.pid };
@@ -154,13 +297,15 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
154
297
  taskId,
155
298
  files,
156
299
  pid: process.pid,
300
+ processStartTime: getProcessStartTime(process.pid),
301
+ hostname: hostname(),
157
302
  acquiredAt: new Date().toISOString(),
158
303
  };
159
304
 
160
- // Atomic file creation via 'wx' flag (Fixes TOCTOU Race Condition)
161
305
  try {
162
306
  const fd = openSync(lockFile, "wx");
163
307
  writeSync(fd, JSON.stringify(payload, null, 2), "utf-8");
308
+ fsyncSync(fd);
164
309
  closeSync(fd);
165
310
  return { ok: true, lockFile };
166
311
  } catch (err) {
@@ -183,7 +328,7 @@ export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
183
328
 
184
329
  if (existsSync(lockFile)) {
185
330
  try {
186
- unlinkSync(lockFile); // CRITICAL FIX: Fixed ESM unlinkSync (removed legacy require("node:fs"))
331
+ unlinkSync(lockFile);
187
332
  return true;
188
333
  } catch (_) {}
189
334
  }
package/src/webhook.mjs CHANGED
@@ -9,7 +9,7 @@ import { createServer } from "node:http";
9
9
  * @returns {boolean} True if signature matches
10
10
  */
11
11
  export function verifySignature(payload, signatureHeader, secret) {
12
- if (!secret) return true; // If no secret is configured, skip verification (development mode)
12
+ if (!secret) return false; // Fail closed if secret is not configured
13
13
  if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
14
14
 
15
15
  const hmac = createHmac("sha256", secret);
@@ -104,15 +104,29 @@ export function createWebhookServer({ _port = 8787, secret = process.env.JULES_W
104
104
  return;
105
105
  }
106
106
 
107
+ const MAX_BODY = 2 * 1024 * 1024; // 2MB payload cap
107
108
  const chunks = [];
108
- req.on("data", (chunk) => chunks.push(chunk));
109
+ let totalSize = 0;
110
+
111
+ req.on("data", (chunk) => {
112
+ totalSize += chunk.length;
113
+ if (totalSize > MAX_BODY) {
114
+ res.writeHead(413, { "Content-Type": "application/json" });
115
+ res.end(JSON.stringify({ error: "Payload Too Large" }));
116
+ req.destroy();
117
+ return;
118
+ }
119
+ chunks.push(chunk);
120
+ });
121
+
109
122
  req.on("end", () => {
123
+ if (totalSize > MAX_BODY) return;
110
124
  const bodyBuf = Buffer.concat(chunks);
111
125
  const sigHeader = req.headers["x-hub-signature-256"];
112
126
  const eventType = req.headers["x-github-event"];
113
127
  const contentType = req.headers["content-type"] || "application/json";
114
128
 
115
- if (secret && !verifySignature(bodyBuf, sigHeader, secret)) {
129
+ if (!verifySignature(bodyBuf, sigHeader, secret)) {
116
130
  log(`[Webhook] Invalid signature received from ${req.socket.remoteAddress}`);
117
131
  res.writeHead(401, { "Content-Type": "application/json" });
118
132
  res.end(JSON.stringify({ error: "Invalid signature" }));