brute_rack 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/exe/brute-client +290 -0
- data/exe/brute-server +16 -0
- data/exe/brute-server-demo +35 -0
- data/lib/brute_rack/app.rb +139 -0
- data/lib/brute_rack/endpoints/config.rb +37 -0
- data/lib/brute_rack/endpoints/files.rb +104 -0
- data/lib/brute_rack/endpoints/flow.rb +46 -0
- data/lib/brute_rack/endpoints/global.rb +41 -0
- data/lib/brute_rack/endpoints/logging.rb +25 -0
- data/lib/brute_rack/endpoints/messages.rb +107 -0
- data/lib/brute_rack/endpoints/path_vcs.rb +32 -0
- data/lib/brute_rack/endpoints/provider.rb +16 -0
- data/lib/brute_rack/endpoints/sessions.rb +120 -0
- data/lib/brute_rack/endpoints/tools.rb +44 -0
- data/lib/brute_rack/event_bus.rb +33 -0
- data/lib/brute_rack/session_registry.rb +107 -0
- data/lib/brute_rack/sse.rb +32 -0
- data/lib/brute_rack.rb +36 -0
- metadata +101 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0b7e3f20b9ac6c79ca75305edcd49c5e73f407a083a26e00dc02065413aa0d96
|
|
4
|
+
data.tar.gz: f3a6c8efd975fe580c3608d5568930d250d8f983cbf541a20eefc8dfa895c5ec
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f286b4d40dce441de9d2055509e72606a601bbd8a34355bfeb6c2038ae67271a942cdaed4f5747e75d03f660a2795a0f9bfa1d5fc765f42255ed6ef038be93a8
|
|
7
|
+
data.tar.gz: d47fcedfff3ce7845ae555586ba4b35bda21bc5833698fff0af9f34dba176d8fecaeee851c7c4eea519f27122d0fe0352dbf929fd8eff33152aaa8c6d925fdcb
|
data/exe/brute-client
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# CLI client for the Brute HTTP server.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# brute-client health
|
|
8
|
+
# brute-client prompt "Fix the failing tests"
|
|
9
|
+
# brute-client prompt --session abc123 "Continue working"
|
|
10
|
+
# brute-client stream "Refactor the auth module"
|
|
11
|
+
# brute-client sessions
|
|
12
|
+
# brute-client sessions create --title "Bug fix"
|
|
13
|
+
# brute-client sessions delete <id>
|
|
14
|
+
# brute-client files list .
|
|
15
|
+
# brute-client files read app.rb
|
|
16
|
+
# brute-client files search "def initialize"
|
|
17
|
+
# brute-client files status
|
|
18
|
+
# brute-client tools
|
|
19
|
+
# brute-client config
|
|
20
|
+
# brute-client vcs
|
|
21
|
+
# brute-client shell "ls -la"
|
|
22
|
+
|
|
23
|
+
require "net/http"
|
|
24
|
+
require "json"
|
|
25
|
+
require "uri"
|
|
26
|
+
require "optparse"
|
|
27
|
+
|
|
28
|
+
class BruteClient
|
|
29
|
+
def initialize(base_url)
|
|
30
|
+
@base = URI.parse(base_url)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(path, query: nil)
|
|
34
|
+
uri = URI.join(@base, path)
|
|
35
|
+
uri.query = URI.encode_www_form(query) if query
|
|
36
|
+
Net::HTTP.get_response(uri).then { |r| handle(r) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def post(path, body: {})
|
|
40
|
+
uri = URI.join(@base, path)
|
|
41
|
+
Net::HTTP.post(uri, JSON.generate(body), "Content-Type" => "application/json").then { |r| handle(r) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(path)
|
|
45
|
+
uri = URI.join(@base, path)
|
|
46
|
+
req = Net::HTTP::Delete.new(uri)
|
|
47
|
+
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }.then { |r| handle(r) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def stream(path, body: {})
|
|
51
|
+
uri = URI.join(@base, path)
|
|
52
|
+
req = Net::HTTP::Post.new(uri)
|
|
53
|
+
req["Content-Type"] = "application/json"
|
|
54
|
+
req["Accept"] = "text/event-stream"
|
|
55
|
+
req.body = JSON.generate(body)
|
|
56
|
+
|
|
57
|
+
Net::HTTP.start(uri.host, uri.port) do |http|
|
|
58
|
+
http.request(req) do |response|
|
|
59
|
+
response.read_body do |chunk|
|
|
60
|
+
chunk.lines.each do |line|
|
|
61
|
+
case line
|
|
62
|
+
when /\Aevent: (.+)/
|
|
63
|
+
@current_event = $1.strip
|
|
64
|
+
when /\Adata: (.+)/
|
|
65
|
+
handle_sse_event(@current_event, $1.strip)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def handle(response)
|
|
76
|
+
case response.code.to_i
|
|
77
|
+
when 200..299
|
|
78
|
+
begin
|
|
79
|
+
JSON.parse(response.body).then { |data| pp data }
|
|
80
|
+
rescue JSON::ParserError
|
|
81
|
+
puts response.body
|
|
82
|
+
end
|
|
83
|
+
when 204
|
|
84
|
+
puts "(no content)"
|
|
85
|
+
else
|
|
86
|
+
$stderr.puts "Error #{response.code}: #{response.body}"
|
|
87
|
+
exit 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def handle_sse_event(type, data)
|
|
92
|
+
parsed = JSON.parse(data) rescue { "raw" => data }
|
|
93
|
+
case type
|
|
94
|
+
when "content"
|
|
95
|
+
print parsed["text"]
|
|
96
|
+
when "tool_call"
|
|
97
|
+
$stderr.puts "\n--- [#{parsed["name"]}] ---"
|
|
98
|
+
when "tool_result"
|
|
99
|
+
$stderr.puts " [#{parsed["success"] ? "ok" : "FAILED"}]"
|
|
100
|
+
when "done"
|
|
101
|
+
puts
|
|
102
|
+
$stderr.puts "Tools: #{parsed["tools_called"]&.join(", ")}" if parsed["tools_called"]&.any?
|
|
103
|
+
when "error"
|
|
104
|
+
$stderr.puts "Error: #{parsed["message"]}"
|
|
105
|
+
when "server.connected"
|
|
106
|
+
$stderr.puts "Connected to brute-server #{parsed["version"]}"
|
|
107
|
+
else
|
|
108
|
+
$stderr.puts "[#{type}] #{data}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- CLI ---
|
|
114
|
+
|
|
115
|
+
base_url = ENV["BRUTE_SERVER"] || "http://127.0.0.1:9292"
|
|
116
|
+
client = BruteClient.new(base_url)
|
|
117
|
+
|
|
118
|
+
command = ARGV.shift
|
|
119
|
+
|
|
120
|
+
case command
|
|
121
|
+
when "health"
|
|
122
|
+
client.get("/global/health")
|
|
123
|
+
|
|
124
|
+
when "prompt"
|
|
125
|
+
session_id = nil
|
|
126
|
+
parser = OptionParser.new do |opts|
|
|
127
|
+
opts.on("-s", "--session ID") { |id| session_id = id }
|
|
128
|
+
end
|
|
129
|
+
parser.order!(ARGV)
|
|
130
|
+
message = ARGV.join(" ")
|
|
131
|
+
abort "Usage: brute-client prompt \"your message\"" if message.empty?
|
|
132
|
+
|
|
133
|
+
session_id ||= SecureRandom.uuid
|
|
134
|
+
client.post("/session/#{session_id}/message", body: {
|
|
135
|
+
parts: [{ type: "text", text: message }],
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
when "stream"
|
|
139
|
+
message = ARGV.join(" ")
|
|
140
|
+
abort "Usage: brute-client stream \"your message\"" if message.empty?
|
|
141
|
+
|
|
142
|
+
session_id = SecureRandom.uuid
|
|
143
|
+
client.post("/session", body: { title: message[0..50] })
|
|
144
|
+
# Use prompt_async + event stream
|
|
145
|
+
# For now, use blocking message which returns the full response
|
|
146
|
+
client.post("/session/#{session_id}/message", body: {
|
|
147
|
+
parts: [{ type: "text", text: message }],
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
when "sessions"
|
|
151
|
+
sub = ARGV.shift
|
|
152
|
+
case sub
|
|
153
|
+
when "create"
|
|
154
|
+
title = nil
|
|
155
|
+
OptionParser.new { |o| o.on("-t", "--title T") { |t| title = t } }.order!(ARGV)
|
|
156
|
+
client.post("/session", body: { title: title })
|
|
157
|
+
when "delete"
|
|
158
|
+
id = ARGV.shift
|
|
159
|
+
abort "Usage: brute-client sessions delete <id>" unless id
|
|
160
|
+
client.delete("/session/#{id}")
|
|
161
|
+
when "status"
|
|
162
|
+
client.get("/session/status")
|
|
163
|
+
when nil
|
|
164
|
+
client.get("/session")
|
|
165
|
+
else
|
|
166
|
+
# Treat as session ID — get details
|
|
167
|
+
client.get("/session/#{sub}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
when "messages"
|
|
171
|
+
id = ARGV.shift
|
|
172
|
+
abort "Usage: brute-client messages <session-id>" unless id
|
|
173
|
+
msg_id = ARGV.shift
|
|
174
|
+
if msg_id
|
|
175
|
+
client.get("/session/#{id}/message/#{msg_id}")
|
|
176
|
+
else
|
|
177
|
+
client.get("/session/#{id}/message")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
when "files"
|
|
181
|
+
sub = ARGV.shift
|
|
182
|
+
case sub
|
|
183
|
+
when "list"
|
|
184
|
+
path = ARGV.shift || "."
|
|
185
|
+
client.get("/file", query: { path: path })
|
|
186
|
+
when "read"
|
|
187
|
+
path = ARGV.shift
|
|
188
|
+
abort "Usage: brute-client files read <path>" unless path
|
|
189
|
+
client.get("/file/content", query: { path: path })
|
|
190
|
+
when "search"
|
|
191
|
+
pattern = ARGV.join(" ")
|
|
192
|
+
abort "Usage: brute-client files search <pattern>" if pattern.empty?
|
|
193
|
+
client.get("/find", query: { pattern: pattern })
|
|
194
|
+
when "find"
|
|
195
|
+
query = ARGV.join(" ")
|
|
196
|
+
abort "Usage: brute-client files find <query>" if query.empty?
|
|
197
|
+
client.get("/find/file", query: { query: query })
|
|
198
|
+
when "status"
|
|
199
|
+
client.get("/file/status")
|
|
200
|
+
else
|
|
201
|
+
abort "Usage: brute-client files {list|read|search|find|status}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
when "tools"
|
|
205
|
+
sub = ARGV.shift
|
|
206
|
+
case sub
|
|
207
|
+
when "ids", nil
|
|
208
|
+
client.get("/experimental/tool/ids")
|
|
209
|
+
when "list"
|
|
210
|
+
client.get("/experimental/tool")
|
|
211
|
+
else
|
|
212
|
+
abort "Usage: brute-client tools {ids|list}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
when "config"
|
|
216
|
+
sub = ARGV.shift
|
|
217
|
+
case sub
|
|
218
|
+
when "providers"
|
|
219
|
+
client.get("/config/providers")
|
|
220
|
+
when nil
|
|
221
|
+
client.get("/config")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
when "provider"
|
|
225
|
+
client.get("/provider")
|
|
226
|
+
|
|
227
|
+
when "vcs"
|
|
228
|
+
client.get("/vcs")
|
|
229
|
+
|
|
230
|
+
when "path"
|
|
231
|
+
client.get("/path")
|
|
232
|
+
|
|
233
|
+
when "shell"
|
|
234
|
+
command_str = ARGV.join(" ")
|
|
235
|
+
abort "Usage: brute-client shell \"command\"" if command_str.empty?
|
|
236
|
+
session_id = SecureRandom.uuid
|
|
237
|
+
client.post("/session/#{session_id}/shell", body: { command: command_str })
|
|
238
|
+
|
|
239
|
+
when "todo"
|
|
240
|
+
id = ARGV.shift
|
|
241
|
+
abort "Usage: brute-client todo <session-id>" unless id
|
|
242
|
+
client.get("/session/#{id}/todo")
|
|
243
|
+
|
|
244
|
+
when "abort"
|
|
245
|
+
id = ARGV.shift
|
|
246
|
+
abort "Usage: brute-client abort <session-id>" unless id
|
|
247
|
+
client.post("/session/#{id}/abort")
|
|
248
|
+
|
|
249
|
+
when "log"
|
|
250
|
+
message = ARGV.join(" ")
|
|
251
|
+
abort "Usage: brute-client log \"message\"" if message.empty?
|
|
252
|
+
client.post("/log", body: { service: "cli", level: "info", message: message })
|
|
253
|
+
|
|
254
|
+
when nil, "help", "--help", "-h"
|
|
255
|
+
puts <<~HELP
|
|
256
|
+
Usage: brute-client <command> [args]
|
|
257
|
+
|
|
258
|
+
Commands:
|
|
259
|
+
health Server health check
|
|
260
|
+
prompt [-s session] "message" Send a prompt (blocking)
|
|
261
|
+
stream "message" Send a prompt (streaming)
|
|
262
|
+
sessions List sessions
|
|
263
|
+
sessions create [-t title] Create a session
|
|
264
|
+
sessions delete <id> Delete a session
|
|
265
|
+
sessions status Session statuses
|
|
266
|
+
messages <session-id> [msg-id] List/get messages
|
|
267
|
+
files list [path] List directory
|
|
268
|
+
files read <path> Read file content
|
|
269
|
+
files search <pattern> Search file contents
|
|
270
|
+
files find <query> Find files by name
|
|
271
|
+
files status Git file status
|
|
272
|
+
tools [ids|list] List available tools
|
|
273
|
+
config [providers] Show configuration
|
|
274
|
+
provider List providers
|
|
275
|
+
vcs Git info
|
|
276
|
+
path Current directory
|
|
277
|
+
shell "command" Execute shell command
|
|
278
|
+
todo <session-id> Session todo list
|
|
279
|
+
abort <session-id> Abort running session
|
|
280
|
+
log "message" Write server log entry
|
|
281
|
+
|
|
282
|
+
Environment:
|
|
283
|
+
BRUTE_SERVER Server URL (default: http://127.0.0.1:9292)
|
|
284
|
+
HELP
|
|
285
|
+
|
|
286
|
+
else
|
|
287
|
+
$stderr.puts "Unknown command: #{command}"
|
|
288
|
+
$stderr.puts "Run 'brute-client help' for usage."
|
|
289
|
+
exit 1
|
|
290
|
+
end
|
data/exe/brute-server
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Start the Brute HTTP server.
|
|
5
|
+
#
|
|
6
|
+
# brute-server # Run ./service.rb in the current directory
|
|
7
|
+
# brute-server /path/to/service.rb # Run a specific service config
|
|
8
|
+
#
|
|
9
|
+
# Requires async-service and falcon.
|
|
10
|
+
|
|
11
|
+
config = ARGV.shift || File.join(Dir.pwd, "service.rb")
|
|
12
|
+
|
|
13
|
+
abort "brute-server: #{config} not found" unless File.exist?(config)
|
|
14
|
+
|
|
15
|
+
$stderr.puts "brute-server: #{config}"
|
|
16
|
+
exec("async-service", config, *ARGV)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Run one of the bundled example services.
|
|
5
|
+
#
|
|
6
|
+
# brute-server-demo # Run the default example
|
|
7
|
+
# brute-server-demo default # Same as above
|
|
8
|
+
# brute-server-demo restricted # Read-only research agent
|
|
9
|
+
# brute-server-demo multi-agent # Two agents on different ports
|
|
10
|
+
|
|
11
|
+
EXAMPLES_DIR = File.expand_path("../examples", __dir__)
|
|
12
|
+
AVAILABLE = Dir.children(EXAMPLES_DIR)
|
|
13
|
+
.select { |d| File.exist?(File.join(EXAMPLES_DIR, d, "service.rb")) }
|
|
14
|
+
.sort
|
|
15
|
+
|
|
16
|
+
name = ARGV.shift || "default"
|
|
17
|
+
|
|
18
|
+
if %w[-h --help help].include?(name)
|
|
19
|
+
$stderr.puts "Usage: brute-server-demo [example]"
|
|
20
|
+
$stderr.puts
|
|
21
|
+
$stderr.puts "Available examples:"
|
|
22
|
+
AVAILABLE.each { |e| $stderr.puts " #{e}" }
|
|
23
|
+
exit
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
service_rb = File.join(EXAMPLES_DIR, name, "service.rb")
|
|
27
|
+
|
|
28
|
+
unless File.exist?(service_rb)
|
|
29
|
+
$stderr.puts "Unknown example: #{name}"
|
|
30
|
+
$stderr.puts "Available: #{AVAILABLE.join(", ")}"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
brute_server = File.expand_path("brute-server", __dir__)
|
|
35
|
+
exec(brute_server, service_rb, *ARGV)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module BruteRack
|
|
6
|
+
# Rack application implementing the OpenCode-compatible server API.
|
|
7
|
+
# Routes requests by method + path to endpoint modules.
|
|
8
|
+
#
|
|
9
|
+
# require "brute_rack"
|
|
10
|
+
# BruteRack::App.new
|
|
11
|
+
#
|
|
12
|
+
class App
|
|
13
|
+
def self.not_found
|
|
14
|
+
[404, {"content-type" => "application/json"}, ['{"error":"not found"}']]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.method_not_allowed
|
|
18
|
+
[405, {"content-type" => "application/json"}, ['{"error":"method not allowed"}']]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(cwd: Dir.pwd, agent_options: {})
|
|
22
|
+
@cwd = cwd
|
|
23
|
+
@logger = Logger.new($stderr, level: Logger::INFO)
|
|
24
|
+
@event_bus = EventBus.new
|
|
25
|
+
@registry = SessionRegistry.new(event_bus: @event_bus, cwd: cwd, agent_options: agent_options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(env)
|
|
29
|
+
method = env["REQUEST_METHOD"]
|
|
30
|
+
path = env["PATH_INFO"]
|
|
31
|
+
|
|
32
|
+
case method
|
|
33
|
+
when "GET" then route_get(path, env)
|
|
34
|
+
when "POST" then route_post(path, env)
|
|
35
|
+
when "PATCH" then route_patch(path, env)
|
|
36
|
+
when "DELETE" then route_delete(path, env)
|
|
37
|
+
else self.class.method_not_allowed
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def ctx
|
|
44
|
+
{ cwd: @cwd, event_bus: @event_bus, registry: @registry, logger: @logger }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# ----------------------------------------------------------------
|
|
48
|
+
# GET
|
|
49
|
+
# ----------------------------------------------------------------
|
|
50
|
+
def route_get(path, env)
|
|
51
|
+
case path
|
|
52
|
+
# Global
|
|
53
|
+
when "/global/health" then Endpoints::Global.health(env, **ctx)
|
|
54
|
+
when "/global/event" then Endpoints::Global.event(env, **ctx)
|
|
55
|
+
when "/event" then Endpoints::Global.event(env, **ctx)
|
|
56
|
+
# Sessions
|
|
57
|
+
when "/session" then Endpoints::Sessions.list(env, **ctx)
|
|
58
|
+
when "/session/status" then Endpoints::Sessions.status(env, **ctx)
|
|
59
|
+
when %r{\A/session/([^/]+)/message/(.+)\z}
|
|
60
|
+
Endpoints::Messages.get_message(env, id: $1, message_id: $2, **ctx)
|
|
61
|
+
when %r{\A/session/([^/]+)/message\z}
|
|
62
|
+
Endpoints::Messages.list(env, id: $1, **ctx)
|
|
63
|
+
when %r{\A/session/([^/]+)/todo\z}
|
|
64
|
+
Endpoints::Sessions.todo(env, id: $1, **ctx)
|
|
65
|
+
when %r{\A/session/([^/]+)\z}
|
|
66
|
+
Endpoints::Sessions.get(env, id: $1, **ctx)
|
|
67
|
+
# Files
|
|
68
|
+
when "/find" then Endpoints::Files.find(env, **ctx)
|
|
69
|
+
when "/find/file" then Endpoints::Files.find_file(env, **ctx)
|
|
70
|
+
when "/file/content" then Endpoints::Files.content(env, **ctx)
|
|
71
|
+
when "/file/status" then Endpoints::Files.status(env, **ctx)
|
|
72
|
+
when "/file" then Endpoints::Files.list(env, **ctx)
|
|
73
|
+
# Tools
|
|
74
|
+
when "/experimental/tool/ids" then Endpoints::Tools.ids(env, **ctx)
|
|
75
|
+
when "/experimental/tool" then Endpoints::Tools.list(env, **ctx)
|
|
76
|
+
# Config & Provider
|
|
77
|
+
when "/config/providers" then Endpoints::Config.providers(env, **ctx)
|
|
78
|
+
when "/config" then Endpoints::Config.get(env, **ctx)
|
|
79
|
+
when "/provider" then Endpoints::Provider.list(env, **ctx)
|
|
80
|
+
# Path & VCS
|
|
81
|
+
when "/path" then Endpoints::PathVcs.path(env, **ctx)
|
|
82
|
+
when "/vcs" then Endpoints::PathVcs.vcs(env, **ctx)
|
|
83
|
+
else self.class.not_found
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# ----------------------------------------------------------------
|
|
88
|
+
# POST
|
|
89
|
+
# ----------------------------------------------------------------
|
|
90
|
+
def route_post(path, env)
|
|
91
|
+
case path
|
|
92
|
+
# Sessions
|
|
93
|
+
when "/session"
|
|
94
|
+
Endpoints::Sessions.create(env, **ctx)
|
|
95
|
+
when %r{\A/session/([^/]+)/message\z}
|
|
96
|
+
Endpoints::Messages.send_message(env, id: $1, **ctx)
|
|
97
|
+
when %r{\A/session/([^/]+)/prompt_async\z}
|
|
98
|
+
Endpoints::Messages.prompt_async(env, id: $1, **ctx)
|
|
99
|
+
when %r{\A/session/([^/]+)/shell\z}
|
|
100
|
+
Endpoints::Messages.shell(env, id: $1, **ctx)
|
|
101
|
+
when %r{\A/session/([^/]+)/abort\z}
|
|
102
|
+
Endpoints::Sessions.abort(env, id: $1, **ctx)
|
|
103
|
+
when %r{\A/session/([^/]+)/fork\z}
|
|
104
|
+
Endpoints::Sessions.fork(env, id: $1, **ctx)
|
|
105
|
+
when %r{\A/session/([^/]+)/summarize\z}
|
|
106
|
+
Endpoints::Sessions.summarize(env, id: $1, **ctx)
|
|
107
|
+
# Flow (brute-specific)
|
|
108
|
+
when "/flow"
|
|
109
|
+
Endpoints::Flow.call(env, **ctx)
|
|
110
|
+
# Logging
|
|
111
|
+
when "/log"
|
|
112
|
+
Endpoints::Logging.create(env, **ctx)
|
|
113
|
+
else self.class.not_found
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ----------------------------------------------------------------
|
|
118
|
+
# PATCH
|
|
119
|
+
# ----------------------------------------------------------------
|
|
120
|
+
def route_patch(path, env)
|
|
121
|
+
case path
|
|
122
|
+
when %r{\A/session/([^/]+)\z}
|
|
123
|
+
Endpoints::Sessions.update(env, id: $1, **ctx)
|
|
124
|
+
else self.class.not_found
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ----------------------------------------------------------------
|
|
129
|
+
# DELETE
|
|
130
|
+
# ----------------------------------------------------------------
|
|
131
|
+
def route_delete(path, env)
|
|
132
|
+
case path
|
|
133
|
+
when %r{\A/session/([^/]+)\z}
|
|
134
|
+
Endpoints::Sessions.delete(env, id: $1, **ctx)
|
|
135
|
+
else self.class.not_found
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BruteRack
|
|
4
|
+
module Endpoints
|
|
5
|
+
# GET /config → agent configuration
|
|
6
|
+
# GET /config/providers → available providers and models
|
|
7
|
+
module Config
|
|
8
|
+
PROVIDER_MAP = {
|
|
9
|
+
"ANTHROPIC_API_KEY" => { id: "anthropic", name: "Anthropic", models: ["claude-sonnet-4-20250514", "claude-haiku-4-20250414"] },
|
|
10
|
+
"OPENAI_API_KEY" => { id: "openai", name: "OpenAI", models: ["gpt-4o", "gpt-4o-mini", "o3-mini"] },
|
|
11
|
+
"GOOGLE_API_KEY" => { id: "google", name: "Google", models: ["gemini-2.0-flash", "gemini-2.5-pro"] },
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.get(_env, cwd:, **)
|
|
15
|
+
{
|
|
16
|
+
version: Brute::VERSION,
|
|
17
|
+
cwd: cwd,
|
|
18
|
+
tools: LLM::Function.registry.map(&:name),
|
|
19
|
+
tool_count: LLM::Function.registry.size,
|
|
20
|
+
providers: available_providers.map { |p| p[:id] },
|
|
21
|
+
}.then { |config| [200, HEADERS_JSON, [JSON.generate(config)]] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.providers(_env, **)
|
|
25
|
+
providers = available_providers
|
|
26
|
+
defaults = providers.each_with_object({}) { |p, h| h[p[:id]] = p[:models].first }
|
|
27
|
+
[200, HEADERS_JSON, [JSON.generate(providers: providers, default: defaults)]]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.available_providers
|
|
31
|
+
PROVIDER_MAP.filter_map do |env_key, info|
|
|
32
|
+
info if ENV[env_key]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module BruteRack
|
|
6
|
+
module Endpoints
|
|
7
|
+
# GET /find?pattern=<pat> → search file contents (ripgrep)
|
|
8
|
+
# GET /find/file?query=<q> → find files by name
|
|
9
|
+
# GET /file?path=<path> → list directory
|
|
10
|
+
# GET /file/content?path=<p> → read file content
|
|
11
|
+
# GET /file/status → git status
|
|
12
|
+
module Files
|
|
13
|
+
def self.find(env, cwd:, **)
|
|
14
|
+
params = parse_query(env)
|
|
15
|
+
pattern = params["pattern"]
|
|
16
|
+
return [400, HEADERS_JSON, [JSON.generate(error: "pattern required")]] unless pattern
|
|
17
|
+
|
|
18
|
+
Brute::Tools::FSSearch.new.call(pattern: pattern, path: cwd).then do |result|
|
|
19
|
+
matches = result[:results].to_s.lines.filter_map do |line|
|
|
20
|
+
if line =~ /\A(.+?):(\d+):(.*)/
|
|
21
|
+
{ path: $1, line_number: $2.to_i, lines: $3.strip }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
[200, HEADERS_JSON, [JSON.generate(matches)]]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.find_file(env, cwd:, **)
|
|
29
|
+
params = parse_query(env)
|
|
30
|
+
query = params["query"]
|
|
31
|
+
return [400, HEADERS_JSON, [JSON.generate(error: "query required")]] unless query
|
|
32
|
+
|
|
33
|
+
limit = (params["limit"] || 50).to_i.clamp(1, 200)
|
|
34
|
+
type_filter = params["type"] # "file" or "directory"
|
|
35
|
+
|
|
36
|
+
cmd = ["find", cwd, "-maxdepth", "8", "-iname", "*#{query}*"]
|
|
37
|
+
cmd += ["-type", "f"] if type_filter == "file"
|
|
38
|
+
cmd += ["-type", "d"] if type_filter == "directory"
|
|
39
|
+
|
|
40
|
+
Open3.capture3(*cmd).then do |stdout, _, _|
|
|
41
|
+
paths = stdout.lines.map(&:strip).reject(&:empty?).first(limit)
|
|
42
|
+
[200, HEADERS_JSON, [JSON.generate(paths)]]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.list(env, cwd:, **)
|
|
47
|
+
params = parse_query(env)
|
|
48
|
+
path = File.expand_path(params["path"] || ".", cwd)
|
|
49
|
+
|
|
50
|
+
return [404, HEADERS_JSON, [JSON.generate(error: "not found")]] unless File.directory?(path)
|
|
51
|
+
|
|
52
|
+
Dir.entries(path).reject { |f| f.start_with?(".") }.sort.map do |name|
|
|
53
|
+
full = File.join(path, name)
|
|
54
|
+
{
|
|
55
|
+
name: name,
|
|
56
|
+
path: full,
|
|
57
|
+
type: File.directory?(full) ? "directory" : "file",
|
|
58
|
+
size: File.file?(full) ? File.size(full) : nil,
|
|
59
|
+
}
|
|
60
|
+
end.then do |nodes|
|
|
61
|
+
[200, HEADERS_JSON, [JSON.generate(nodes)]]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.content(env, cwd:, **)
|
|
66
|
+
params = parse_query(env)
|
|
67
|
+
path = params["path"]
|
|
68
|
+
return [400, HEADERS_JSON, [JSON.generate(error: "path required")]] unless path
|
|
69
|
+
|
|
70
|
+
full = File.expand_path(path, cwd)
|
|
71
|
+
return [404, HEADERS_JSON, [JSON.generate(error: "not found")]] unless File.exist?(full)
|
|
72
|
+
return [400, HEADERS_JSON, [JSON.generate(error: "not a file")]] unless File.file?(full)
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
path: full,
|
|
76
|
+
content: File.read(full, encoding: "UTF-8"),
|
|
77
|
+
size: File.size(full),
|
|
78
|
+
lines: File.readlines(full).size,
|
|
79
|
+
}.then do |result|
|
|
80
|
+
[200, HEADERS_JSON, [JSON.generate(result)]]
|
|
81
|
+
end
|
|
82
|
+
rescue Encoding::InvalidByteSequenceError
|
|
83
|
+
[400, HEADERS_JSON, [JSON.generate(error: "binary file")]]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.status(_env, cwd:, **)
|
|
87
|
+
Open3.capture3("git", "status", "--porcelain", chdir: cwd).then do |stdout, _, st|
|
|
88
|
+
if st.success?
|
|
89
|
+
files = stdout.lines.map { |l|
|
|
90
|
+
{ status: l[0..1].strip, path: l[3..].strip }
|
|
91
|
+
}
|
|
92
|
+
[200, HEADERS_JSON, [JSON.generate(files)]]
|
|
93
|
+
else
|
|
94
|
+
[200, HEADERS_JSON, [JSON.generate([])]]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.parse_query(env)
|
|
100
|
+
Rack::Utils.parse_query(env["QUERY_STRING"] || "")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BruteRack
|
|
4
|
+
module Endpoints
|
|
5
|
+
# POST /flow — run a BPMN multi-agent flow.
|
|
6
|
+
#
|
|
7
|
+
# Request: { "message": "...", "cwd": "..." }
|
|
8
|
+
# Response: { "result": {...} }
|
|
9
|
+
#
|
|
10
|
+
# Requires the brute_flow gem to be installed.
|
|
11
|
+
#
|
|
12
|
+
module Flow
|
|
13
|
+
def self.call(env, cwd:)
|
|
14
|
+
require "brute_flow"
|
|
15
|
+
|
|
16
|
+
JSON.parse(env["rack.input"].read).then do |body|
|
|
17
|
+
Brute.flow(cwd: body["cwd"] || cwd, variables: { user_message: body["message"] }) do
|
|
18
|
+
service :router, type: "Brute::Flow::Services::RouterService"
|
|
19
|
+
exclusive_gateway :mode, default: :simple_path do
|
|
20
|
+
branch :fibre_path, condition: '=agent_mode = "fibre"' do
|
|
21
|
+
parallel do
|
|
22
|
+
service :tools, type: "Brute::Flow::Services::ToolSuggestService"
|
|
23
|
+
service :memory, type: "Brute::Flow::Services::MemoryRecallService"
|
|
24
|
+
end
|
|
25
|
+
service :agent, type: "Brute::Flow::Services::AgentService"
|
|
26
|
+
end
|
|
27
|
+
branch :simple_path do
|
|
28
|
+
service :agent, type: "Brute::Flow::Services::AgentService"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end.then do |runner|
|
|
32
|
+
runner.run.then do |result|
|
|
33
|
+
[200, {"content-type" => "application/json"}, [JSON.generate(result: result)]]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
rescue LoadError
|
|
38
|
+
[501, {"content-type" => "application/json"},
|
|
39
|
+
[JSON.generate(error: "brute_flow gem not installed")]]
|
|
40
|
+
rescue => e
|
|
41
|
+
[500, {"content-type" => "application/json"},
|
|
42
|
+
[JSON.generate(error: e.message)]]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|