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/README.md +69 -263
- package/bin/agentctl.mjs +9 -2
- package/bin/mcp-server.mjs +5 -0
- package/index.mjs +11 -2
- package/package.json +4 -2
- package/scripts/jules-merge-swarm.mjs +179 -11
- package/src/config.mjs +34 -0
- package/src/engine.mjs +119 -3
- package/src/execution_envelope.mjs +103 -0
- package/src/git.mjs +20 -2
- package/src/mcp.mjs +297 -0
- package/src/process-group.mjs +80 -0
- package/src/risk.mjs +3 -2
- package/src/rules_budget.mjs +102 -0
- package/src/security.mjs +45 -6
- package/src/state.mjs +169 -24
- package/src/webhook.mjs +17 -3
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
|
-
*
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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" }));
|