earl-bot 0.1.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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +260 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +177 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/Rakefile +11 -0
- data/bin/README.md +21 -0
- data/bin/ci +49 -0
- data/bin/claude-context +155 -0
- data/bin/claude-usage +110 -0
- data/bin/coverage +221 -0
- data/bin/rubocop +10 -0
- data/bin/watch-ci +198 -0
- data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
- data/config/earl-claude-home/.claude/settings.json +34 -0
- data/earl-bot.gemspec +42 -0
- data/exe/earl +51 -0
- data/exe/earl-install +129 -0
- data/exe/earl-permission-server +39 -0
- data/lib/earl/claude_session/stats.rb +76 -0
- data/lib/earl/claude_session.rb +468 -0
- data/lib/earl/command_executor/constants.rb +53 -0
- data/lib/earl/command_executor/heartbeat_display.rb +54 -0
- data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
- data/lib/earl/command_executor/session_handler.rb +126 -0
- data/lib/earl/command_executor/spawn_handler.rb +99 -0
- data/lib/earl/command_executor/stats_formatter.rb +66 -0
- data/lib/earl/command_executor/usage_handler.rb +132 -0
- data/lib/earl/command_executor.rb +128 -0
- data/lib/earl/command_parser.rb +57 -0
- data/lib/earl/config.rb +94 -0
- data/lib/earl/cron_parser.rb +105 -0
- data/lib/earl/formatting.rb +14 -0
- data/lib/earl/heartbeat_config.rb +101 -0
- data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
- data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
- data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
- data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
- data/lib/earl/heartbeat_scheduler.rb +131 -0
- data/lib/earl/logging.rb +12 -0
- data/lib/earl/mattermost/api_client.rb +85 -0
- data/lib/earl/mattermost.rb +261 -0
- data/lib/earl/mcp/approval_handler.rb +304 -0
- data/lib/earl/mcp/config.rb +62 -0
- data/lib/earl/mcp/github_pat_handler.rb +450 -0
- data/lib/earl/mcp/handler_base.rb +13 -0
- data/lib/earl/mcp/heartbeat_handler.rb +310 -0
- data/lib/earl/mcp/memory_handler.rb +89 -0
- data/lib/earl/mcp/server.rb +123 -0
- data/lib/earl/mcp/tmux_handler.rb +562 -0
- data/lib/earl/memory/prompt_builder.rb +40 -0
- data/lib/earl/memory/store.rb +125 -0
- data/lib/earl/message_queue.rb +56 -0
- data/lib/earl/permission_config.rb +22 -0
- data/lib/earl/question_handler/question_posting.rb +58 -0
- data/lib/earl/question_handler.rb +116 -0
- data/lib/earl/runner/idle_management.rb +44 -0
- data/lib/earl/runner/lifecycle.rb +73 -0
- data/lib/earl/runner/message_handling.rb +121 -0
- data/lib/earl/runner/reaction_handling.rb +42 -0
- data/lib/earl/runner/response_lifecycle.rb +96 -0
- data/lib/earl/runner/service_builder.rb +48 -0
- data/lib/earl/runner/startup.rb +73 -0
- data/lib/earl/runner/thread_context_builder.rb +43 -0
- data/lib/earl/runner.rb +70 -0
- data/lib/earl/safari_automation.rb +497 -0
- data/lib/earl/session_manager/persistence.rb +46 -0
- data/lib/earl/session_manager/session_creation.rb +108 -0
- data/lib/earl/session_manager.rb +92 -0
- data/lib/earl/session_store.rb +84 -0
- data/lib/earl/streaming_response.rb +219 -0
- data/lib/earl/tmux/parsing.rb +80 -0
- data/lib/earl/tmux/processes.rb +34 -0
- data/lib/earl/tmux/sessions.rb +41 -0
- data/lib/earl/tmux.rb +122 -0
- data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
- data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
- data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
- data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
- data/lib/earl/tmux_monitor.rb +249 -0
- data/lib/earl/tmux_session_store.rb +133 -0
- data/lib/earl/tool_input_formatter.rb +44 -0
- data/lib/earl/version.rb +5 -0
- data/lib/earl.rb +87 -0
- data/lib/tasks/.keep +1 -0
- metadata +248 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
module Mcp
|
|
5
|
+
# Handles permission approval flow: posts a permission request to Mattermost,
|
|
6
|
+
# adds reaction options, and waits for a user reaction to approve or deny.
|
|
7
|
+
# Tracks per-tool approvals persisted to a global file (allowed_tools.json)
|
|
8
|
+
# so "always allow" applies across all threads.
|
|
9
|
+
class ApprovalHandler
|
|
10
|
+
include Logging
|
|
11
|
+
include HandlerBase
|
|
12
|
+
|
|
13
|
+
TOOL_NAME = "permission_prompt"
|
|
14
|
+
TOOL_NAMES = [TOOL_NAME].freeze
|
|
15
|
+
|
|
16
|
+
INPUT_FORMATTERS = {
|
|
17
|
+
"Bash" => ->(input) { input["command"].to_s[0..500] },
|
|
18
|
+
"Edit" => lambda { |input|
|
|
19
|
+
"#{input["file_path"]}\n#{input.fetch("new_string", input.fetch("content", ""))[0..300]}"
|
|
20
|
+
},
|
|
21
|
+
"Write" => lambda { |input|
|
|
22
|
+
"#{input["file_path"]}\n#{input.fetch("new_string", input.fetch("content", ""))[0..300]}"
|
|
23
|
+
}
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
TOOL_SCHEMA = {
|
|
27
|
+
name: TOOL_NAME,
|
|
28
|
+
description: "Request permission to execute a tool",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
tool_name: { type: "string", description: "Name of the tool requesting permission" },
|
|
33
|
+
input: { type: "object", description: "The tool's input parameters" }
|
|
34
|
+
},
|
|
35
|
+
required: %w[tool_name input]
|
|
36
|
+
}
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
def self.allowed_tools_path
|
|
40
|
+
@allowed_tools_path ||= File.join(Earl.config_root, "allowed_tools.json")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Bundles tool_name and input that travel together through the approval flow.
|
|
44
|
+
ToolRequest = Data.define(:tool_name, :input)
|
|
45
|
+
|
|
46
|
+
# Reaction emoji sets for the permission approval flow.
|
|
47
|
+
module Reactions
|
|
48
|
+
APPROVE = %w[+1 white_check_mark].freeze
|
|
49
|
+
DENY = %w[-1].freeze
|
|
50
|
+
ALL = %w[+1 white_check_mark -1].freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(config:, api_client:)
|
|
54
|
+
@config = config
|
|
55
|
+
@api = api_client
|
|
56
|
+
@allowed_tools = load_allowed_tools
|
|
57
|
+
@mutex = Mutex.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def tool_definitions
|
|
61
|
+
[TOOL_SCHEMA]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(_name, arguments)
|
|
65
|
+
request = build_request(arguments)
|
|
66
|
+
format_handler_result(handle(request))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def handle(request)
|
|
70
|
+
return allow_result(request.input) if auto_approved?(request.tool_name)
|
|
71
|
+
|
|
72
|
+
post_and_wait(request)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def build_request(arguments)
|
|
78
|
+
ToolRequest.new(
|
|
79
|
+
tool_name: arguments["tool_name"] || "unknown",
|
|
80
|
+
input: arguments["input"] || {}
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def auto_approved?(tool_name)
|
|
85
|
+
return false unless @mutex.synchronize { @allowed_tools.include?(tool_name) }
|
|
86
|
+
|
|
87
|
+
log(:info, "Auto-allowing #{tool_name} (previously approved)")
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def post_and_wait(request)
|
|
92
|
+
post_id = post_permission_request(request)
|
|
93
|
+
return deny_result("Failed to post permission request") unless post_id
|
|
94
|
+
|
|
95
|
+
add_reaction_options(post_id)
|
|
96
|
+
decision = wait_for_reaction(post_id, request)
|
|
97
|
+
delete_permission_post(post_id)
|
|
98
|
+
decision
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def allow_result(input)
|
|
102
|
+
{ behavior: "allow", updatedInput: input }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def deny_result(reason = "Denied")
|
|
106
|
+
{ behavior: "deny", message: reason }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def format_handler_result(result)
|
|
110
|
+
{ content: [{ type: "text", text: JSON.generate(result) }] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Mattermost posting for permission requests.
|
|
114
|
+
module PermissionPosting
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def post_permission_request(request)
|
|
118
|
+
message = permission_message(request)
|
|
119
|
+
response = @api.post("/posts", {
|
|
120
|
+
channel_id: @config.platform_channel_id,
|
|
121
|
+
message: message,
|
|
122
|
+
root_id: @config.platform_thread_id
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return unless response.is_a?(Net::HTTPSuccess)
|
|
126
|
+
|
|
127
|
+
JSON.parse(response.body)["id"]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def permission_message(request)
|
|
131
|
+
tool_name = request.tool_name
|
|
132
|
+
input_summary = format_input(tool_name, request.input)
|
|
133
|
+
format_permission_text(tool_name, input_summary)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def format_permission_text(tool_name, input_summary)
|
|
137
|
+
":lock: **Permission Request**\nClaude wants to run: `#{tool_name}`\n```\n#{input_summary}\n```\n" \
|
|
138
|
+
"React: :+1: allow once | :white_check_mark: always allow `#{tool_name}` | :-1: deny"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def format_input(tool_name, input)
|
|
142
|
+
formatter = INPUT_FORMATTERS[tool_name]
|
|
143
|
+
formatter ? formatter.call(input) : JSON.generate(input)[0..500]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def add_reaction_options(post_id)
|
|
147
|
+
Reactions::ALL.each do |emoji|
|
|
148
|
+
@api.post("/reactions", {
|
|
149
|
+
user_id: @config.platform_bot_id,
|
|
150
|
+
post_id: post_id,
|
|
151
|
+
emoji_name: emoji
|
|
152
|
+
})
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def delete_permission_post(post_id)
|
|
157
|
+
@api.delete("/posts/#{post_id}")
|
|
158
|
+
rescue StandardError => error
|
|
159
|
+
log(:warn, "Failed to delete permission post #{post_id}: #{error.message}")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# WebSocket-based reaction polling for permission decisions.
|
|
164
|
+
module ReactionPolling
|
|
165
|
+
# Bundles poll_for_reaction parameters into a single context object.
|
|
166
|
+
PollContext = Data.define(:ws, :post_id, :request, :deadline)
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def wait_for_reaction(post_id, request)
|
|
171
|
+
deadline = Time.now + (@config.permission_timeout_ms / 1000.0)
|
|
172
|
+
websocket = connect_websocket
|
|
173
|
+
|
|
174
|
+
return deny_result("WebSocket connection failed") unless websocket
|
|
175
|
+
|
|
176
|
+
context = PollContext.new(ws: websocket, post_id: post_id, request: request, deadline: deadline)
|
|
177
|
+
poll_for_reaction(context) || deny_result("Timed out waiting for approval")
|
|
178
|
+
ensure
|
|
179
|
+
close_websocket(websocket)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def close_websocket(websocket)
|
|
183
|
+
websocket&.close
|
|
184
|
+
rescue StandardError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def connect_websocket
|
|
189
|
+
ws = WebSocket::Client::Simple.connect(@config.websocket_url)
|
|
190
|
+
token = @config.platform_token
|
|
191
|
+
ws_ref = ws
|
|
192
|
+
ws.on(:open) do
|
|
193
|
+
ws_ref.send(JSON.generate({ seq: 1, action: "authentication_challenge", data: { token: token } }))
|
|
194
|
+
end
|
|
195
|
+
ws
|
|
196
|
+
rescue StandardError => error
|
|
197
|
+
log(:error, "MCP WebSocket connect failed: #{error.message}")
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def poll_for_reaction(context)
|
|
202
|
+
reaction_queue = Queue.new
|
|
203
|
+
register_reaction_listener(context, reaction_queue)
|
|
204
|
+
poll_reaction_loop(context, reaction_queue)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def register_reaction_listener(context, reaction_queue)
|
|
208
|
+
websocket, target_post_id = context.deconstruct
|
|
209
|
+
websocket&.on(:message) do |msg|
|
|
210
|
+
reaction_data = extract_reaction_data(msg.data)
|
|
211
|
+
reaction_queue.push(reaction_data) if reaction_data && reaction_data["post_id"] == target_post_id
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def extract_reaction_data(data)
|
|
216
|
+
return unless data && !data.empty?
|
|
217
|
+
|
|
218
|
+
parsed = JSON.parse(data)
|
|
219
|
+
event_name, event_data = parsed.values_at("event", "data")
|
|
220
|
+
return unless event_name == "reaction_added"
|
|
221
|
+
|
|
222
|
+
JSON.parse(event_data&.dig("reaction") || "{}")
|
|
223
|
+
rescue JSON::ParserError
|
|
224
|
+
log(:debug, "MCP approval: skipped unparsable WebSocket message")
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def poll_reaction_loop(context, reaction_queue)
|
|
229
|
+
loop do
|
|
230
|
+
return nil if (context.deadline - Time.now) <= 0
|
|
231
|
+
|
|
232
|
+
reaction = dequeue_reaction(reaction_queue)
|
|
233
|
+
next unless valid_user_reaction?(reaction)
|
|
234
|
+
|
|
235
|
+
return process_reaction(reaction["emoji_name"], context.request)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def valid_user_reaction?(reaction)
|
|
240
|
+
return false unless reaction
|
|
241
|
+
|
|
242
|
+
user_id = reaction["user_id"]
|
|
243
|
+
user_id != @config.platform_bot_id && allowed_reactor?(user_id)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def dequeue_reaction(reaction_queue)
|
|
247
|
+
reaction_queue.pop(true)
|
|
248
|
+
rescue ThreadError
|
|
249
|
+
sleep 0.5
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def allowed_reactor?(user_id)
|
|
254
|
+
allowed = @config.allowed_users
|
|
255
|
+
return true if allowed.empty?
|
|
256
|
+
|
|
257
|
+
response = @api.get("/users/#{user_id}")
|
|
258
|
+
return false unless response.is_a?(Net::HTTPSuccess)
|
|
259
|
+
|
|
260
|
+
user = JSON.parse(response.body)
|
|
261
|
+
allowed.include?(user["username"])
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def process_reaction(emoji_name, request)
|
|
265
|
+
if Reactions::APPROVE.include?(emoji_name)
|
|
266
|
+
persist_always_allow(request.tool_name) if emoji_name == "white_check_mark"
|
|
267
|
+
allow_result(request.input)
|
|
268
|
+
elsif Reactions::DENY.include?(emoji_name)
|
|
269
|
+
deny_result("Denied by user")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def persist_always_allow(tool_name)
|
|
274
|
+
@mutex.synchronize { @allowed_tools.add(tool_name) }
|
|
275
|
+
save_allowed_tools
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Persistence for globally allowed tool list.
|
|
280
|
+
module AllowedToolsPersistence
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def load_allowed_tools
|
|
284
|
+
path = self.class.allowed_tools_path
|
|
285
|
+
return Set.new unless File.exist?(path)
|
|
286
|
+
|
|
287
|
+
Set.new(JSON.parse(File.read(path)))
|
|
288
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
289
|
+
Set.new
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def save_allowed_tools
|
|
293
|
+
File.write(self.class.allowed_tools_path, JSON.generate(@allowed_tools.to_a))
|
|
294
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOSPC, IOError => error
|
|
295
|
+
log(:warn, "Failed to save allowed tools: #{error.message}")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
include PermissionPosting
|
|
300
|
+
include ReactionPolling
|
|
301
|
+
include AllowedToolsPersistence
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
module Mcp
|
|
5
|
+
# Reads MCP permission server configuration from environment variables,
|
|
6
|
+
# set by the parent EARL process when spawning Claude with --permission-prompt-tool.
|
|
7
|
+
class Config
|
|
8
|
+
attr_reader :allowed_users, :permission_timeout_ms
|
|
9
|
+
|
|
10
|
+
# Groups platform connection fields into a single struct.
|
|
11
|
+
PlatformConnection = Data.define(:url, :token, :channel_id, :thread_id, :bot_id)
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@platform = PlatformConnection.new(
|
|
15
|
+
url: required_env("PLATFORM_URL"),
|
|
16
|
+
token: required_env("PLATFORM_TOKEN"),
|
|
17
|
+
channel_id: required_env("PLATFORM_CHANNEL_ID"),
|
|
18
|
+
thread_id: required_env("PLATFORM_THREAD_ID"),
|
|
19
|
+
bot_id: required_env("PLATFORM_BOT_ID")
|
|
20
|
+
)
|
|
21
|
+
@allowed_users = ENV.fetch("ALLOWED_USERS", "").split(",").map(&:strip)
|
|
22
|
+
@permission_timeout_ms = ENV.fetch("PERMISSION_TIMEOUT_MS", "86400000").to_i
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def platform_url = @platform.url
|
|
26
|
+
def platform_token = @platform.token
|
|
27
|
+
def platform_channel_id = @platform.channel_id
|
|
28
|
+
def platform_thread_id = @platform.thread_id
|
|
29
|
+
def platform_bot_id = @platform.bot_id
|
|
30
|
+
|
|
31
|
+
def api_url(path)
|
|
32
|
+
"#{platform_url}/api/v4#{path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def websocket_url
|
|
36
|
+
"#{platform_url.sub(%r{^https://}, "wss://").sub(%r{^http://}, "ws://")}/api/v4/websocket"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build a Config-like object compatible with ApiClient
|
|
40
|
+
def to_api_config
|
|
41
|
+
ApiConfig.new(
|
|
42
|
+
mattermost_url: platform_url,
|
|
43
|
+
bot_token: platform_token,
|
|
44
|
+
bot_id: platform_bot_id
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Minimal config struct compatible with ApiClient, built from platform env vars.
|
|
49
|
+
ApiConfig = Struct.new(:mattermost_url, :bot_token, :bot_id, keyword_init: true) do
|
|
50
|
+
def api_url(path)
|
|
51
|
+
"#{mattermost_url}/api/v4#{path}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def required_env(key)
|
|
58
|
+
ENV.fetch(key) { raise "Missing required env var: #{key}" }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|