@misterhuydo/sentinel 1.6.9 → 1.6.10

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-24T14:02:31.712Z
1
+ 2026-04-25T09:41:31.525Z
@@ -1,10 +1,9 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-04-24T14:25:27.008Z",
3
- "checkpoint_at": "2026-04-24T14:25:27.009Z",
2
+ "message": "Auto-checkpoint at 2026-04-25T09:42:20.720Z",
3
+ "checkpoint_at": "2026-04-25T09:42:20.724Z",
4
4
  "active_files": [
5
5
  "J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
6
- "J:\\Projects\\Sentinel\\cli\\lib\\test.js",
7
- "J:\\Projects\\Sentinel\\cli\\python\\sentinel\\cicd_trigger.py"
6
+ "J:\\Projects\\Sentinel\\cli\\lib\\test.js"
8
7
  ],
9
8
  "notes": [
10
9
  {
@@ -186,7 +185,6 @@
186
185
  ],
187
186
  "mtime_snapshot": {
188
187
  "J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js": 1774252515044.4768,
189
- "J:\\Projects\\Sentinel\\cli\\lib\\test.js": 1774252437350.0059,
190
- "J:\\Projects\\Sentinel\\cli\\python\\sentinel\\cicd_trigger.py": 1777039448643.5408
188
+ "J:\\Projects\\Sentinel\\cli\\lib\\test.js": 1774252437350.0059
191
189
  }
192
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.6.9",
3
+ "version": "1.6.10",
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.9"
1
+ __version__ = "1.6.10"
@@ -582,6 +582,12 @@ def generate_fix(
582
582
  if timed_out:
583
583
  logger.error("Claude Code timed out for %s", event.fingerprint)
584
584
  return "error", None, ""
585
+ # Envelope check first: a successful JSON result means the attempt
586
+ # authenticated cleanly, even if the diff body contains substrings
587
+ # like "HttpStatus.UNAUTHORIZED" that would fool the substring scan.
588
+ parsed_attempt = _parse_claude_json(raw_output)
589
+ if not parsed_attempt["is_error"] and parsed_attempt["result"]:
590
+ break
585
591
  if not _is_auth_error(raw_output):
586
592
  break
587
593
  logger.warning("fix_engine: %s auth error for %s — trying next method", label, event.fingerprint)
@@ -1,95 +1,126 @@
1
- """
2
- test_fix_engine_json.py — Unit tests for _parse_claude_json().
3
-
4
- Parses the single-object JSON emitted by `claude --print --output-format json`.
5
- Critical: must extract session_id (for resume), cost (for budget tracking),
6
- and the result text (which contains the patch).
7
- """
8
- import json
9
- import pytest
10
-
11
- from sentinel.fix_engine import _parse_claude_json
12
-
13
-
14
- def _wrap(result_text: str, session_id: str = "abc-123",
15
- cost: float = 0.05, is_error: bool = False) -> str:
16
- return json.dumps({
17
- "type": "result",
18
- "subtype": "success" if not is_error else "error",
19
- "is_error": is_error,
20
- "result": result_text,
21
- "session_id": session_id,
22
- "total_cost_usd": cost,
23
- "duration_ms": 1234,
24
- "stop_reason": "end_turn",
25
- })
26
-
27
-
28
- # ── happy path ────────────────────────────────────────────────────────────────
29
-
30
- def test_extracts_result_session_cost():
31
- raw = _wrap("Here is the patch:\n```diff\n...```", "sess-1", 0.07)
32
- parsed = _parse_claude_json(raw)
33
- assert parsed["session_id"] == "sess-1"
34
- assert parsed["total_cost_usd"] == pytest.approx(0.07)
35
- assert "patch" in parsed["result"]
36
- assert parsed["is_error"] is False
37
-
38
-
39
- def test_handles_zero_cost():
40
- raw = _wrap("ok", "sess", 0.0)
41
- parsed = _parse_claude_json(raw)
42
- assert parsed["total_cost_usd"] == 0.0
43
-
44
-
45
- def test_carries_is_error_flag():
46
- raw = _wrap("err msg", "sess", 0.01, is_error=True)
47
- parsed = _parse_claude_json(raw)
48
- assert parsed["is_error"] is True
49
-
50
-
51
- # ── tolerant inputs ───────────────────────────────────────────────────────────
52
-
53
- def test_strips_leading_trailing_whitespace():
54
- raw = " \n" + _wrap("ok", "s", 0.0) + "\n "
55
- parsed = _parse_claude_json(raw)
56
- assert parsed["session_id"] == "s"
57
- assert parsed["result"] == "ok"
58
-
59
-
60
- def test_finds_json_after_stderr_garbage():
61
- """Claude sometimes prints debug lines before the JSON object."""
62
- raw = (
63
- "Some stderr line\n"
64
- "Another warning\n"
65
- + _wrap("payload", "s", 0.0)
66
- )
67
- parsed = _parse_claude_json(raw)
68
- assert parsed["session_id"] == "s"
69
- assert parsed["result"] == "payload"
70
-
71
-
72
- def test_returns_empty_on_unparseable():
73
- parsed = _parse_claude_json("not json at all")
74
- assert parsed["session_id"] == ""
75
- assert parsed["result"] == ""
76
- assert parsed["total_cost_usd"] == 0.0
77
- # When unparseable, surface as an error so caller doesn't silently swallow it
78
- assert parsed["is_error"] is True
79
-
80
-
81
- def test_returns_empty_on_empty_string():
82
- parsed = _parse_claude_json("")
83
- assert parsed["session_id"] == ""
84
- assert parsed["result"] == ""
85
- assert parsed["is_error"] is True
86
-
87
-
88
- def test_handles_missing_fields_gracefully():
89
- """If claude omits some fields, extract what's there and default the rest."""
90
- raw = json.dumps({"type": "result", "result": "x", "session_id": "s"})
91
- parsed = _parse_claude_json(raw)
92
- assert parsed["session_id"] == "s"
93
- assert parsed["result"] == "x"
94
- assert parsed["total_cost_usd"] == 0.0
95
- assert parsed["is_error"] is False
1
+ """
2
+ test_fix_engine_json.py — Unit tests for _parse_claude_json().
3
+
4
+ Parses the single-object JSON emitted by `claude --print --output-format json`.
5
+ Critical: must extract session_id (for resume), cost (for budget tracking),
6
+ and the result text (which contains the patch).
7
+ """
8
+ import json
9
+ import pytest
10
+
11
+ from sentinel.fix_engine import _parse_claude_json, _is_auth_error
12
+
13
+
14
+ def _wrap(result_text: str, session_id: str = "abc-123",
15
+ cost: float = 0.05, is_error: bool = False) -> str:
16
+ return json.dumps({
17
+ "type": "result",
18
+ "subtype": "success" if not is_error else "error",
19
+ "is_error": is_error,
20
+ "result": result_text,
21
+ "session_id": session_id,
22
+ "total_cost_usd": cost,
23
+ "duration_ms": 1234,
24
+ "stop_reason": "end_turn",
25
+ })
26
+
27
+
28
+ # ── happy path ────────────────────────────────────────────────────────────────
29
+
30
+ def test_extracts_result_session_cost():
31
+ raw = _wrap("Here is the patch:\n```diff\n...```", "sess-1", 0.07)
32
+ parsed = _parse_claude_json(raw)
33
+ assert parsed["session_id"] == "sess-1"
34
+ assert parsed["total_cost_usd"] == pytest.approx(0.07)
35
+ assert "patch" in parsed["result"]
36
+ assert parsed["is_error"] is False
37
+
38
+
39
+ def test_handles_zero_cost():
40
+ raw = _wrap("ok", "sess", 0.0)
41
+ parsed = _parse_claude_json(raw)
42
+ assert parsed["total_cost_usd"] == 0.0
43
+
44
+
45
+ def test_carries_is_error_flag():
46
+ raw = _wrap("err msg", "sess", 0.01, is_error=True)
47
+ parsed = _parse_claude_json(raw)
48
+ assert parsed["is_error"] is True
49
+
50
+
51
+ # ── tolerant inputs ───────────────────────────────────────────────────────────
52
+
53
+ def test_strips_leading_trailing_whitespace():
54
+ raw = " \n" + _wrap("ok", "s", 0.0) + "\n "
55
+ parsed = _parse_claude_json(raw)
56
+ assert parsed["session_id"] == "s"
57
+ assert parsed["result"] == "ok"
58
+
59
+
60
+ def test_finds_json_after_stderr_garbage():
61
+ """Claude sometimes prints debug lines before the JSON object."""
62
+ raw = (
63
+ "Some stderr line\n"
64
+ "Another warning\n"
65
+ + _wrap("payload", "s", 0.0)
66
+ )
67
+ parsed = _parse_claude_json(raw)
68
+ assert parsed["session_id"] == "s"
69
+ assert parsed["result"] == "payload"
70
+
71
+
72
+ def test_returns_empty_on_unparseable():
73
+ parsed = _parse_claude_json("not json at all")
74
+ assert parsed["session_id"] == ""
75
+ assert parsed["result"] == ""
76
+ assert parsed["total_cost_usd"] == 0.0
77
+ # When unparseable, surface as an error so caller doesn't silently swallow it
78
+ assert parsed["is_error"] is True
79
+
80
+
81
+ def test_returns_empty_on_empty_string():
82
+ parsed = _parse_claude_json("")
83
+ assert parsed["session_id"] == ""
84
+ assert parsed["result"] == ""
85
+ assert parsed["is_error"] is True
86
+
87
+
88
+ def test_handles_missing_fields_gracefully():
89
+ """If claude omits some fields, extract what's there and default the rest."""
90
+ raw = json.dumps({"type": "result", "result": "x", "session_id": "s"})
91
+ parsed = _parse_claude_json(raw)
92
+ assert parsed["session_id"] == "s"
93
+ assert parsed["result"] == "x"
94
+ assert parsed["total_cost_usd"] == 0.0
95
+ assert parsed["is_error"] is False
96
+
97
+
98
+ def test_successful_envelope_overrides_unauthorized_substring():
99
+ # Regression: fp 6cb7a875 on 2026-04-27 - Claude generated a valid patch
100
+ # whose unchanged-context lines contained "HttpStatus.UNAUTHORIZED",
101
+ # tripping _is_auth_error and triggering a false "Both API key and OAuth
102
+ # failed" alert. The fix is to consult the JSON envelope first.
103
+ diff_with_unauthorized = (
104
+ "diff --git a/Foo.java b/Foo.java\n"
105
+ "@@ -10,3 +10,3 @@\n"
106
+ " return new ResponseEntity<>(err, HttpStatus.UNAUTHORIZED);\n"
107
+ )
108
+ raw = _wrap(diff_with_unauthorized, is_error=False)
109
+ assert _is_auth_error(raw) is True # substring match alone is fooled
110
+ parsed = _parse_claude_json(raw)
111
+ assert parsed["is_error"] is False # envelope is the source of truth
112
+ assert parsed["result"]
113
+
114
+
115
+ @pytest.mark.parametrize("hint", [
116
+ "Error: Not logged in. Please run claude login.",
117
+ "API key is not set",
118
+ "401 Unauthorized",
119
+ "authentication failed",
120
+ ])
121
+ def test_is_auth_error_matches_real_hints(hint):
122
+ assert _is_auth_error(hint) is True
123
+
124
+
125
+ def test_is_auth_error_skips_benign_text():
126
+ assert _is_auth_error("All good, here is the patch") is False