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/.agent/prompts/Janitor.md +11 -0
- package/README.md +261 -259
- package/bin/agentctl.mjs +3 -3
- package/bin/init.js +16 -1
- package/index.mjs +11 -2
- package/package.json +2 -1
- package/scripts/jules-merge-swarm.mjs +179 -11
- package/scripts/release.mjs +103 -0
- package/src/config.mjs +37 -0
- package/src/engine.mjs +124 -3
- package/src/execution_envelope.mjs +103 -0
- package/src/git.mjs +20 -2
- package/src/mcp.mjs +120 -13
- package/src/process-group.mjs +80 -0
- package/src/risk.mjs +4 -2
- package/src/rules_budget.mjs +102 -0
- package/src/security.mjs +45 -6
- package/src/state.mjs +160 -23
- package/src/webhook.mjs +67 -18
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;
|
|
@@ -106,17 +200,35 @@ export class BudgetError extends Error {
|
|
|
106
200
|
}
|
|
107
201
|
}
|
|
108
202
|
|
|
109
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
|
13
|
-
if (
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
56
|
-
const prNumber =
|
|
57
|
-
const repo =
|
|
58
|
-
const branch =
|
|
59
|
-
const merged =
|
|
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
|
-
|
|
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 =
|
|
70
|
-
const name =
|
|
71
|
-
const conclusion =
|
|
72
|
-
const repo =
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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" }));
|