jules-orchestrator-kit 0.9.4 → 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 -274
- package/bin/agentctl.mjs +1 -1
- package/index.mjs +11 -2
- package/package.json +1 -1
- 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 +98 -12
- 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 +160 -23
- package/src/webhook.mjs +17 -3
package/src/config.mjs
CHANGED
|
@@ -278,6 +278,30 @@ export function resolveVerify(root = process.cwd()) {
|
|
|
278
278
|
return { test: s.testCmd || "", build: s.buildCmd || "" };
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
export const TIER_PRESETS = {
|
|
282
|
+
free: {
|
|
283
|
+
dailyTasks: 15,
|
|
284
|
+
repairAttempts: 1,
|
|
285
|
+
concurrency: 1,
|
|
286
|
+
staggerMs: 3000,
|
|
287
|
+
diffKb: 50,
|
|
288
|
+
},
|
|
289
|
+
pro: {
|
|
290
|
+
dailyTasks: 100,
|
|
291
|
+
repairAttempts: 2,
|
|
292
|
+
concurrency: 2,
|
|
293
|
+
staggerMs: 1500,
|
|
294
|
+
diffKb: 75,
|
|
295
|
+
},
|
|
296
|
+
ultra: {
|
|
297
|
+
dailyTasks: 300,
|
|
298
|
+
repairAttempts: 3,
|
|
299
|
+
concurrency: 3,
|
|
300
|
+
staggerMs: 1000,
|
|
301
|
+
diffKb: 75,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
281
305
|
/**
|
|
282
306
|
* Loads and validates configuration from .agent/config.yml or .agent/jules.yml.
|
|
283
307
|
*/
|
|
@@ -301,9 +325,16 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
301
325
|
const buildCmd = parsed.build_cmd || parsed.verify?.build || "";
|
|
302
326
|
const autoVerify = resolveVerify(root);
|
|
303
327
|
|
|
328
|
+
const activeTier = String(process.env.JULES_TIER || parsed.tier || "ultra").toLowerCase();
|
|
329
|
+
const tierLimits = TIER_PRESETS[activeTier] || TIER_PRESETS.ultra;
|
|
330
|
+
|
|
331
|
+
const envDailyTasks = process.env.JULES_DAILY_BUDGET ? Number(process.env.JULES_DAILY_BUDGET) : null;
|
|
332
|
+
const envDiffKb = process.env.JULES_MAX_DIFF_KB ? Number(process.env.JULES_MAX_DIFF_KB) : null;
|
|
333
|
+
|
|
304
334
|
const config = {
|
|
305
335
|
version: parsed.version || DEFAULTS.version,
|
|
306
336
|
provider: parsed.provider || DEFAULTS.provider,
|
|
337
|
+
tier: activeTier,
|
|
307
338
|
verify: {
|
|
308
339
|
test: testCmd || autoVerify.test,
|
|
309
340
|
build: buildCmd || autoVerify.build,
|
|
@@ -311,7 +342,10 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
311
342
|
scope: normalizeScope(parsed),
|
|
312
343
|
limits: {
|
|
313
344
|
...DEFAULTS.limits,
|
|
345
|
+
...tierLimits,
|
|
314
346
|
...(parsed.limits || {}),
|
|
347
|
+
...(envDailyTasks && !isNaN(envDailyTasks) ? { dailyTasks: envDailyTasks } : {}),
|
|
348
|
+
...(envDiffKb && !isNaN(envDiffKb) ? { diffKb: envDiffKb } : {}),
|
|
315
349
|
},
|
|
316
350
|
isolation: parsed.isolation || DEFAULTS.isolation,
|
|
317
351
|
runner: parsed.runner || DEFAULTS.runner,
|
package/src/engine.mjs
CHANGED
|
@@ -5,6 +5,33 @@ import { createProvider } from "./provider.mjs";
|
|
|
5
5
|
import { withBudget, appendLedger, getQueueDir, ensureDir } from "./state.mjs";
|
|
6
6
|
import { readdirSync, readFileSync, renameSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Computes a SHA-256 fingerprint for a failure state (normalized stderr + diff text).
|
|
12
|
+
*/
|
|
13
|
+
export function fingerprintFailureState(failure = {}, root = process.cwd()) {
|
|
14
|
+
const rawStderr = failure.stderr || failure.stdout || failure.message || "Unknown Error";
|
|
15
|
+
const normalizedStderr = String(rawStderr)
|
|
16
|
+
.replace(/:\d+:\d+/g, ":?:?")
|
|
17
|
+
.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z?\b/g, "<timestamp>")
|
|
18
|
+
.replace(/\b[0-9a-f]{7,40}\b/g, "<sha>")
|
|
19
|
+
.replace(/\/[\w\/-]+\//g, "/?/")
|
|
20
|
+
.replace(/\s+/g, " ")
|
|
21
|
+
.trim();
|
|
22
|
+
|
|
23
|
+
let diffStat = "0:0";
|
|
24
|
+
try {
|
|
25
|
+
const rawDiff = diffText(root) || "";
|
|
26
|
+
const lines = rawDiff.split("\n");
|
|
27
|
+
const added = lines.filter((l) => l.startsWith("+")).length;
|
|
28
|
+
const removed = lines.filter((l) => l.startsWith("-")).length;
|
|
29
|
+
diffStat = `${added}:${removed}`;
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
|
|
32
|
+
const combined = `${normalizedStderr}::${diffStat}`;
|
|
33
|
+
return createHash("sha256").update(combined).digest("hex").substring(0, 16);
|
|
34
|
+
}
|
|
8
35
|
|
|
9
36
|
/**
|
|
10
37
|
* Gatekeeper verification engine (Phase 1: Scope, Phase 2: Payload, Phase 3: Secrets, Phase 4: Verify Commands).
|
|
@@ -106,14 +133,77 @@ export async function gate(opts = {}) {
|
|
|
106
133
|
return { ok: verifyOk, code: verifyOk ? 0 : 4, phases };
|
|
107
134
|
}
|
|
108
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Sliding-window ring-buffer circuit breaker for non-convergent OODA loops.
|
|
138
|
+
*/
|
|
139
|
+
export class OODACircuitBreaker {
|
|
140
|
+
constructor({ windowSize = 6, threshold = 2, cooldownMs = 60000 } = {}) {
|
|
141
|
+
this.windowSize = windowSize;
|
|
142
|
+
this.threshold = threshold;
|
|
143
|
+
this.cooldownMs = cooldownMs;
|
|
144
|
+
this.ring = [];
|
|
145
|
+
this.openedAt = 0;
|
|
146
|
+
this.openedFingerprint = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
observe(fingerprint) {
|
|
150
|
+
if (!fingerprint || typeof fingerprint !== "string") {
|
|
151
|
+
return { tripped: false };
|
|
152
|
+
}
|
|
153
|
+
if (this.isOpen()) {
|
|
154
|
+
return { tripped: true, reason: "OODA_CIRCUIT_OPEN", fingerprint: this.openedFingerprint };
|
|
155
|
+
}
|
|
156
|
+
const occurrences = this.ring.filter((f) => f === fingerprint).length + 1;
|
|
157
|
+
this.ring.push(fingerprint);
|
|
158
|
+
if (this.ring.length > this.windowSize) this.ring.shift();
|
|
159
|
+
|
|
160
|
+
if (occurrences >= this.threshold) {
|
|
161
|
+
this.openedAt = Date.now();
|
|
162
|
+
this.openedFingerprint = fingerprint;
|
|
163
|
+
return {
|
|
164
|
+
tripped: true,
|
|
165
|
+
reason: "OODA_THRASH_DETECTED",
|
|
166
|
+
fingerprint,
|
|
167
|
+
occurrences,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { tripped: false, occurrences };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
isOpen() {
|
|
174
|
+
if (this.openedAt === 0) return false;
|
|
175
|
+
if (Date.now() - this.openedAt >= this.cooldownMs) {
|
|
176
|
+
this.openedAt = 0;
|
|
177
|
+
this.openedFingerprint = null;
|
|
178
|
+
this.ring = [];
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
reset() {
|
|
185
|
+
this.ring = [];
|
|
186
|
+
this.openedAt = 0;
|
|
187
|
+
this.openedFingerprint = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
109
191
|
export async function repair(failure, opts = {}) {
|
|
110
192
|
const root = opts.root || process.cwd();
|
|
111
193
|
const config = opts.config || loadConfig(root);
|
|
112
194
|
const maxRetries = config.limits.repairAttempts || 3;
|
|
113
|
-
const provider = createProvider(config.provider, config);
|
|
195
|
+
const provider = opts.provider || createProvider(config.provider, config);
|
|
114
196
|
const attempts = [];
|
|
197
|
+
const breaker = opts.circuitBreaker || new OODACircuitBreaker({
|
|
198
|
+
windowSize: Math.max(maxRetries + 1, 6),
|
|
199
|
+
threshold: 2,
|
|
200
|
+
cooldownMs: 60000,
|
|
201
|
+
});
|
|
115
202
|
|
|
116
203
|
let currentFailure = failure;
|
|
204
|
+
const initialFingerprint = fingerprintFailureState(currentFailure, root);
|
|
205
|
+
breaker.observe(initialFingerprint);
|
|
206
|
+
|
|
117
207
|
for (let n = 1; n <= maxRetries; n++) {
|
|
118
208
|
const repairPrompt = buildRepairPrompt(currentFailure, n, config);
|
|
119
209
|
let session;
|
|
@@ -137,9 +227,23 @@ export async function repair(failure, opts = {}) {
|
|
|
137
227
|
// Re-verify after repair attempt
|
|
138
228
|
const gateRes = await gate({ root, config, fix: false });
|
|
139
229
|
if (gateRes.ok) {
|
|
230
|
+
breaker.reset();
|
|
140
231
|
return { ok: true, attempts, finalStatus: "PASSED" };
|
|
141
232
|
}
|
|
233
|
+
|
|
142
234
|
currentFailure = gateRes.phases.find((p) => p.phase === "verify")?.testResult || failure;
|
|
235
|
+
const currentFingerprint = fingerprintFailureState(currentFailure, root);
|
|
236
|
+
|
|
237
|
+
const check = breaker.observe(currentFingerprint);
|
|
238
|
+
if (check.tripped) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
attempts,
|
|
242
|
+
finalStatus: "DETERMINISTIC_REGRESSION",
|
|
243
|
+
reason: `Identical failure state fingerprint (${currentFingerprint}) observed during attempt #${n}`,
|
|
244
|
+
fingerprint: currentFingerprint,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
143
247
|
}
|
|
144
248
|
|
|
145
249
|
return { ok: false, attempts, finalStatus: "OODA_EXHAUSTED" };
|
|
@@ -147,11 +251,23 @@ export async function repair(failure, opts = {}) {
|
|
|
147
251
|
|
|
148
252
|
function buildRepairPrompt(failure, attempt, _config) {
|
|
149
253
|
const cleanStderr = redactSecrets(failure.stderr || failure.stdout || "Unknown Error");
|
|
150
|
-
|
|
254
|
+
|
|
255
|
+
let escalationStrategy = "DIRECT_REPAIR";
|
|
256
|
+
let escalationDirective = "1. Ground your fix strictly in the error log below. Do NOT guess file contents or function signatures.";
|
|
257
|
+
|
|
258
|
+
if (attempt === 2) {
|
|
259
|
+
escalationStrategy = "DIAGNOSTIC_ANALYSIS";
|
|
260
|
+
escalationDirective = "1. DIAGNOSE FIRST: Two attempts have failed. Analyze if the root cause is in a different file or component than where the previous patch was applied.";
|
|
261
|
+
} else if (attempt >= 3) {
|
|
262
|
+
escalationStrategy = "MINIMAL_SIMPLIFICATION";
|
|
263
|
+
escalationDirective = "1. SIMPLIFY: Multiple repair attempts have failed. Revert complex refactors and implement the minimal patch that satisfies the failing assertion.";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return `Auto-Repair Attempt #${attempt} [Strategy: ${escalationStrategy}]
|
|
151
267
|
Command Failed: ${failure.command || "verify"}
|
|
152
268
|
|
|
153
269
|
REPAIR DIRECTIVE & STRICT INVARIANTS:
|
|
154
|
-
|
|
270
|
+
${escalationDirective}
|
|
155
271
|
2. NO WEAKENING: You are STRICTLY FORBIDDEN from deleting tests, commenting out assertions, or weakening expectations to achieve a passing test. Leave unmet requirements RED if the requirement is valid and feature code is missing.
|
|
156
272
|
3. CARRY EVIDENCE: Address the root cause directly and verify the exact fix.
|
|
157
273
|
4. RE-VERIFY: Execute full verification after patching to ensure zero new regressions.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { resolveRoot } from "./config.mjs";
|
|
3
|
+
import { resolveBase } from "./git.mjs";
|
|
4
|
+
import { classifyRiskTier } from "./risk.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generates a SHA-256 hash of the execution envelope payload.
|
|
8
|
+
*/
|
|
9
|
+
export function hashExecutionEnvelope(envelope) {
|
|
10
|
+
if (!envelope || typeof envelope !== "object") return "";
|
|
11
|
+
const rawPayload = {
|
|
12
|
+
id: envelope.id,
|
|
13
|
+
taskId: envelope.taskId,
|
|
14
|
+
baseRef: envelope.baseRef,
|
|
15
|
+
baseSha: envelope.baseSha,
|
|
16
|
+
configSha: envelope.configSha,
|
|
17
|
+
scope: envelope.scope,
|
|
18
|
+
verify: envelope.verify,
|
|
19
|
+
riskTier: envelope.riskTier,
|
|
20
|
+
budgetReservationId: envelope.budgetReservationId,
|
|
21
|
+
capabilities: envelope.capabilities,
|
|
22
|
+
createdAt: envelope.createdAt,
|
|
23
|
+
};
|
|
24
|
+
const sortedKeys = Object.keys(rawPayload).sort();
|
|
25
|
+
const canonicalPayload = {};
|
|
26
|
+
for (const k of sortedKeys) {
|
|
27
|
+
if (rawPayload[k] !== undefined) {
|
|
28
|
+
canonicalPayload[k] = rawPayload[k];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return createHash("sha256").update(JSON.stringify(canonicalPayload)).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deep freezes an execution envelope to prevent modifications during OODA repair loops.
|
|
36
|
+
*/
|
|
37
|
+
export function freezeExecutionEnvelope(envelope) {
|
|
38
|
+
if (!envelope || typeof envelope !== "object") return envelope;
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
...envelope,
|
|
41
|
+
scope: Object.freeze({
|
|
42
|
+
deny: Object.freeze([...(envelope.scope?.deny || [])]),
|
|
43
|
+
allow: Object.freeze([...(envelope.scope?.allow || [])]),
|
|
44
|
+
protect: Object.freeze([...(envelope.scope?.protect || [])]),
|
|
45
|
+
}),
|
|
46
|
+
verify: Object.freeze({ ...(envelope.verify || {}) }),
|
|
47
|
+
capabilities: Object.freeze([...(envelope.capabilities || [])]),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates an immutable Capability-Bounded Execution Envelope (CBEE).
|
|
53
|
+
*/
|
|
54
|
+
export function createExecutionEnvelope(task = {}, opts = {}) {
|
|
55
|
+
const root = opts.root || resolveRoot();
|
|
56
|
+
const baseRef = opts.base || task.base || "main";
|
|
57
|
+
const baseSha = resolveBase(root, baseRef);
|
|
58
|
+
|
|
59
|
+
const taskId = task.id || opts.taskId || `task-${Date.now()}`;
|
|
60
|
+
const envelopeId = `env-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
61
|
+
|
|
62
|
+
const denyPaths = opts.forbiddenPaths || task.forbiddenPaths || [".github/**", "package-lock.json", "pnpm-lock.yaml"];
|
|
63
|
+
const allowPaths = opts.allowPaths || task.allowPaths || [];
|
|
64
|
+
const protectPaths = opts.protectPaths || task.protectPaths || ["package.json", "tsconfig.json", "AGENTS.md"];
|
|
65
|
+
|
|
66
|
+
const files = task.files || opts.files || [];
|
|
67
|
+
const riskMeta = classifyRiskTier(files, { diffLines: opts.diffLines || 0 });
|
|
68
|
+
|
|
69
|
+
const rawEnvelope = {
|
|
70
|
+
id: envelopeId,
|
|
71
|
+
taskId,
|
|
72
|
+
baseRef,
|
|
73
|
+
baseSha,
|
|
74
|
+
configSha: createHash("sha256").update(JSON.stringify({ denyPaths, allowPaths, protectPaths })).digest("hex"),
|
|
75
|
+
scope: {
|
|
76
|
+
deny: denyPaths,
|
|
77
|
+
allow: allowPaths,
|
|
78
|
+
protect: protectPaths,
|
|
79
|
+
},
|
|
80
|
+
verify: {
|
|
81
|
+
testCmd: opts.testCmd || task.testCmd || "npm test",
|
|
82
|
+
buildCmd: opts.buildCmd || task.buildCmd || "npm run build",
|
|
83
|
+
},
|
|
84
|
+
riskTier: riskMeta.tier,
|
|
85
|
+
budgetReservationId: opts.reservationId || null,
|
|
86
|
+
capabilities: opts.capabilities || ["git:read", "git:diff", "test:run"],
|
|
87
|
+
createdAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const envelopeHash = hashExecutionEnvelope(rawEnvelope);
|
|
91
|
+
const fullEnvelope = { ...rawEnvelope, hash: envelopeHash };
|
|
92
|
+
|
|
93
|
+
return freezeExecutionEnvelope(fullEnvelope);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validates that an envelope has not been tampered with.
|
|
98
|
+
*/
|
|
99
|
+
export function verifyExecutionEnvelope(envelope) {
|
|
100
|
+
if (!envelope || !envelope.hash) return false;
|
|
101
|
+
const expectedHash = hashExecutionEnvelope(envelope);
|
|
102
|
+
return envelope.hash === expectedHash;
|
|
103
|
+
}
|
package/src/git.mjs
CHANGED
|
@@ -16,10 +16,28 @@ function normalizePath(p) {
|
|
|
16
16
|
|
|
17
17
|
export function runCmd(command, opts = {}) {
|
|
18
18
|
const cwd = opts.cwd || process.cwd();
|
|
19
|
+
let binary = "";
|
|
20
|
+
let args = [];
|
|
21
|
+
|
|
22
|
+
if (Array.isArray(command)) {
|
|
23
|
+
binary = command[0];
|
|
24
|
+
args = command.slice(1);
|
|
25
|
+
} else if (typeof command === "string") {
|
|
26
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
27
|
+
binary = tokens[0] || "";
|
|
28
|
+
args = tokens.slice(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!binary) {
|
|
32
|
+
if (opts.ignoreError) return { status: 0, stdout: "", stderr: "" };
|
|
33
|
+
throw new GateError("Empty command provided");
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
try {
|
|
20
|
-
const stdout = execFileSync(
|
|
37
|
+
const stdout = execFileSync(binary, args, {
|
|
21
38
|
cwd,
|
|
22
39
|
encoding: "utf-8",
|
|
40
|
+
shell: false,
|
|
23
41
|
stdio: ["ignore", "pipe", "pipe"],
|
|
24
42
|
});
|
|
25
43
|
return { status: 0, stdout: stdout.trim(), stderr: "" };
|
|
@@ -59,7 +77,7 @@ export function git(args = [], opts = {}) {
|
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
export function resolveBase(root = process.cwd(), baseRef = "main") {
|
|
62
|
-
const candidates = [`origin/${baseRef}`,
|
|
80
|
+
const candidates = [`origin/${baseRef}`, `refs/remotes/origin/${baseRef}`, baseRef];
|
|
63
81
|
for (const ref of candidates) {
|
|
64
82
|
try {
|
|
65
83
|
const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
package/src/mcp.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Transform } from "node:stream";
|
|
2
2
|
import { loadConfig, resolveRoot, detectStack } from "./config.mjs";
|
|
3
3
|
import { gate, dispatch } from "./engine.mjs";
|
|
4
4
|
import { classifyRiskTier } from "./risk.mjs";
|
|
@@ -6,9 +6,86 @@ import { checkDailyBudget, lockStatus } from "./state.mjs";
|
|
|
6
6
|
|
|
7
7
|
export const MCP_SERVER_INFO = {
|
|
8
8
|
name: "jules-orchestrator-kit",
|
|
9
|
-
version: "0.
|
|
9
|
+
version: "0.20.0",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Memory-bounded MCP Frame Decoder supporting both Content-Length headers and line-delimited JSON-RPC messages.
|
|
16
|
+
*/
|
|
17
|
+
export class McpFrameDecoder extends Transform {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
super({ ...options, readableObjectMode: true });
|
|
20
|
+
this.buffer = Buffer.alloc(0);
|
|
21
|
+
this.maxFrameSize = options.maxFrameSize || MAX_MCP_FRAME_SIZE;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_transform(chunk, encoding, callback) {
|
|
25
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
26
|
+
|
|
27
|
+
if (this.buffer.length > this.maxFrameSize * 2) {
|
|
28
|
+
this.emit("error", new Error(`MCP Stdio buffer exceeded max limit of ${this.maxFrameSize} bytes`));
|
|
29
|
+
this.buffer = Buffer.alloc(0);
|
|
30
|
+
return callback();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
while (this.buffer.length > 0) {
|
|
34
|
+
const str = this.buffer.toString("utf-8");
|
|
35
|
+
const headerMatch = /Content-Length:\s*(\d+)\r?\n\r?\n/i.exec(str);
|
|
36
|
+
|
|
37
|
+
if (headerMatch) {
|
|
38
|
+
const contentLength = parseInt(headerMatch[1], 10);
|
|
39
|
+
if (contentLength > this.maxFrameSize) {
|
|
40
|
+
this.emit("error", new Error(`Frame Content-Length (${contentLength}) exceeds limit (${this.maxFrameSize})`));
|
|
41
|
+
this.buffer = Buffer.alloc(0);
|
|
42
|
+
return callback();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const headerLength = headerMatch.index + headerMatch[0].length;
|
|
46
|
+
const totalFrameLength = headerLength + contentLength;
|
|
47
|
+
|
|
48
|
+
if (this.buffer.length >= totalFrameLength) {
|
|
49
|
+
const payloadBuf = this.buffer.subarray(headerLength, totalFrameLength);
|
|
50
|
+
this.buffer = this.buffer.subarray(totalFrameLength);
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(payloadBuf.toString("utf-8"));
|
|
53
|
+
this.push(parsed);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
this.emit("error", new Error(`Malformed JSON payload in framed message: ${err.message}`));
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
} else {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const newlineIndex = this.buffer.indexOf(0x0a);
|
|
64
|
+
if (newlineIndex !== -1) {
|
|
65
|
+
const lineBuf = this.buffer.subarray(0, newlineIndex);
|
|
66
|
+
this.buffer = this.buffer.subarray(newlineIndex + 1);
|
|
67
|
+
|
|
68
|
+
const lineStr = lineBuf.toString("utf-8").trim();
|
|
69
|
+
if (!lineStr) continue;
|
|
70
|
+
|
|
71
|
+
if (/^Content-Length:\s*\d+/i.test(lineStr)) continue;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(lineStr);
|
|
75
|
+
this.push(parsed);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
this.emit("error", new Error(`Malformed JSON in line-delimited message: ${err.message}`));
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
callback();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
12
89
|
export const MCP_TOOLS = [
|
|
13
90
|
{
|
|
14
91
|
name: "dispatch_jules_task",
|
|
@@ -82,7 +159,7 @@ export async function handleMcpRequest(request, opts = {}) {
|
|
|
82
159
|
}
|
|
83
160
|
|
|
84
161
|
if (method === "notifications/initialized") {
|
|
85
|
-
return null;
|
|
162
|
+
return null;
|
|
86
163
|
}
|
|
87
164
|
|
|
88
165
|
if (method === "tools/list") {
|
|
@@ -186,26 +263,35 @@ export async function handleMcpRequest(request, opts = {}) {
|
|
|
186
263
|
}
|
|
187
264
|
|
|
188
265
|
export function startMcpServer(input = process.stdin, output = process.stdout, opts = {}) {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
rl.on("line", async (line) => {
|
|
192
|
-
const trimmed = line.trim();
|
|
193
|
-
if (!trimmed) return;
|
|
266
|
+
const decoder = new McpFrameDecoder({ maxFrameSize: opts.maxFrameSize || MAX_MCP_FRAME_SIZE });
|
|
194
267
|
|
|
268
|
+
decoder.on("data", async (request) => {
|
|
195
269
|
try {
|
|
196
|
-
const request = JSON.parse(trimmed);
|
|
197
270
|
const response = await handleMcpRequest(request, opts);
|
|
198
|
-
if (response !== null) {
|
|
271
|
+
if (response !== null && response !== undefined) {
|
|
199
272
|
output.write(JSON.stringify(response) + "\n");
|
|
200
273
|
}
|
|
201
274
|
} catch (err) {
|
|
275
|
+
// Panic boundary: Catch unhandled async exceptions and send valid JSON-RPC error
|
|
202
276
|
output.write(
|
|
203
277
|
JSON.stringify({
|
|
204
278
|
jsonrpc: "2.0",
|
|
205
|
-
id: null,
|
|
206
|
-
error: { code: -
|
|
279
|
+
id: request?.id || null,
|
|
280
|
+
error: { code: -32603, message: `Internal server panic: ${err.message}` },
|
|
207
281
|
}) + "\n"
|
|
208
282
|
);
|
|
209
283
|
}
|
|
210
284
|
});
|
|
285
|
+
|
|
286
|
+
decoder.on("error", (err) => {
|
|
287
|
+
output.write(
|
|
288
|
+
JSON.stringify({
|
|
289
|
+
jsonrpc: "2.0",
|
|
290
|
+
id: null,
|
|
291
|
+
error: { code: -32700, message: `MCP Stream framing error: ${err.message}` },
|
|
292
|
+
}) + "\n"
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
input.pipe(decoder);
|
|
211
297
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProcessGroupManager manages child processes in isolated process groups
|
|
5
|
+
* and ensures 100% leak-free cleanup on process exit or termination signals.
|
|
6
|
+
*/
|
|
7
|
+
export class ProcessGroupManager {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.activePgids = new Set();
|
|
10
|
+
this.activePids = new Set();
|
|
11
|
+
this.installed = false;
|
|
12
|
+
this.installSignalHandlers();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
installSignalHandlers() {
|
|
16
|
+
if (this.installed) return;
|
|
17
|
+
this.installed = true;
|
|
18
|
+
|
|
19
|
+
const cleanup = (signal) => {
|
|
20
|
+
this.killAll(signal || "SIGTERM");
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
process.once("SIGINT", () => {
|
|
24
|
+
cleanup("SIGINT");
|
|
25
|
+
process.exit(130);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
process.once("SIGTERM", () => {
|
|
29
|
+
cleanup("SIGTERM");
|
|
30
|
+
process.exit(143);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
process.once("exit", () => {
|
|
34
|
+
this.killAll("SIGKILL");
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
spawnProcess(command, args = [], options = {}) {
|
|
39
|
+
const opts = {
|
|
40
|
+
...options,
|
|
41
|
+
detached: true,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const child = spawn(command, args, opts);
|
|
45
|
+
|
|
46
|
+
if (child.pid) {
|
|
47
|
+
const pgid = child.pid;
|
|
48
|
+
this.activePgids.add(pgid);
|
|
49
|
+
this.activePids.add(child.pid);
|
|
50
|
+
|
|
51
|
+
child.on("exit", () => {
|
|
52
|
+
this.activePgids.delete(pgid);
|
|
53
|
+
this.activePids.delete(child.pid);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
child.on("error", () => {
|
|
57
|
+
this.activePgids.delete(pgid);
|
|
58
|
+
this.activePids.delete(child.pid);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return child;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
killAll(signal = "SIGTERM") {
|
|
66
|
+
for (const pgid of this.activePgids) {
|
|
67
|
+
try {
|
|
68
|
+
process.kill(-pgid, signal);
|
|
69
|
+
} catch (_) {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pgid, signal);
|
|
72
|
+
} catch (_) {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.activePgids.clear();
|
|
76
|
+
this.activePids.clear();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const processGroupManager = new ProcessGroupManager();
|
package/src/risk.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizePath } from "./config.mjs";
|
|
2
|
+
import { matchesGlob } from "./security.mjs";
|
|
2
3
|
|
|
3
4
|
export const RISK_TIERS = {
|
|
4
5
|
R0: "R0_COSMETIC", // Docs, markdown, comments, safe devDep patches
|
|
@@ -54,7 +55,7 @@ export function classifyRiskTier(files = [], opts = {}) {
|
|
|
54
55
|
for (const rawFile of files) {
|
|
55
56
|
const file = normalizePath(rawFile);
|
|
56
57
|
for (const pat of RESTRICTED_PATH_PATTERNS) {
|
|
57
|
-
if (file
|
|
58
|
+
if (matchesGlob(file, pat) || file.endsWith(pat)) {
|
|
58
59
|
return {
|
|
59
60
|
tier: RISK_TIERS.R3,
|
|
60
61
|
reason: `Matches restricted security/financial path pattern '${pat}'`,
|
|
@@ -78,7 +79,7 @@ export function classifyRiskTier(files = [], opts = {}) {
|
|
|
78
79
|
for (const rawFile of files) {
|
|
79
80
|
const file = normalizePath(rawFile);
|
|
80
81
|
for (const pat of CONSEQUENTIAL_PATH_PATTERNS) {
|
|
81
|
-
if (file
|
|
82
|
+
if (matchesGlob(file, pat)) {
|
|
82
83
|
return {
|
|
83
84
|
tier: RISK_TIERS.R2,
|
|
84
85
|
reason: `Matches consequential component/DB path pattern '${pat}'`,
|