@misterhuydo/sentinel 1.0.77 → 1.0.83
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 +1 -1
- package/.cairn/session.json +2 -2
- package/lib/generate.js +15 -1
- package/lib/init.js +381 -319
- package/lib/upgrade.js +40 -0
- package/package.json +21 -21
- package/python/scripts/patch_notify.js +200 -0
- package/python/sentinel/config_loader.py +6 -0
- package/python/sentinel/fix_engine.py +177 -160
- package/python/sentinel/main.py +35 -0
- package/python/sentinel/notify.py +88 -0
- package/python/sentinel/sentinel_boss.py +1605 -1371
- package/python/sentinel/slack_bot.py +427 -384
- package/python/sentinel/state_store.py +423 -341
- package/templates/sentinel.properties +3 -1
- package/templates/workspace-sentinel.properties +33 -0
- package/.cairn/views/2a85cc_init.js +0 -273
package/lib/upgrade.js
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
|
|
2
|
+
function ensureClaudePermissions() {
|
|
3
|
+
const settingsPath = require('path').join(require('os').homedir(), '.claude', 'settings.json');
|
|
4
|
+
const required = ['Read(**)', 'Write(**)', 'Edit(**)', 'Bash(**)'];
|
|
5
|
+
let settings = {};
|
|
6
|
+
try {
|
|
7
|
+
if (fs.existsSync(settingsPath)) {
|
|
8
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
9
|
+
}
|
|
10
|
+
} catch (e) {
|
|
11
|
+
warn('Could not read ' + settingsPath + ': ' + e.message);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!settings.permissions) settings.permissions = {};
|
|
15
|
+
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
16
|
+
const existing = new Set(settings.permissions.allow);
|
|
17
|
+
const added = [];
|
|
18
|
+
for (const perm of required) {
|
|
19
|
+
if (!existing.has(perm)) {
|
|
20
|
+
settings.permissions.allow.push(perm);
|
|
21
|
+
added.push(perm);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (added.length === 0) {
|
|
25
|
+
ok('Claude Code permissions already configured');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const path = require('path');
|
|
30
|
+
fs.ensureDirSync(path.dirname(settingsPath));
|
|
31
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '
|
|
32
|
+
');
|
|
33
|
+
ok('Claude Code permissions patched: ' + added.join(', '));
|
|
34
|
+
} catch (e) {
|
|
35
|
+
warn('Could not write ' + settingsPath + ': ' + e.message);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
1
39
|
'use strict';
|
|
2
40
|
|
|
3
41
|
const fs = require('fs-extra');
|
|
@@ -67,6 +105,8 @@ module.exports = async function upgrade() {
|
|
|
67
105
|
if (shFiles.length) spawnSync('chmod', ['+x', ...shFiles], { stdio: 'inherit' });
|
|
68
106
|
}
|
|
69
107
|
ok('Python source updated');
|
|
108
|
+
info('Patching Claude Code permissions…');
|
|
109
|
+
ensureClaudePermissions();
|
|
70
110
|
|
|
71
111
|
// Print new version
|
|
72
112
|
const { version: latest } = require(path.join(pkgDir, 'package.json'));
|
package/package.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@misterhuydo/sentinel",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Sentinel — Autonomous DevOps Agent installer and manager",
|
|
5
|
-
"bin": {
|
|
6
|
-
"sentinel": "./bin/sentinel.js"
|
|
7
|
-
},
|
|
8
|
-
"scripts": {
|
|
9
|
-
"prepublishOnly": "node scripts/bundle.js"
|
|
10
|
-
},
|
|
11
|
-
"dependencies": {
|
|
12
|
-
"chalk": "^4.1.2",
|
|
13
|
-
"fs-extra": "^11.2.0",
|
|
14
|
-
"prompts": "^2.4.2"
|
|
15
|
-
},
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=16"
|
|
18
|
-
},
|
|
19
|
-
"author": "misterhuydo",
|
|
20
|
-
"license": "MIT"
|
|
21
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@misterhuydo/sentinel",
|
|
3
|
+
"version": "1.0.83",
|
|
4
|
+
"description": "Sentinel — Autonomous DevOps Agent installer and manager",
|
|
5
|
+
"bin": {
|
|
6
|
+
"sentinel": "./bin/sentinel.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"prepublishOnly": "node scripts/bundle.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"chalk": "^4.1.2",
|
|
13
|
+
"fs-extra": "^11.2.0",
|
|
14
|
+
"prompts": "^2.4.2"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=16"
|
|
18
|
+
},
|
|
19
|
+
"author": "misterhuydo",
|
|
20
|
+
"license": "MIT"
|
|
21
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
// ── fix_engine.py ─────────────────────────────────────────────────────────────
|
|
5
|
+
{
|
|
6
|
+
const f = 'sentinel/fix_engine.py';
|
|
7
|
+
let c = fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
|
|
8
|
+
|
|
9
|
+
// 1. Add notify import
|
|
10
|
+
const OLD_IMPORT = 'from .config_loader import RepoConfig, SentinelConfig\nfrom .log_parser import ErrorEvent';
|
|
11
|
+
const NEW_IMPORT = 'from .config_loader import RepoConfig, SentinelConfig\nfrom .log_parser import ErrorEvent\nfrom .notify import alert_if_rate_limited, slack_alert';
|
|
12
|
+
if (!c.includes(OLD_IMPORT)) { console.error('fix_engine import not found'); process.exit(1); }
|
|
13
|
+
c = c.replace(OLD_IMPORT, NEW_IMPORT);
|
|
14
|
+
|
|
15
|
+
// 2. Only inject ANTHROPIC_API_KEY when claude_pro_for_tasks is False
|
|
16
|
+
// (so when both are configured, heavy tasks use Claude Pro OAuth)
|
|
17
|
+
const OLD_ENV = ` import os as _os
|
|
18
|
+
env = _os.environ.copy()
|
|
19
|
+
if cfg.anthropic_api_key:
|
|
20
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key`;
|
|
21
|
+
const NEW_ENV = ` import os as _os
|
|
22
|
+
env = _os.environ.copy()
|
|
23
|
+
# Inject API key only when Claude Pro is NOT preferred for tasks
|
|
24
|
+
# (when claude_pro_for_tasks=True and API key is set, let claude CLI use OAuth/Pro)
|
|
25
|
+
if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
|
|
26
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key`;
|
|
27
|
+
if (!c.includes(OLD_ENV)) { console.error('fix_engine env block not found'); process.exit(1); }
|
|
28
|
+
c = c.replace(OLD_ENV, NEW_ENV);
|
|
29
|
+
|
|
30
|
+
// 3. After getting output, check for rate limits and alert Slack
|
|
31
|
+
const OLD_OUTPUT = ` output = (result.stdout or "") + (result.stderr or "")
|
|
32
|
+
|
|
33
|
+
if output.strip().upper().startswith("SKIP:"):`;
|
|
34
|
+
const NEW_OUTPUT = ` output = (result.stdout or "") + (result.stderr or "")
|
|
35
|
+
|
|
36
|
+
# Alert Slack immediately on rate-limit / auth failure — never stay silent
|
|
37
|
+
alert_if_rate_limited(
|
|
38
|
+
cfg.slack_bot_token,
|
|
39
|
+
cfg.slack_channel,
|
|
40
|
+
source=f"fix_engine/{event.fingerprint}",
|
|
41
|
+
output=output,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if output.strip().upper().startswith("SKIP:"):`;
|
|
45
|
+
if (!c.includes(OLD_OUTPUT)) { console.error('fix_engine output block not found'); process.exit(1); }
|
|
46
|
+
c = c.replace(OLD_OUTPUT, NEW_OUTPUT);
|
|
47
|
+
|
|
48
|
+
// 4. Also alert on FileNotFoundError (claude CLI missing)
|
|
49
|
+
const OLD_NOTFOUND = ` except FileNotFoundError:
|
|
50
|
+
logger.error("Claude Code binary not found at '%s'", cfg.claude_code_bin)
|
|
51
|
+
return "error", None, ""`;
|
|
52
|
+
const NEW_NOTFOUND = ' except FileNotFoundError:\n'
|
|
53
|
+
+ ' msg = (\n'
|
|
54
|
+
+ ' f":warning: *Sentinel \u2014 Claude CLI not found*\\n"\n'
|
|
55
|
+
+ ' f"`{cfg.claude_code_bin}` not found. Run: `npm install -g @anthropic-ai/claude-code`\\n"\n'
|
|
56
|
+
+ ' f"Fix engine is disabled until this is resolved."\n'
|
|
57
|
+
+ ' )\n'
|
|
58
|
+
+ ' logger.error("Claude Code binary not found at \'%s\'", cfg.claude_code_bin)\n'
|
|
59
|
+
+ ' slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)\n'
|
|
60
|
+
+ ' return "error", None, ""';
|
|
61
|
+
if (!c.includes(OLD_NOTFOUND)) { console.error('fix_engine notfound block not found'); process.exit(1); }
|
|
62
|
+
c = c.replace(OLD_NOTFOUND, NEW_NOTFOUND);
|
|
63
|
+
|
|
64
|
+
fs.writeFileSync(f, c, 'utf8');
|
|
65
|
+
console.log('fix_engine.py done. Lines:', c.split('\n').length);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── sentinel_boss.py ──────────────────────────────────────────────────────────
|
|
69
|
+
{
|
|
70
|
+
const f = 'sentinel/sentinel_boss.py';
|
|
71
|
+
let c = fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
|
|
72
|
+
|
|
73
|
+
// 1. Add notify import
|
|
74
|
+
const OLD_IMPORT = 'logger = logging.getLogger(__name__)';
|
|
75
|
+
const NEW_IMPORT = 'from .notify import alert_if_rate_limited, slack_alert, is_rate_limited\n\nlogger = logging.getLogger(__name__)';
|
|
76
|
+
if (!c.includes(OLD_IMPORT)) { console.error('boss import anchor not found'); process.exit(1); }
|
|
77
|
+
c = c.replace(OLD_IMPORT, NEW_IMPORT);
|
|
78
|
+
|
|
79
|
+
// 2. Revert auth priority: API key first, CLI fallback
|
|
80
|
+
// Replace current handle_message body
|
|
81
|
+
const OLD_HM_BODY =
|
|
82
|
+
` # 1st priority: Claude Pro / OAuth via CLI
|
|
83
|
+
cli_reply, cli_done = await _handle_with_cli(
|
|
84
|
+
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
85
|
+
user_id=user_id, attachments=attachments,
|
|
86
|
+
)
|
|
87
|
+
if not cli_reply.startswith(":warning:"):
|
|
88
|
+
return cli_reply, cli_done
|
|
89
|
+
|
|
90
|
+
# CLI failed — try ANTHROPIC_API_KEY fallback
|
|
91
|
+
try:
|
|
92
|
+
import anthropic # noqa: F401
|
|
93
|
+
except ImportError:
|
|
94
|
+
return (
|
|
95
|
+
":warning: \`anthropic\` package not installed. Run: \`pip install anthropic\`",
|
|
96
|
+
True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
100
|
+
if not api_key:
|
|
101
|
+
return cli_reply, cli_done # No fallback available
|
|
102
|
+
|
|
103
|
+
logger.info("Boss: CLI path failed (%s…), falling back to ANTHROPIC_API_KEY", cli_reply[:60])
|
|
104
|
+
return await _handle_with_api(
|
|
105
|
+
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
106
|
+
user_id=user_id, attachments=attachments,
|
|
107
|
+
)`;
|
|
108
|
+
|
|
109
|
+
const NEW_HM_BODY =
|
|
110
|
+
` api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
111
|
+
|
|
112
|
+
# 1st priority: ANTHROPIC_API_KEY — full structured tools, cheap per-token for Boss queries
|
|
113
|
+
if api_key:
|
|
114
|
+
try:
|
|
115
|
+
import anthropic # noqa: F401
|
|
116
|
+
return await _handle_with_api(
|
|
117
|
+
message, history, cfg_loader, store, slack_client=slack_client,
|
|
118
|
+
user_name=user_name, user_id=user_id, attachments=attachments,
|
|
119
|
+
)
|
|
120
|
+
except Exception as api_err:
|
|
121
|
+
err_str = str(api_err)
|
|
122
|
+
# Detect rate-limit / auth failure and alert Slack before falling through
|
|
123
|
+
cfg = cfg_loader.sentinel
|
|
124
|
+
if is_rate_limited(err_str):
|
|
125
|
+
from .notify import rate_limit_message
|
|
126
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
127
|
+
"sentinel_boss/api", err_str)
|
|
128
|
+
logger.warning("Boss: API key path failed (%s), trying CLI fallback", err_str[:80])
|
|
129
|
+
|
|
130
|
+
# 2nd priority: Claude Pro / OAuth via CLI (limited tools but no API key needed)
|
|
131
|
+
cli_reply, cli_done = await _handle_with_cli(
|
|
132
|
+
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
133
|
+
user_id=user_id, attachments=attachments,
|
|
134
|
+
)
|
|
135
|
+
if not cli_reply.startswith(":warning:"):
|
|
136
|
+
return cli_reply, cli_done
|
|
137
|
+
|
|
138
|
+
# Both paths failed — alert Slack and return error
|
|
139
|
+
cfg = cfg_loader.sentinel
|
|
140
|
+
err_output = cli_reply
|
|
141
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
142
|
+
"sentinel_boss/cli", err_output)
|
|
143
|
+
if not api_key:
|
|
144
|
+
# No auth at all configured
|
|
145
|
+
no_auth_msg = (
|
|
146
|
+
":warning: *Sentinel Boss — no Claude auth configured*\\n"
|
|
147
|
+
"Configure at least one of:\\n"
|
|
148
|
+
"\u2022 \`ANTHROPIC_API_KEY\` in \`sentinel.properties\` \u2014 full features\\n"
|
|
149
|
+
"\u2022 Claude Pro OAuth: run \`claude login\` on the server \u2014 required for fix_engine\\n"
|
|
150
|
+
"See: https://github.com/misterhuydo/Sentinel#authentication"
|
|
151
|
+
)
|
|
152
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel, no_auth_msg)
|
|
153
|
+
return ":warning: No Claude authentication configured. See Slack for details.", True
|
|
154
|
+
return cli_reply, cli_done`;
|
|
155
|
+
|
|
156
|
+
if (!c.includes(OLD_HM_BODY)) { console.error('handle_message body not found'); process.exit(1); }
|
|
157
|
+
c = c.replace(OLD_HM_BODY, NEW_HM_BODY);
|
|
158
|
+
|
|
159
|
+
// 3. In _handle_with_cli, alert Slack on rate-limit detected in CLI output
|
|
160
|
+
const OLD_CLI_FAIL =
|
|
161
|
+
` if result.returncode != 0 and not output:
|
|
162
|
+
return f":warning: \`claude --print\` failed (exit {result.returncode}): {(result.stderr or '').strip()[:300]}", True`;
|
|
163
|
+
const NEW_CLI_FAIL =
|
|
164
|
+
` raw_err = (result.stderr or "").strip()
|
|
165
|
+
if result.returncode != 0 and not output:
|
|
166
|
+
full_err = f"exit {result.returncode}: {raw_err[:300]}"
|
|
167
|
+
cfg = cfg_loader.sentinel
|
|
168
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
169
|
+
"sentinel_boss/cli", raw_err or full_err)
|
|
170
|
+
return f":warning: \`claude --print\` failed ({full_err})", True`;
|
|
171
|
+
if (!c.includes(OLD_CLI_FAIL)) { console.error('cli fail block not found'); process.exit(1); }
|
|
172
|
+
c = c.replace(OLD_CLI_FAIL, NEW_CLI_FAIL);
|
|
173
|
+
|
|
174
|
+
// 4. Also alert in ask_codebase when claude --print fails
|
|
175
|
+
const OLD_ASK_FAIL = ` if r.returncode != 0 and not output:
|
|
176
|
+
return {"repo": repo_name, "error": f"claude --print failed (rc={r.returncode}): {(r.stderr or '')[:200]}"}`;
|
|
177
|
+
const NEW_ASK_FAIL = ` if r.returncode != 0 and not output:
|
|
178
|
+
raw_err = (r.stderr or "")
|
|
179
|
+
alert_if_rate_limited(
|
|
180
|
+
cfg.slack_bot_token, cfg.slack_channel,
|
|
181
|
+
f"ask_codebase/{repo_name}", raw_err,
|
|
182
|
+
)
|
|
183
|
+
return {"repo": repo_name, "error": f"claude --print failed (rc={r.returncode}): {raw_err[:200]}"}`;
|
|
184
|
+
if (!c.includes(OLD_ASK_FAIL)) { console.error('ask_codebase fail block not found'); process.exit(1); }
|
|
185
|
+
c = c.replace(OLD_ASK_FAIL, NEW_ASK_FAIL);
|
|
186
|
+
|
|
187
|
+
// 5. ask_codebase: respect claude_pro_for_tasks (don't inject API key when Pro preferred)
|
|
188
|
+
const OLD_ASK_ENV = ` env = os.environ.copy()
|
|
189
|
+
if cfg.anthropic_api_key:
|
|
190
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key`;
|
|
191
|
+
const NEW_ASK_ENV = ` env = os.environ.copy()
|
|
192
|
+
# Only inject API key when Claude Pro is NOT preferred for heavy tasks
|
|
193
|
+
if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
|
|
194
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key`;
|
|
195
|
+
if (!c.includes(OLD_ASK_ENV)) { console.error('ask_codebase env not found'); process.exit(1); }
|
|
196
|
+
c = c.replace(OLD_ASK_ENV, NEW_ASK_ENV);
|
|
197
|
+
|
|
198
|
+
fs.writeFileSync(f, c, 'utf8');
|
|
199
|
+
console.log('sentinel_boss.py done. Lines:', c.split('\n').length);
|
|
200
|
+
}
|
|
@@ -63,6 +63,11 @@ class SentinelConfig:
|
|
|
63
63
|
slack_watch_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
|
|
64
64
|
slack_allowed_users: list[str] = field(default_factory=list) # if set, only these Slack user IDs can talk to Boss
|
|
65
65
|
project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
|
|
66
|
+
# Auth strategy:
|
|
67
|
+
# ANTHROPIC_API_KEY — used by Sentinel Boss conversation (structured tools, cheap per-token)
|
|
68
|
+
# Claude Pro / OAuth — used by fix_engine + ask_codebase when CLAUDE_PRO_FOR_TASKS=true
|
|
69
|
+
# At least one must be configured. Both = ideal split (Boss=API key, heavy tasks=Pro).
|
|
70
|
+
claude_pro_for_tasks: bool = True # when True + API key set, fix_engine/ask_codebase use claude CLI (Pro billing)
|
|
66
71
|
|
|
67
72
|
|
|
68
73
|
@dataclass
|
|
@@ -158,6 +163,7 @@ class ConfigLoader:
|
|
|
158
163
|
c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
|
|
159
164
|
c.slack_allowed_users = _csv(d.get("SLACK_ALLOWED_USERS", ""))
|
|
160
165
|
c.project_name = d.get("PROJECT_NAME", "")
|
|
166
|
+
c.claude_pro_for_tasks = d.get("CLAUDE_PRO_FOR_TASKS", "true").lower() != "false"
|
|
161
167
|
self.sentinel = c
|
|
162
168
|
|
|
163
169
|
def _load_log_sources(self):
|