jules-orchestrator-kit 0.9.4 → 0.21.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;
@@ -106,17 +200,35 @@ export class BudgetError extends Error {
106
200
  }
107
201
  }
108
202
 
109
- export async function withBudget(fn, root = resolveRoot(), limit = 300) {
203
+ export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300) {
204
+ const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
110
205
  const budget = checkDailyBudget(root, limit);
111
206
  if (!budget.ok) {
112
207
  throw new BudgetError(`Daily budget exhausted (${budget.used}/${budget.budget} tasks executed)`);
113
208
  }
114
- return fn();
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;
229
+ }
115
230
  }
116
231
 
117
- /**
118
- * Atomic Advisory Lock File Manager with PID Vitality Check & Stale Lock Reaper (CWE-367 Fix).
119
- */
120
232
  export function getLockDir(rootOrOpts = resolveRoot()) {
121
233
  const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
122
234
  const dir = join(getStateDir(root), "locks");
@@ -124,14 +236,39 @@ export function getLockDir(rootOrOpts = resolveRoot()) {
124
236
  return dir;
125
237
  }
126
238
 
127
- 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) {
128
243
  if (!pid || typeof pid !== "number") return false;
129
244
  try {
130
245
  process.kill(pid, 0);
131
- return true;
132
246
  } catch (_) {
133
247
  return false;
134
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;
135
272
  }
136
273
 
137
274
  export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveRoot()) {
@@ -139,15 +276,13 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
139
276
  const lockDir = getLockDir(root);
140
277
  const lockFile = join(lockDir, `${taskId}.json`);
141
278
 
142
- // Stale Lock Reaper Check
143
279
  if (existsSync(lockFile)) {
144
280
  try {
145
281
  const existing = JSON.parse(readFileSync(lockFile, "utf-8"));
146
- const isAlive = isPidAlive(existing.pid);
282
+ const isAlive = isPidAlive(existing.pid, existing.processStartTime);
147
283
  const isExpired = existing.acquiredAt && Date.now() - new Date(existing.acquiredAt).getTime() > 7200000;
148
284
 
149
285
  if (!isAlive || isExpired) {
150
- // Dead or expired holder; safely reap lock
151
286
  try { unlinkSync(lockFile); } catch (_) {}
152
287
  } else {
153
288
  return { ok: false, holder: existing.agent, taskId, pid: existing.pid };
@@ -162,13 +297,15 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
162
297
  taskId,
163
298
  files,
164
299
  pid: process.pid,
300
+ processStartTime: getProcessStartTime(process.pid),
301
+ hostname: hostname(),
165
302
  acquiredAt: new Date().toISOString(),
166
303
  };
167
304
 
168
- // Atomic file creation via 'wx' flag (Fixes TOCTOU Race Condition)
169
305
  try {
170
306
  const fd = openSync(lockFile, "wx");
171
307
  writeSync(fd, JSON.stringify(payload, null, 2), "utf-8");
308
+ fsyncSync(fd);
172
309
  closeSync(fd);
173
310
  return { ok: true, lockFile };
174
311
  } catch (err) {
@@ -191,7 +328,7 @@ export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
191
328
 
192
329
  if (existsSync(lockFile)) {
193
330
  try {
194
- unlinkSync(lockFile); // CRITICAL FIX: Fixed ESM unlinkSync (removed legacy require("node:fs"))
331
+ unlinkSync(lockFile);
195
332
  return true;
196
333
  } catch (_) {}
197
334
  }
package/src/webhook.mjs CHANGED
@@ -9,8 +9,13 @@ 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)
13
- if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
12
+ if (!secret) return false; // Fail closed if secret is not configured
13
+ if (typeof signatureHeader !== "string") return false;
14
+ if (!signatureHeader.startsWith("sha256=")) return false;
15
+
16
+ const hexSignature = signatureHeader.slice(7);
17
+ // Verify hexSignature consists strictly of 64 hex characters (0-9, a-f, A-F)
18
+ if (!/^[0-9a-fA-F]{64}$/.test(hexSignature)) return false;
14
19
 
15
20
  const hmac = createHmac("sha256", secret);
16
21
  const bodyBuf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf-8");
@@ -30,13 +35,16 @@ export function verifySignature(payload, signatureHeader, secret) {
30
35
  * @returns {object} Parsed JSON payload
31
36
  */
32
37
  export function parseWebhookPayload(bodyBuf, contentType = "application/json") {
33
- const str = bodyBuf.toString("utf-8");
38
+ if (!bodyBuf || bodyBuf.length === 0) return {};
39
+ const str = bodyBuf.toString("utf-8").trim();
40
+ if (!str) return {};
41
+
34
42
  if (contentType.includes("application/x-www-form-urlencoded")) {
35
43
  const params = new URLSearchParams(str);
36
44
  const payloadStr = params.get("payload");
37
45
  return payloadStr ? JSON.parse(payloadStr) : {};
38
46
  }
39
- return str ? JSON.parse(str) : {};
47
+ return JSON.parse(str);
40
48
  }
41
49
 
42
50
  /**
@@ -47,37 +55,64 @@ export function parseWebhookPayload(bodyBuf, contentType = "application/json") {
47
55
  * @returns {object} Response summary { handled: boolean, action: string, details: object }
48
56
  */
49
57
  export function routeWebhookEvent(eventType, payload, handlers = {}) {
58
+ const safePayload = payload || {};
59
+
50
60
  if (eventType === "ping") {
51
- return { handled: true, action: "ping", details: { zen: payload.zen, hookId: payload.hook_id } };
61
+ return { handled: true, action: "ping", details: { zen: safePayload.zen, hookId: safePayload.hook_id } };
52
62
  }
53
63
 
54
64
  if (eventType === "pull_request") {
55
- const action = payload.action;
56
- const prNumber = payload.number || payload.pull_request?.number;
57
- const repo = payload.repository?.full_name;
58
- const branch = payload.pull_request?.head?.ref;
59
- const merged = payload.pull_request?.merged || false;
65
+ const action = safePayload.action;
66
+ const prNumber = safePayload.number || safePayload.pull_request?.number;
67
+ const repo = safePayload.repository?.full_name;
68
+ const branch = safePayload.pull_request?.head?.ref;
69
+ const merged = safePayload.pull_request?.merged || false;
60
70
 
61
71
  const details = { action, prNumber, repo, branch, merged };
62
72
  if (typeof handlers.onPullRequest === "function") {
63
- handlers.onPullRequest(details, payload);
73
+ try {
74
+ handlers.onPullRequest(details, safePayload);
75
+ } catch (err) {
76
+ // Safely ignore errors from handlers
77
+ }
64
78
  }
65
79
  return { handled: true, action: `pull_request:${action}`, details };
66
80
  }
67
81
 
68
82
  if (eventType === "workflow_run") {
69
- const action = payload.action;
70
- const name = payload.workflow?.name || payload.workflow_run?.name;
71
- const conclusion = payload.workflow_run?.conclusion;
72
- const repo = payload.repository?.full_name;
83
+ const action = safePayload.action;
84
+ const name = safePayload.workflow?.name || safePayload.workflow_run?.name;
85
+ const conclusion = safePayload.workflow_run?.conclusion;
86
+ const repo = safePayload.repository?.full_name;
73
87
 
74
88
  const details = { action, name, conclusion, repo };
75
89
  if (typeof handlers.onWorkflowRun === "function") {
76
- handlers.onWorkflowRun(details, payload);
90
+ try {
91
+ handlers.onWorkflowRun(details, safePayload);
92
+ } catch (err) {
93
+ // Safely ignore errors from handlers
94
+ }
77
95
  }
78
96
  return { handled: true, action: `workflow_run:${action}`, details };
79
97
  }
80
98
 
99
+ // Fallback status handler for unhandled GitHub event types
100
+ if (typeof handlers.onUnhandled === "function") {
101
+ try {
102
+ handlers.onUnhandled(eventType, safePayload);
103
+ } catch (err) {
104
+ // Safely ignore or log error from fallback handler to prevent crashing
105
+ }
106
+ }
107
+
108
+ if (typeof handlers.onFallback === "function") {
109
+ try {
110
+ handlers.onFallback(eventType, safePayload);
111
+ } catch (err) {
112
+ // Safely ignore or log error from fallback handler to prevent crashing
113
+ }
114
+ }
115
+
81
116
  return { handled: false, action: eventType, details: {} };
82
117
  }
83
118
 
@@ -104,15 +139,29 @@ export function createWebhookServer({ _port = 8787, secret = process.env.JULES_W
104
139
  return;
105
140
  }
106
141
 
142
+ const MAX_BODY = 2 * 1024 * 1024; // 2MB payload cap
107
143
  const chunks = [];
108
- req.on("data", (chunk) => chunks.push(chunk));
144
+ let totalSize = 0;
145
+
146
+ req.on("data", (chunk) => {
147
+ totalSize += chunk.length;
148
+ if (totalSize > MAX_BODY) {
149
+ res.writeHead(413, { "Content-Type": "application/json" });
150
+ res.end(JSON.stringify({ error: "Payload Too Large" }));
151
+ req.destroy();
152
+ return;
153
+ }
154
+ chunks.push(chunk);
155
+ });
156
+
109
157
  req.on("end", () => {
158
+ if (totalSize > MAX_BODY) return;
110
159
  const bodyBuf = Buffer.concat(chunks);
111
160
  const sigHeader = req.headers["x-hub-signature-256"];
112
161
  const eventType = req.headers["x-github-event"];
113
162
  const contentType = req.headers["content-type"] || "application/json";
114
163
 
115
- if (secret && !verifySignature(bodyBuf, sigHeader, secret)) {
164
+ if (!verifySignature(bodyBuf, sigHeader, secret)) {
116
165
  log(`[Webhook] Invalid signature received from ${req.socket.remoteAddress}`);
117
166
  res.writeHead(401, { "Content-Type": "application/json" });
118
167
  res.end(JSON.stringify({ error: "Invalid signature" }));