jules-orchestrator-kit 0.42.0 → 0.52.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/security.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join, basename } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
- import { canonicalizePath } from "./config.mjs";
4
+ import { canonicalizePath, isWindowsAbsolutePath } from "./config.mjs";
5
5
  import { detectCrossPackageBoundaryViolations } from "./stack-detector.mjs";
6
6
 
7
7
  export const HIGH_CONFIDENCE_PATTERNS = [
@@ -208,34 +208,118 @@ export function anonymizePii(text) {
208
208
  * @param {string} globPattern
209
209
  * @param {{ caseInsensitive?: boolean }} [opts]
210
210
  */
211
+ /**
212
+ * Matches one `/`-free glob segment against one `/`-free path segment.
213
+ *
214
+ * Only `*` (zero or more characters) and `?` (exactly one) are special; every
215
+ * other character — including regex metacharacters like `(`, `+`, `.`, `[` —
216
+ * is matched literally, preserving the escaping behaviour the old regex
217
+ * translation had. A segment that is exactly `*` keeps its historical one-or-
218
+ * more semantics, so `*` cannot match an empty segment.
219
+ *
220
+ * Implemented with the classic greedy-star wildcard algorithm rather than a
221
+ * compiled regex: a segment like `*a*a*a*a*a*a*b` translated to
222
+ * `^[^/]*a[^/]*a…$` and backtracked exponentially on a long run of `a`s, so
223
+ * this path must never build a regex. The algorithm scans each character a
224
+ * bounded number of times and cannot blow up the way the regex could.
225
+ *
226
+ * @param {string} str
227
+ * @param {string} pattern
228
+ * @param {boolean} [caseInsensitive]
229
+ * @returns {boolean}
230
+ */
231
+ function matchGlobSegment(str, pattern, caseInsensitive = false) {
232
+ if (pattern === "*") return str.length > 0;
233
+
234
+ let s = caseInsensitive ? str.toLowerCase() : str;
235
+ let p = caseInsensitive ? pattern.toLowerCase() : pattern;
236
+
237
+ let si = 0;
238
+ let pi = 0;
239
+ let star = -1;
240
+ let matchIdx = 0;
241
+
242
+ while (si < s.length) {
243
+ if (pi < p.length && (p[pi] === "?" || p[pi] === s[si])) {
244
+ si++;
245
+ pi++;
246
+ } else if (pi < p.length && p[pi] === "*") {
247
+ star = pi;
248
+ matchIdx = si;
249
+ pi++;
250
+ } else if (star !== -1) {
251
+ pi = star + 1;
252
+ matchIdx++;
253
+ si = matchIdx;
254
+ } else {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ while (pi < p.length && p[pi] === "*") pi++;
260
+ return pi === p.length;
261
+ }
262
+
263
+ /**
264
+ * Linear-time glob matcher over `/`-split segments.
265
+ *
266
+ * `**` matches zero or more whole segments; every other pattern segment
267
+ * matches exactly one path segment via `matchGlobSegment`. This is a
268
+ * bottom-up dynamic program with a rolling array: O(n·m) time and O(m) memory
269
+ * for n path and m pattern segments, and it builds no regex at all.
270
+ *
271
+ * The previous implementation translated `**` into overlapping dot-star and
272
+ * start-anchored `(?: … |^)` alternations (`SEC-01`). Anchored against `$`, a
273
+ * pattern like `*a*a*a*a*a*a*a*a*b` or a chain of globstars caused catastrophic
274
+ * backtracking — the match time grew exponentially with input length and a
275
+ * hostile deny rule or file list could stall the dispatch gate. The DP
276
+ * replaces every one of those constructs with a bounded scan.
277
+ *
278
+ * @param {string[]} pathSegs
279
+ * @param {string[]} patSegs
280
+ * @param {boolean} [caseInsensitive]
281
+ * @returns {boolean}
282
+ */
283
+ function matchGlobSegments(pathSegs, patSegs, caseInsensitive = false) {
284
+ const n = pathSegs.length;
285
+ const m = patSegs.length;
286
+
287
+ // next[j] answers "does pathSegs[i+1..] match patSegs[j..]?". Seeded for the
288
+ // empty-path row (i = n): only true when every remaining pattern segment is
289
+ // `**`, since those are the only segments that can match zero path segments.
290
+ let next = new Array(m + 1).fill(false);
291
+ next[m] = true;
292
+ for (let j = m - 1; j >= 0; j--) {
293
+ next[j] = patSegs[j] === "**" && next[j + 1];
294
+ }
295
+
296
+ for (let i = n - 1; i >= 0; i--) {
297
+ const cur = new Array(m + 1).fill(false);
298
+ // cur[m] stays false: a path segment remains but the pattern is exhausted.
299
+ for (let j = m - 1; j >= 0; j--) {
300
+ if (patSegs[j] === "**") {
301
+ // Consume this segment and keep `**` (next[j]), or match zero segments
302
+ // and move on (cur[j + 1]).
303
+ cur[j] = next[j] || cur[j + 1];
304
+ } else if (matchGlobSegment(pathSegs[i], patSegs[j], caseInsensitive)) {
305
+ cur[j] = next[j + 1];
306
+ }
307
+ }
308
+ next = cur;
309
+ }
310
+
311
+ return next[0];
312
+ }
313
+
211
314
  export function matchesGlob(filePath, globPattern, opts = {}) {
212
315
  if (!filePath || !globPattern) return false;
213
316
  const file = canonicalizePath(filePath);
214
317
  const pattern = canonicalizePath(globPattern);
215
- const flags = opts.caseInsensitive ? "i" : "";
216
-
217
- if (opts.caseInsensitive ? file.toLowerCase() === pattern.toLowerCase() : file === pattern) return true;
318
+ const caseInsensitive = Boolean(opts.caseInsensitive);
218
319
 
219
- const parts = pattern.split("/");
220
- const regexParts = parts.map((part) => {
221
- if (part === "**") return "___GLOBSTAR___";
222
- if (part === "*") return "[^/]+";
223
- const escaped = part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
224
- return escaped.replace(/\\\*/g, "[^/]*").replace(/\\\?/g, ".");
225
- });
320
+ if (caseInsensitive ? file.toLowerCase() === pattern.toLowerCase() : file === pattern) return true;
226
321
 
227
- let regexStr = regexParts.join("/");
228
- regexStr = regexStr
229
- .replace(/^___GLOBSTAR___\//g, "(?:.*/|^)")
230
- .replace(/\/___GLOBSTAR___$/g, "(?:/.*|$)")
231
- .replace(/\/___GLOBSTAR___\//g, "(?:/|/.+/|/)")
232
- .replace(/___GLOBSTAR___/g, ".*");
233
-
234
- try {
235
- return new RegExp(`^${regexStr}$`, flags).test(file);
236
- } catch (_) {
237
- return false;
238
- }
322
+ return matchGlobSegments(file.split("/"), pattern.split("/"), caseInsensitive);
239
323
  }
240
324
 
241
325
  export function isForbiddenPath(filePath, config = {}) {
@@ -256,8 +340,19 @@ export function checkScope(files = [], scope = {}, opts = {}) {
256
340
  const file = canonicalizePath(rawFile);
257
341
 
258
342
  // A path that climbs out of the repository root can never be legitimate and
259
- // must not be silently pattern-matched against repo-relative rules.
260
- if (file === ".." || file.startsWith("../") || file.startsWith("/")) {
343
+ // must not be silently pattern-matched against repo-relative rules. This
344
+ // covers every spelling: POSIX absolute (`/etc/passwd`) and traversal
345
+ // (`../`, `..`), Windows drive-qualified and drive-relative (`C:\...`,
346
+ // `C:/...`, `C:foo`), and UNC (`\\server\share`, `//server/share`). The raw
347
+ // spelling is checked as well as the canonical one, because canonicalisation
348
+ // folds a leading `//` UNC into `/` and the check must fail on both.
349
+ if (
350
+ file === ".." ||
351
+ file.startsWith("../") ||
352
+ file.startsWith("/") ||
353
+ isWindowsAbsolutePath(rawFile) ||
354
+ isWindowsAbsolutePath(file)
355
+ ) {
261
356
  violations.push({ file, reason: "Path escapes the repository root", rule: "deny", pattern: "<traversal>" });
262
357
  continue;
263
358
  }
@@ -393,6 +488,59 @@ export function checkCrossPackageImports(diffOrText = "", root = process.cwd(),
393
488
  // regex without changing how the value renders, copies, or authenticates.
394
489
  const INVISIBLE_CHARS = /[\u00AD\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2064\uFEFF]/g;
395
490
 
491
+ // Unicode lookalikes that NFKD does NOT decompose. Full-width and other
492
+ // compatibility forms are handled by String#normalize("NFKD") below; these are
493
+ // the Cyrillic/Greek/Latin homoglyphs that survive NFKD because they are
494
+ // distinct code points with no compatibility decomposition. A credential
495
+ // scanner without this table can be defeated by a single substituted glyph,
496
+ // e.g. `ghp_` spelled with Cyrillic `р`.
497
+ const CONFUSABLE_TO_ASCII = new Map([
498
+ // Cyrillic
499
+ ["А", "A"], ["В", "B"], ["Е", "E"], ["К", "K"], ["М", "M"], ["Н", "H"],
500
+ ["О", "O"], ["Р", "P"], ["С", "C"], ["Т", "T"], ["У", "Y"], ["Х", "X"],
501
+ ["а", "a"], ["е", "e"], ["о", "o"], ["р", "p"], ["с", "c"], ["у", "y"],
502
+ ["х", "x"], ["і", "i"], ["ј", "j"], ["ѕ", "s"],
503
+ // Greek
504
+ ["Α", "A"], ["Β", "B"], ["Ε", "E"], ["Ζ", "Z"], ["Η", "H"], ["Ι", "I"],
505
+ ["Κ", "K"], ["Μ", "M"], ["Ν", "N"], ["Ο", "O"], ["Ρ", "P"], ["Τ", "T"],
506
+ ["Υ", "Y"], ["Χ", "X"], ["ο", "o"], ["ι", "i"], ["ν", "v"], ["υ", "u"],
507
+ ["ρ", "p"], ["τ", "t"], ["χ", "x"],
508
+ // Other Unicode lookalikes
509
+ ["ſ", "s"], // U+017F LATIN SMALL LETTER LONG S
510
+ ["K", "K"], // U+212A KELVIN SIGN
511
+ ]);
512
+
513
+ /**
514
+ * Reduces the confusable spellings a credential can hide behind to plain
515
+ * ASCII before the secret patterns run (`SEC-04`).
516
+ *
517
+ * 1. NFKD decomposes full-width and other compatibility forms
518
+ * (`ghp_…` → `ghp_…`).
519
+ * 2. Combining marks the decomposition may leave behind are stripped
520
+ * (`e\u0301` → `e`).
521
+ * 3. The curated lookalike table maps Cyrillic/Greek/Latin homoglyphs that
522
+ * NFKD cannot see through to their ASCII target.
523
+ *
524
+ * This is the zero-dependency subset of Unicode TR39 confusable handling; the
525
+ * full confusables data table is intentionally omitted so the kit keeps
526
+ * shipping with no runtime dependencies.
527
+ *
528
+ * @param {string} str
529
+ * @returns {string}
530
+ */
531
+ function normalizeSecretText(str) {
532
+ if (!str || typeof str !== "string") return str;
533
+ let out = str;
534
+ try {
535
+ out = out.normalize("NFKD");
536
+ } catch (_) {}
537
+ out = out.replace(/[\u0300-\u036f]/g, "");
538
+ for (const [from, to] of CONFUSABLE_TO_ASCII) {
539
+ out = out.split(from).join(to);
540
+ }
541
+ return out;
542
+ }
543
+
396
544
  // A credential split across a source-level string concatenation is invisible to
397
545
  // a line-oriented scanner. This is not only an evasion technique — formatters
398
546
  // wrap long string literals exactly this way, so it also happens by accident.
@@ -442,9 +590,14 @@ function secretScanVariants(addedLines) {
442
590
 
443
591
  const hexDecoded = tryHexDecodeTokens(dejoined);
444
592
  const pctDecoded = tryPercentDecode(dejoined);
593
+ // Confusable / NFKD normalisation runs over both the raw text and the
594
+ // concatenation-collapsed text, so a credential that is both split across a
595
+ // source-level join AND spelled with homoglyphs still surfaces.
596
+ const confusable = normalizeSecretText(stripped);
597
+ const confusableDejoined = normalizeSecretText(dejoined);
445
598
 
446
599
  return {
447
- all: [...new Set([addedLines, stripped, dejoined, hexDecoded, pctDecoded])],
600
+ all: [...new Set([addedLines, stripped, dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined])],
448
601
  normalized: dejoined,
449
602
  };
450
603
  }
@@ -600,7 +753,7 @@ function splitDiffByFile(diffText) {
600
753
  };
601
754
 
602
755
  for (const line of diffText.split("\n")) {
603
- if (line.startsWith("+++")) {
756
+ if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
604
757
  const name = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
605
758
  select(name && name !== "/dev/null" ? name : null);
606
759
  lineNo = null;
@@ -673,6 +826,104 @@ function locateFindingLine(lines, type) {
673
826
  return null;
674
827
  }
675
828
 
829
+ /**
830
+ * Detects test file assertion tampering, weakening, or test skips.
831
+ *
832
+ * @param {string} diffOrText - Unified git diff
833
+ * @param {Object} [options]
834
+ * @param {boolean} [options.allowTestModifications=false]
835
+ * @returns {{ ok: boolean, violations: Array<{ file: string, type: string, line?: number, reason: string }> }}
836
+ */
837
+ export function checkTestTampering(diffOrText = "", options = {}) {
838
+ if (!diffOrText || typeof diffOrText !== "string") return { ok: true, violations: [] };
839
+ if (options.allowTestModifications === true) return { ok: true, violations: [] };
840
+
841
+ const violations = [];
842
+ const lines = diffOrText.split("\n");
843
+ let currentFile = null;
844
+ let currentLineNo = null;
845
+
846
+ const isTestFile = (f) => {
847
+ if (!f) return false;
848
+ const n = f.replace(/\\/g, "/").toLowerCase();
849
+ return (
850
+ n.includes(".test.") ||
851
+ n.includes(".spec.") ||
852
+ n.includes("_test.") ||
853
+ n.includes("/test/") ||
854
+ n.includes("/tests/") ||
855
+ n.includes("/__tests__/")
856
+ );
857
+ };
858
+
859
+ const SKIP_INJECTIONS = [
860
+ { pattern: /\b(?:it|test|describe|context)\.skip\s*\(/i, desc: "Injected test skip (.skip())" },
861
+ { pattern: /\b(?:xit|xtest|xdescribe)\s*\(/i, desc: "Injected disabled test (xit/xtest/xdescribe)" },
862
+ { pattern: /@pytest\.mark\.skip/i, desc: "Injected Pytest skip decorator (@pytest.mark.skip)" },
863
+ { pattern: /\bt\.Skip\s*\(/i, desc: "Injected Go test skip (t.Skip())" },
864
+ { pattern: /#\[ignore\]/i, desc: "Injected Rust test ignore attribute (#[ignore])" },
865
+ { pattern: /\/\/\s*@ts-ignore/i, desc: "Injected TypeScript ignore comment (// @ts-ignore)" },
866
+ { pattern: /\/\/\s*@ts-nocheck/i, desc: "Injected TypeScript nocheck directive (// @ts-nocheck)" },
867
+ ];
868
+
869
+ const COMMENTED_ASSERTION = /^\+\s*(?:\/\/|\/\*)\s*(?:expect\(|assert\.|assert\(|t\.expect|t\.assert)/i;
870
+
871
+ for (let i = 0; i < lines.length; i++) {
872
+ const line = lines[i];
873
+ if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
874
+ const target = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
875
+ currentFile = target && target !== "/dev/null" ? target : null;
876
+ currentLineNo = null;
877
+ continue;
878
+ }
879
+
880
+ const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
881
+ if (hunkMatch) {
882
+ currentLineNo = Number(hunkMatch[1]);
883
+ continue;
884
+ }
885
+
886
+ if (!currentFile || !isTestFile(currentFile)) {
887
+ continue;
888
+ }
889
+
890
+ if (line.startsWith("+") && !line.startsWith("+++")) {
891
+ const addedText = line.slice(1);
892
+
893
+ // Check skip injections
894
+ for (const skipRule of SKIP_INJECTIONS) {
895
+ if (skipRule.pattern.test(addedText)) {
896
+ violations.push({
897
+ file: currentFile,
898
+ line: currentLineNo,
899
+ type: "TEST_SKIP_INJECTION",
900
+ reason: `Test Tamper Guard: ${skipRule.desc} found in ${currentFile}${currentLineNo ? `:${currentLineNo}` : ""}`,
901
+ });
902
+ }
903
+ }
904
+
905
+ // Check commented-out assertions
906
+ if (COMMENTED_ASSERTION.test(line)) {
907
+ violations.push({
908
+ file: currentFile,
909
+ line: currentLineNo,
910
+ type: "COMMENTED_ASSERTION",
911
+ reason: `Test Tamper Guard: Commented-out test assertion detected in ${currentFile}${currentLineNo ? `:${currentLineNo}` : ""}`,
912
+ });
913
+ }
914
+
915
+ if (currentLineNo !== null) currentLineNo++;
916
+ } else if (currentLineNo !== null && !line.startsWith("-") && !line.startsWith("\\")) {
917
+ currentLineNo++;
918
+ }
919
+ }
920
+
921
+ return {
922
+ ok: violations.length === 0,
923
+ violations,
924
+ };
925
+ }
926
+
676
927
  export function scanDiff(diffTextStr = "", options = {}) {
677
928
  if (!diffTextStr) return { ok: true, findings: [] };
678
929
 
@@ -727,8 +978,15 @@ export function scanDiff(diffTextStr = "", options = {}) {
727
978
  }
728
979
  }
729
980
 
981
+ const tamperingRes = checkTestTampering(diffTextStr, options);
982
+ if (!tamperingRes.ok) {
983
+ for (const v of tamperingRes.violations) {
984
+ findings.push({ severity: "CRITICAL", type: "TEST_TAMPERING_DETECTED", file: v.file ?? null, line: v.line ?? null, description: v.reason });
985
+ }
986
+ }
987
+
730
988
  return {
731
- ok: secretsOk && edgeRes.ok && crossPkgRes.ok,
989
+ ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok,
732
990
  findings,
733
991
  };
734
992
  }
@@ -0,0 +1,312 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { createProvider } from "./provider.mjs";
5
+ import { loadConfig, resolveRoot } from "./config.mjs";
6
+ import { redactSecrets } from "./security.mjs";
7
+
8
+ /**
9
+ * Parses human-readable duration strings (e.g. "7d", "24h", "30m", "60s") into milliseconds.
10
+ *
11
+ * @param {string|number} duration
12
+ * @returns {number} Milliseconds
13
+ */
14
+ export function parseAgeDuration(duration) {
15
+ if (typeof duration === "number" && Number.isFinite(duration)) return duration;
16
+ if (!duration || typeof duration !== "string") return 0;
17
+ const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([smhdw])$/i);
18
+ if (!match) {
19
+ const num = Number(duration);
20
+ return Number.isFinite(num) ? num : 0;
21
+ }
22
+ const val = parseFloat(match[1]);
23
+ const unit = match[2].toLowerCase();
24
+ switch (unit) {
25
+ case "s": return val * 1000;
26
+ case "m": return val * 60 * 1000;
27
+ case "h": return val * 60 * 60 * 1000;
28
+ case "d": return val * 24 * 60 * 60 * 1000;
29
+ case "w": return val * 7 * 24 * 60 * 60 * 1000;
30
+ default: return 0;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Extracts git diff patch, PR details, and modified files from a Jules session.
36
+ *
37
+ * @param {string} sessionId
38
+ * @param {object} opts
39
+ * @returns {Promise<{ ok: boolean, id: string, patch: string, pr: object|null, files: string[], error?: string }>}
40
+ */
41
+ export async function extractSessionPatch(sessionId, opts = {}) {
42
+ if (!sessionId || typeof sessionId !== "string") {
43
+ throw new TypeError("extractSessionPatch requires a valid sessionId string");
44
+ }
45
+ const root = opts.root || resolveRoot();
46
+ const config = opts.config || loadConfig(root);
47
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
48
+
49
+ const session = await provider.getSession(sessionId, opts);
50
+ const activitiesRes = await provider.listActivities(sessionId, opts).catch(() => ({ activities: [] }));
51
+ const activities = activitiesRes.activities || [];
52
+
53
+ let patch = "";
54
+ let pr = null;
55
+ const files = new Set();
56
+
57
+ // 1. Inspect session outputs
58
+ const outputs = session?.raw?.outputs || session?.outputs || [];
59
+ for (const out of outputs) {
60
+ if (out.pullRequest) {
61
+ pr = out.pullRequest;
62
+ }
63
+ if (out.gitPatch || out.patch || out.diff) {
64
+ patch = out.gitPatch || out.patch || out.diff;
65
+ }
66
+ }
67
+
68
+ // 2. Inspect activity artifacts and logs if patch wasn't in top-level output
69
+ for (const act of activities) {
70
+ if (act.pullRequest) pr = act.pullRequest;
71
+ if (act.gitPatch || act.patch || act.diff) {
72
+ patch = patch || act.gitPatch || act.patch || act.diff;
73
+ }
74
+ if (act.artifacts && Array.isArray(act.artifacts)) {
75
+ for (const art of act.artifacts) {
76
+ if (art.patch || art.diff || (typeof art.content === "string" && art.content.startsWith("diff --git"))) {
77
+ patch = patch || art.patch || art.diff || art.content;
78
+ }
79
+ if (art.path || art.filename) {
80
+ files.add(art.path || art.filename);
81
+ }
82
+ }
83
+ }
84
+ if (act.changedFiles && Array.isArray(act.changedFiles)) {
85
+ act.changedFiles.forEach((f) => files.add(f));
86
+ }
87
+ }
88
+
89
+ // Extract changed files from patch headers if files set is empty
90
+ if (patch && files.size === 0) {
91
+ const fileMatches = patch.matchAll(/^diff --git a\/(.+?) b\/(.+?)$/gm);
92
+ for (const m of fileMatches) {
93
+ files.add(m[2]);
94
+ }
95
+ }
96
+
97
+ return {
98
+ ok: Boolean(patch || pr),
99
+ id: sessionId,
100
+ patch: patch ? (patch.endsWith("\n") ? patch : patch + "\n") : "",
101
+ pr,
102
+ files: Array.from(files),
103
+ rawSession: session.raw,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Applies a Jules session's patch to the local working tree with git apply safety checks.
109
+ *
110
+ * @param {string} sessionId
111
+ * @param {object} opts
112
+ * @returns {Promise<{ ok: boolean, patchApplied: boolean, checkPassed: boolean, patch: string, files: string[], error?: string }>}
113
+ */
114
+ export async function applySessionPatch(sessionId, opts = {}) {
115
+ const root = opts.root || resolveRoot();
116
+ const res = await extractSessionPatch(sessionId, { ...opts, root });
117
+
118
+ if (!res.patch) {
119
+ return {
120
+ ok: false,
121
+ patchApplied: false,
122
+ checkPassed: false,
123
+ patch: "",
124
+ files: res.files || [],
125
+ error: "No git patch found in session output or activities.",
126
+ };
127
+ }
128
+
129
+ if (opts.save) {
130
+ const savePath = resolve(root, opts.save);
131
+ writeFileSync(savePath, res.patch, "utf-8");
132
+ }
133
+
134
+ // 1. Dry run: git apply --check
135
+ const checkRes = spawnSync("git", ["apply", "--check", "-"], {
136
+ cwd: root,
137
+ input: res.patch,
138
+ encoding: "utf-8",
139
+ });
140
+
141
+ if (checkRes.status !== 0) {
142
+ const stderr = (checkRes.stderr || "").trim();
143
+ return {
144
+ ok: false,
145
+ patchApplied: false,
146
+ checkPassed: false,
147
+ patch: res.patch,
148
+ files: res.files,
149
+ error: `git apply --check failed: ${stderr || "Patch does not cleanly apply to working directory"}`,
150
+ };
151
+ }
152
+
153
+ // 2. If apply requested, execute git apply
154
+ if (opts.apply) {
155
+ const applyRes = spawnSync("git", ["apply", "-"], {
156
+ cwd: root,
157
+ input: res.patch,
158
+ encoding: "utf-8",
159
+ });
160
+
161
+ if (applyRes.status !== 0) {
162
+ const stderr = (applyRes.stderr || "").trim();
163
+ return {
164
+ ok: false,
165
+ patchApplied: false,
166
+ checkPassed: true,
167
+ patch: res.patch,
168
+ files: res.files,
169
+ error: `git apply failed: ${stderr}`,
170
+ };
171
+ }
172
+
173
+ return {
174
+ ok: true,
175
+ patchApplied: true,
176
+ checkPassed: true,
177
+ patch: res.patch,
178
+ files: res.files,
179
+ };
180
+ }
181
+
182
+ return {
183
+ ok: true,
184
+ patchApplied: false,
185
+ checkPassed: true,
186
+ patch: res.patch,
187
+ files: res.files,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Gathers failure context and error output from a failed session and synthesizes a new OODA retry session.
193
+ *
194
+ * @param {string} sessionId
195
+ * @param {object} opts
196
+ * @returns {Promise<{ ok: boolean, originalSessionId: string, newSession: object, failureReason: string }>}
197
+ */
198
+ export async function retrySession(sessionId, opts = {}) {
199
+ if (!sessionId || typeof sessionId !== "string") {
200
+ throw new TypeError("retrySession requires a valid sessionId string");
201
+ }
202
+ const root = opts.root || resolveRoot();
203
+ const config = opts.config || loadConfig(root);
204
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
205
+
206
+ const session = await provider.getSession(sessionId, opts);
207
+ const activitiesRes = await provider.listActivities(sessionId, opts).catch(() => ({ activities: [] }));
208
+ const activities = activitiesRes.activities || [];
209
+
210
+ // Extract failure diagnostics
211
+ const errors = [];
212
+ for (const act of activities) {
213
+ if (act.error) errors.push(typeof act.error === "string" ? act.error : JSON.stringify(act.error));
214
+ if (act.executionOutput && (act.exitCode !== 0 || act.status === "FAILED")) {
215
+ errors.push(act.executionOutput);
216
+ }
217
+ }
218
+
219
+ const rawPrompt = session?.raw?.prompt || session?.prompt || "";
220
+ const title = session?.raw?.title || `Retry of Session ${sessionId}`;
221
+ const failureReason = errors.join("\n").slice(0, 4000) || "Previous session did not complete cleanly.";
222
+
223
+ let synthesizedPrompt = rawPrompt;
224
+ if (opts.withFailure !== false && failureReason) {
225
+ synthesizedPrompt = `${rawPrompt}\n\n[PREVIOUS_ATTEMPT_FAILURE_DIAGNOSTIC]\nThe previous automated session failed with the following error output:\n\`\`\`\n${redactSecrets(failureReason)}\n\`\`\`\nPlease analyze this failure, fix the root cause, and ensure all tests pass.\n`;
226
+ }
227
+
228
+ const source = session?.raw?.sourceContext?.source || opts.source;
229
+ const branch = session?.raw?.sourceContext?.githubRepoContext?.startingBranch || opts.branch || "main";
230
+
231
+ const dispatchRes = await provider.dispatch(
232
+ {
233
+ title: opts.title || title,
234
+ prompt: synthesizedPrompt,
235
+ role: opts.role,
236
+ source,
237
+ branch,
238
+ },
239
+ opts
240
+ );
241
+
242
+ return {
243
+ ok: true,
244
+ originalSessionId: sessionId,
245
+ newSession: dispatchRes,
246
+ failureReason,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Queries and batch-archives old or completed Jules sessions based on age and state filters.
252
+ *
253
+ * @param {object} opts
254
+ * @returns {Promise<{ ok: boolean, matchedCount: number, archivedCount: number, sessions: Array<{ id: string, state: string, createTime: string, action: string }> }>}
255
+ */
256
+ export async function pruneSessions(opts = {}) {
257
+ const root = opts.root || resolveRoot();
258
+ const config = opts.config || loadConfig(root);
259
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
260
+
261
+ const listRes = await provider.listSessions({ ...opts, pageSize: opts.pageSize || 100 });
262
+ const allSessions = listRes.sessions || [];
263
+
264
+ const maxAgeMs = opts.age ? parseAgeDuration(opts.age) : 0;
265
+ const now = Date.now();
266
+ const targetState = opts.state ? String(opts.state).toUpperCase() : null;
267
+
268
+ const matched = [];
269
+ for (const s of allSessions) {
270
+ const state = (s.state || s.status || "UNKNOWN").toUpperCase();
271
+ if (targetState && state !== targetState) continue;
272
+
273
+ if (maxAgeMs > 0) {
274
+ const timeStr = s.createTime || s.updateTime;
275
+ const sessionTime = timeStr ? new Date(timeStr).getTime() : 0;
276
+ if (sessionTime > 0 && now - sessionTime < maxAgeMs) {
277
+ continue; // Session is newer than the cutoff age
278
+ }
279
+ }
280
+
281
+ matched.push(s);
282
+ }
283
+
284
+ const results = [];
285
+ for (const s of matched) {
286
+ const id = s.id || (s.name ? s.name.split("/").pop() : "");
287
+ if (!id) continue;
288
+
289
+ if (opts.dryRun) {
290
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "DRY_RUN_SKIP" });
291
+ } else {
292
+ try {
293
+ if (opts.delete) {
294
+ await provider.deleteSession(id, opts);
295
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "DELETED" });
296
+ } else {
297
+ await provider.archiveSession(id, opts);
298
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "ARCHIVED" });
299
+ }
300
+ } catch (err) {
301
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: `ERROR: ${err.message}` });
302
+ }
303
+ }
304
+ }
305
+
306
+ return {
307
+ ok: true,
308
+ matchedCount: matched.length,
309
+ archivedCount: results.filter((r) => r.action === "ARCHIVED" || r.action === "DELETED").length,
310
+ sessions: results,
311
+ };
312
+ }