@misterhuydo/sentinel 1.6.0 → 1.6.1

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/.cairn/.hint-lock CHANGED
@@ -1 +1 @@
1
- 2026-04-22T05:30:19.725Z
1
+ 2026-04-24T10:50:33.264Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-04-22T05:33:19.801Z",
3
- "checkpoint_at": "2026-04-22T05:33:19.802Z",
2
+ "message": "Auto-checkpoint at 2026-04-24T10:58:52.087Z",
3
+ "checkpoint_at": "2026-04-24T10:58:52.089Z",
4
4
  "active_files": [
5
5
  "J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
6
6
  "J:\\Projects\\Sentinel\\cli\\lib\\test.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -1 +1 @@
1
- __version__ = "1.6.0"
1
+ __version__ = "1.6.1"
@@ -254,12 +254,22 @@ def _is_auth_error(output: str) -> bool:
254
254
  return any(hint in low for hint in _AUTH_ERROR_HINTS)
255
255
 
256
256
 
257
- def _claude_cmd(bin_path: str, prompt: str, session_id: str = "") -> list[str]:
257
+ def _claude_cmd(
258
+ bin_path: str,
259
+ prompt: str,
260
+ session_id: str = "",
261
+ use_bare: bool = True,
262
+ ) -> list[str]:
258
263
  """Build the `claude --print` command line.
259
264
 
260
265
  `session_id` (if non-empty) attaches `--resume <id>` so Claude continues an
261
266
  existing session — gives prompt-cache reuse and shared context across fixes.
262
267
 
268
+ `use_bare` controls the `--bare` flag, which forces `ANTHROPIC_API_KEY`-only
269
+ auth. It MUST be True for the API-key attempt (claude needs a key in env)
270
+ and MUST be False for the OAuth attempt (otherwise claude refuses to read
271
+ the cached `claude login` token). The caller picks per attempt.
272
+
263
273
  Output is forced to `--output-format json` so the caller can extract the
264
274
  session_id, cost, and result text deterministically.
265
275
  """
@@ -268,10 +278,9 @@ def _claude_cmd(bin_path: str, prompt: str, session_id: str = "") -> list[str]:
268
278
  skip = _os.getuid() != 0
269
279
  except AttributeError:
270
280
  skip = True # Windows — always pass flag
271
- # --bare: forces ANTHROPIC_API_KEY-only auth, skips keychain/OAuth/hooks.
272
- # Required on headless servers (EC2) where Claude Code 2.x silently returns
273
- # empty output when keychain auth fails.
274
- cmd = [bin_path, "--bare"]
281
+ cmd = [bin_path]
282
+ if use_bare:
283
+ cmd.append("--bare")
275
284
  if skip:
276
285
  cmd.append("--dangerously-skip-permissions")
277
286
  if session_id:
@@ -346,6 +355,7 @@ def _run_claude_attempt(
346
355
  on_progress=None,
347
356
  cmd_override: list | None = None,
348
357
  session_id: str = "",
358
+ use_bare: bool | None = None,
349
359
  ) -> tuple[str, bool]:
350
360
  """
351
361
  Run claude CLI with the given env. Returns (output, timed_out).
@@ -354,11 +364,16 @@ def _run_claude_attempt(
354
364
  (deduped — same message not repeated consecutively).
355
365
  cmd_override: if provided, use this command list instead of _claude_cmd() default.
356
366
  session_id: if provided, passes --resume to Claude to continue an existing session.
367
+ use_bare: if None (default), auto-detect — True iff env carries ANTHROPIC_API_KEY.
368
+ Pass --bare ONLY for the API-key attempt; the OAuth attempt must
369
+ omit it so Claude reads the cached `claude login` token.
357
370
  """
358
371
  import threading as _threading
359
372
 
373
+ if use_bare is None:
374
+ use_bare = bool(env.get("ANTHROPIC_API_KEY"))
360
375
  proc = subprocess.Popen(
361
- cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id),
376
+ cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id, use_bare=use_bare),
362
377
  stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
363
378
  text=True, env=env, cwd=cwd or None,
364
379
  )
@@ -628,12 +628,15 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
628
628
  try:
629
629
  patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
630
630
  _loop = asyncio.get_event_loop()
631
+ all_repos = list(cfg_loader.repos.values())
631
632
 
632
633
  # Run blocking subprocess work in thread executor so Boss stays responsive.
633
634
  # _progress is thread-safe (HTTP POST) so pass it as streaming callback.
634
635
  _progress(":brain: Analyzing with Claude Code...")
635
636
  status, patch_path, marker = await _loop.run_in_executor(
636
- None, generate_fix, event, repo, sentinel, patches_dir, store, _progress
637
+ None,
638
+ lambda: generate_fix(event, repo, sentinel, patches_dir, store,
639
+ _progress, all_repos),
637
640
  )
638
641
 
639
642
  submitter_uid = getattr(event, "submitter_user_id", "")
@@ -652,6 +655,133 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
652
655
  return {"submitter": submitter_uid, "repo_name": repo.repo_name,
653
656
  "status": "blocked", "summary": reason_text[:120], "pr_url": ""}
654
657
 
658
+ # ── Try multi-repo apply first; fall back to single-repo on legacy patches ──
659
+ _progress(f":mag: Patch generated — applying & testing...")
660
+ multi_results = await _loop.run_in_executor(
661
+ None, apply_and_commit_multi, event, patch_path, all_repos, sentinel,
662
+ )
663
+
664
+ if multi_results:
665
+ multi_results = await _loop.run_in_executor(
666
+ None, publish_multi, event, multi_results, sentinel,
667
+ )
668
+
669
+ committed = [r for r in multi_results if r["status"] == "committed"]
670
+ failed = [r for r in multi_results if r["status"] != "committed"]
671
+
672
+ for r in multi_results:
673
+ pr_state = "open" if r.get("pr_url") else (
674
+ "merged" if r["status"] == "committed" and auto_commit else ""
675
+ )
676
+ try:
677
+ store.record_fix_repo(
678
+ event.fingerprint, r["repo_name"],
679
+ branch=r.get("branch", ""), commit_hash=r.get("commit_hash", ""),
680
+ pr_url=r.get("pr_url", ""),
681
+ pr_state=pr_state if r["status"] == "committed" else r["status"],
682
+ apply_order=r.get("apply_order", 0),
683
+ )
684
+ except Exception as _se:
685
+ logger.warning("record_fix_repo failed for %s: %s", r["repo_name"], _se)
686
+
687
+ primary = next((r for r in multi_results if r["repo_name"] == repo.repo_name), None)
688
+ if primary and primary["status"] == "committed":
689
+ store.record_fix(
690
+ event.fingerprint,
691
+ "applied" if auto_commit else "pending",
692
+ patch_path=str(patch_path),
693
+ commit_hash=primary["commit_hash"],
694
+ branch=primary.get("branch", ""),
695
+ pr_url=primary.get("pr_url", ""),
696
+ repo_name=repo.repo_name,
697
+ sentinel_marker=marker,
698
+ )
699
+ else:
700
+ store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
701
+
702
+ if not committed:
703
+ reasons = "; ".join(f"{r['repo_name']}: {r['reason']}" for r in failed)
704
+ _progress(f":x: Multi-repo fix aborted — {reasons[:300]}")
705
+ notify_fix_blocked(sentinel, event.source, event.message,
706
+ reason=f"Multi-repo apply failed: {reasons[:400]}",
707
+ repo_name=repo.repo_name,
708
+ submitter_user_id=submitter_uid,
709
+ body=getattr(event, "body", ""))
710
+ mark_done(event.issue_file)
711
+ return {"submitter": submitter_uid, "repo_name": repo.repo_name,
712
+ "status": "blocked", "summary": "Multi-repo apply failed", "pr_url": ""}
713
+
714
+ if failed:
715
+ _progress(
716
+ f":warning: Multi-repo fix PARTIAL — committed in "
717
+ f"{', '.join(r['repo_name'] for r in committed)}; "
718
+ f"failed in {', '.join(r['repo_name'] for r in failed)}"
719
+ )
720
+
721
+ pr_lines = []
722
+ for r in committed:
723
+ if r.get("pr_url"):
724
+ pr_lines.append(f" • `{r['repo_name']}` → {r['pr_url']}")
725
+ else:
726
+ pr_lines.append(
727
+ f" • `{r['repo_name']}` → pushed to `{r.get('branch','main')}` "
728
+ f"(`{r.get('commit_hash','')[:8]}`)"
729
+ )
730
+ if pr_lines:
731
+ _progress(":white_check_mark: Fix complete:\n" + "\n".join(pr_lines))
732
+
733
+ if auto_commit and not auto_release:
734
+ for r in committed:
735
+ if r.get("commit_hash") and r.get("branch"):
736
+ store.add_pending_release(
737
+ r["repo_name"], r["commit_hash"], r["branch"],
738
+ description=event.message[:120],
739
+ fingerprint=event.fingerprint,
740
+ )
741
+
742
+ if primary and primary["status"] == "committed":
743
+ send_fix_notification(sentinel, {
744
+ "source": event.source,
745
+ "severity": "ERROR",
746
+ "fingerprint": event.fingerprint,
747
+ "first_seen": event.timestamp,
748
+ "message": event.message,
749
+ "stack_trace": event.body,
750
+ "repo_name": repo.repo_name,
751
+ "commit_hash": primary.get("commit_hash", ""),
752
+ "branch": primary.get("branch", ""),
753
+ "pr_url": primary.get("pr_url", ""),
754
+ "auto_commit": auto_commit,
755
+ "files_changed": [],
756
+ })
757
+ notify_fix_applied(
758
+ sentinel, event.source, event.message,
759
+ repo_name=repo.repo_name,
760
+ branch=primary.get("branch", ""),
761
+ pr_url=primary.get("pr_url", ""),
762
+ submitter_user_id=submitter_uid,
763
+ origin_channel=_origin_channel,
764
+ )
765
+
766
+ mark_done(event.issue_file)
767
+
768
+ if auto_commit and auto_release and primary and primary["status"] == "committed":
769
+ ok = cicd_trigger(repo, store, event.fingerprint)
770
+ if ok:
771
+ _progress(f":rocket: Release triggered via {repo.cicd_type}")
772
+ if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
773
+ _run_cascade(repo, sentinel, cfg_loader)
774
+
775
+ return {"submitter": submitter_uid, "repo_name": repo.repo_name,
776
+ "status": "done" if not failed else "partial",
777
+ "summary": event.message[:120],
778
+ "pr_url": primary.get("pr_url", "") if primary else ""}
779
+
780
+ # Legacy single-repo patch (no `repos/<name>/` prefix) — existing flow below.
781
+ logger.info(
782
+ "_handle_issue: patch %s has no repos/<name>/ prefix — falling back to single-repo apply",
783
+ event.fingerprint[:8],
784
+ )
655
785
  _progress(f":mag: Patch generated — running tests (`{repo.repo_name}`)...")
656
786
  commit_status, commit_hash = await _loop.run_in_executor(
657
787
  None, apply_and_commit, event, patch_path, repo, sentinel
@@ -0,0 +1,53 @@
1
+ """
2
+ test_fix_engine_cmd.py — Tests for _claude_cmd flag composition.
3
+
4
+ The OAuth path silently fast-failed in production until 1.6.1 because
5
+ _claude_cmd unconditionally passed --bare, which forces API-key-only auth
6
+ and rejects the OAuth attempt's keyless env. These tests pin the new
7
+ behaviour: --bare only when the API key is actually in the env.
8
+ """
9
+ from sentinel.fix_engine import _claude_cmd
10
+
11
+
12
+ def test_default_includes_bare_for_backcompat():
13
+ """Existing callers without an explicit flag still get --bare."""
14
+ cmd = _claude_cmd("claude", "hi")
15
+ assert "--bare" in cmd
16
+
17
+
18
+ def test_use_bare_false_drops_bare():
19
+ cmd = _claude_cmd("claude", "hi", use_bare=False)
20
+ assert "--bare" not in cmd
21
+
22
+
23
+ def test_use_bare_true_keeps_bare():
24
+ cmd = _claude_cmd("claude", "hi", use_bare=True)
25
+ assert "--bare" in cmd
26
+
27
+
28
+ def test_session_id_adds_resume():
29
+ cmd = _claude_cmd("claude", "hi", session_id="abc-123")
30
+ assert "--resume" in cmd
31
+ i = cmd.index("--resume")
32
+ assert cmd[i + 1] == "abc-123"
33
+
34
+
35
+ def test_no_session_id_skips_resume():
36
+ cmd = _claude_cmd("claude", "hi", session_id="")
37
+ assert "--resume" not in cmd
38
+
39
+
40
+ def test_output_format_json_always_present():
41
+ cmd_a = _claude_cmd("claude", "hi", use_bare=True)
42
+ cmd_b = _claude_cmd("claude", "hi", use_bare=False)
43
+ for cmd in (cmd_a, cmd_b):
44
+ assert "--output-format" in cmd
45
+ i = cmd.index("--output-format")
46
+ assert cmd[i + 1] == "json"
47
+
48
+
49
+ def test_print_and_prompt_are_last():
50
+ """`--print <prompt>` must come last so all flags are interpreted."""
51
+ cmd = _claude_cmd("claude", "the actual prompt", use_bare=False)
52
+ assert cmd[-2] == "--print"
53
+ assert cmd[-1] == "the actual prompt"