claude-agent-server 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/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +213 -0
- data/config.ru +10 -0
- data/docs/openapi.json +773 -0
- data/exe/claude-agent-server +71 -0
- data/lib/claude_agent_server/app.rb +337 -0
- data/lib/claude_agent_server/config.rb +29 -0
- data/lib/claude_agent_server/errors.rb +51 -0
- data/lib/claude_agent_server/middleware/authentication.rb +57 -0
- data/lib/claude_agent_server/middleware/cors.rb +47 -0
- data/lib/claude_agent_server/middleware/error_handler.rb +46 -0
- data/lib/claude_agent_server/middleware/request_id.rb +22 -0
- data/lib/claude_agent_server/services/message_serializer.rb +165 -0
- data/lib/claude_agent_server/services/options_builder.rb +54 -0
- data/lib/claude_agent_server/services/query_executor.rb +27 -0
- data/lib/claude_agent_server/services/session_manager.rb +199 -0
- data/lib/claude_agent_server/services/sse_stream.rb +71 -0
- data/lib/claude_agent_server/version.rb +5 -0
- data/lib/claude_agent_server.rb +36 -0
- metadata +107 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
|
|
6
|
+
options = {}
|
|
7
|
+
|
|
8
|
+
OptionParser.new do |opts|
|
|
9
|
+
opts.banner = 'Usage: claude-agent-server [options]'
|
|
10
|
+
|
|
11
|
+
opts.on('-p', '--port PORT', Integer, 'Port to listen on (default: 9292)') do |p|
|
|
12
|
+
options[:port] = p
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
opts.on('-b', '--bind HOST', 'Host to bind to (default: 0.0.0.0)') do |h|
|
|
16
|
+
options[:host] = h
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
opts.on('-t', '--token TOKEN', 'Authentication token') do |t|
|
|
20
|
+
options[:auth_token] = t
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
opts.on('--session-ttl SECONDS', Integer, 'Session TTL in seconds (default: 3600)') do |ttl|
|
|
24
|
+
options[:session_ttl] = ttl
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
opts.on('--max-sessions N', Integer, 'Maximum concurrent sessions (default: 100)') do |n|
|
|
28
|
+
options[:max_sessions] = n
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
opts.on('--cors-origins ORIGINS', 'Comma-separated CORS origins (default: *)') do |origins|
|
|
32
|
+
options[:cors_origins] = origins
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
opts.on('-v', '--version', 'Show version') do
|
|
36
|
+
require_relative '../lib/claude_agent_server/version'
|
|
37
|
+
puts "claude-agent-server #{ClaudeAgentServer::VERSION}"
|
|
38
|
+
exit
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
opts.on('-h', '--help', 'Show help') do
|
|
42
|
+
puts opts
|
|
43
|
+
exit
|
|
44
|
+
end
|
|
45
|
+
end.parse!
|
|
46
|
+
|
|
47
|
+
require_relative '../lib/claude_agent_server'
|
|
48
|
+
|
|
49
|
+
ClaudeAgentServer.configure do |config|
|
|
50
|
+
config.port = options[:port] if options[:port]
|
|
51
|
+
config.host = options[:host] if options[:host]
|
|
52
|
+
config.auth_token = options[:auth_token] if options[:auth_token]
|
|
53
|
+
config.session_ttl = options[:session_ttl] if options[:session_ttl]
|
|
54
|
+
config.max_sessions = options[:max_sessions] if options[:max_sessions]
|
|
55
|
+
config.cors_origins = options[:cors_origins] if options[:cors_origins]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
config = ClaudeAgentServer.config
|
|
59
|
+
|
|
60
|
+
puts "Starting claude-agent-server v#{ClaudeAgentServer::VERSION}"
|
|
61
|
+
puts "Listening on http://#{config.host}:#{config.port}"
|
|
62
|
+
puts "Auth: #{config.auth_enabled? ? 'enabled' : 'disabled'}"
|
|
63
|
+
puts "Max sessions: #{config.max_sessions}, TTL: #{config.session_ttl}s"
|
|
64
|
+
|
|
65
|
+
# Use array form to avoid shell injection
|
|
66
|
+
Kernel.exec(
|
|
67
|
+
'falcon', 'serve',
|
|
68
|
+
'--bind', "http://#{config.host}:#{config.port}",
|
|
69
|
+
'--count', '1',
|
|
70
|
+
'--config', File.expand_path('../../config.ru', __dir__)
|
|
71
|
+
)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'roda'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'claude_agent_sdk'
|
|
6
|
+
|
|
7
|
+
module ClaudeAgentServer
|
|
8
|
+
class App < Roda
|
|
9
|
+
plugin :request_headers
|
|
10
|
+
plugin :all_verbs
|
|
11
|
+
|
|
12
|
+
@session_manager = Services::SessionManager.new
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :session_manager
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
use Middleware::RequestId
|
|
19
|
+
use Middleware::Cors
|
|
20
|
+
use Middleware::Authentication
|
|
21
|
+
use Middleware::ErrorHandler
|
|
22
|
+
|
|
23
|
+
route do |r| # rubocop:disable Metrics/BlockLength
|
|
24
|
+
# GET /health (outside /v1 — always accessible)
|
|
25
|
+
r.on 'health' do
|
|
26
|
+
r.get do
|
|
27
|
+
json_response({ status: 'ok' })
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# All API routes under /v1
|
|
32
|
+
r.on 'v1' do # rubocop:disable Metrics/BlockLength
|
|
33
|
+
# GET /v1/health
|
|
34
|
+
r.on 'health' do
|
|
35
|
+
r.get do
|
|
36
|
+
json_response({ status: 'ok' })
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# GET /v1/openapi.json
|
|
41
|
+
r.on 'openapi.json' do
|
|
42
|
+
r.get do
|
|
43
|
+
spec_path = File.expand_path('../../docs/openapi.json', __dir__)
|
|
44
|
+
response['content-type'] = 'application/json'
|
|
45
|
+
File.read(spec_path)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# GET /v1/info
|
|
50
|
+
r.on 'info' do
|
|
51
|
+
r.get do
|
|
52
|
+
manager = self.class.session_manager
|
|
53
|
+
json_response({
|
|
54
|
+
version: ClaudeAgentServer::VERSION,
|
|
55
|
+
sdkVersion: ClaudeAgentSDK::VERSION,
|
|
56
|
+
activeSessions: manager.sessions.size
|
|
57
|
+
})
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# /v1/query routes
|
|
62
|
+
r.on 'query' do
|
|
63
|
+
# POST /v1/query/stream
|
|
64
|
+
r.on 'stream' do
|
|
65
|
+
r.post do
|
|
66
|
+
params = parse_json_body(request)
|
|
67
|
+
prompt = params['prompt'] || params[:prompt]
|
|
68
|
+
raise ArgumentError, 'Missing required field: prompt' unless prompt
|
|
69
|
+
|
|
70
|
+
sdk_options = params['options'] || params[:options] || {}
|
|
71
|
+
options = Services::OptionsBuilder.build(sdk_options)
|
|
72
|
+
|
|
73
|
+
response['content-type'] = 'text/event-stream'
|
|
74
|
+
response['cache-control'] = 'no-cache'
|
|
75
|
+
response['x-accel-buffering'] = 'no'
|
|
76
|
+
|
|
77
|
+
body = Services::SseStream.stream_query(prompt: prompt, options: options)
|
|
78
|
+
r.halt [200, response.headers, body]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# POST /v1/query
|
|
83
|
+
r.is do
|
|
84
|
+
r.post do
|
|
85
|
+
params = parse_json_body(request)
|
|
86
|
+
prompt = params['prompt'] || params[:prompt]
|
|
87
|
+
raise ArgumentError, 'Missing required field: prompt' unless prompt
|
|
88
|
+
|
|
89
|
+
sdk_options = params['options'] || params[:options] || {}
|
|
90
|
+
options = Services::OptionsBuilder.build(sdk_options)
|
|
91
|
+
|
|
92
|
+
messages = Services::QueryExecutor.execute(prompt: prompt, options: options)
|
|
93
|
+
json_response({ messages: messages })
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# /v1/cli-sessions routes (read-only CLI session browsing)
|
|
99
|
+
r.on 'cli-sessions' do # rubocop:disable Metrics/BlockLength
|
|
100
|
+
r.is do
|
|
101
|
+
r.get do
|
|
102
|
+
params = r.params
|
|
103
|
+
directory = params['directory']
|
|
104
|
+
limit = params['limit']&.to_i
|
|
105
|
+
include_worktrees = params['includeWorktrees'] != 'false'
|
|
106
|
+
|
|
107
|
+
sessions = ClaudeAgentSDK.list_sessions(
|
|
108
|
+
directory: directory, limit: limit, include_worktrees: include_worktrees
|
|
109
|
+
)
|
|
110
|
+
json_response({ sessions: sessions.map { |s| serialize_sdk_session(s) } })
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
r.on String do |session_id|
|
|
115
|
+
r.on 'messages' do
|
|
116
|
+
r.get do
|
|
117
|
+
params = r.params
|
|
118
|
+
messages = ClaudeAgentSDK.get_session_messages(
|
|
119
|
+
session_id: session_id,
|
|
120
|
+
directory: params['directory'],
|
|
121
|
+
limit: params['limit']&.to_i,
|
|
122
|
+
offset: (params['offset'] || '0').to_i
|
|
123
|
+
)
|
|
124
|
+
json_response({
|
|
125
|
+
sessionId: session_id,
|
|
126
|
+
messages: messages.map { |m| serialize_session_message(m) }
|
|
127
|
+
})
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# /v1/sessions routes
|
|
134
|
+
r.on 'sessions' do # rubocop:disable Metrics/BlockLength
|
|
135
|
+
manager = self.class.session_manager
|
|
136
|
+
|
|
137
|
+
r.is do
|
|
138
|
+
r.get do
|
|
139
|
+
entries = manager.list_sessions
|
|
140
|
+
json_response({ sessions: entries.map { |e| serialize_session_info(e) } })
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
r.post do
|
|
144
|
+
params = parse_json_body(request)
|
|
145
|
+
prompt = params['prompt'] || params[:prompt]
|
|
146
|
+
session_id = params['id'] || params[:id]
|
|
147
|
+
sdk_options = params['options'] || params[:options] || {}
|
|
148
|
+
options = Services::OptionsBuilder.build(sdk_options)
|
|
149
|
+
|
|
150
|
+
entry = manager.create_session(options: options, prompt: prompt, id: session_id)
|
|
151
|
+
response.status = 201
|
|
152
|
+
json_response(serialize_session_info(entry))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
r.on String do |session_id| # rubocop:disable Metrics/BlockLength
|
|
157
|
+
r.is do
|
|
158
|
+
r.get do
|
|
159
|
+
entry = manager.get_session(session_id)
|
|
160
|
+
json_response(serialize_session_info(entry))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
r.delete do
|
|
164
|
+
manager.destroy_session(session_id)
|
|
165
|
+
json_response({ status: 'disconnected', id: session_id })
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# /v1/sessions/:id/events — offset-based polling
|
|
170
|
+
r.on 'events' do
|
|
171
|
+
# GET /v1/sessions/:id/events/sse — SSE stream with resume
|
|
172
|
+
r.on 'sse' do
|
|
173
|
+
r.get do
|
|
174
|
+
entry = manager.get_session(session_id)
|
|
175
|
+
last_event_id = env['HTTP_LAST_EVENT_ID']
|
|
176
|
+
|
|
177
|
+
response['content-type'] = 'text/event-stream'
|
|
178
|
+
response['cache-control'] = 'no-cache'
|
|
179
|
+
response['x-accel-buffering'] = 'no'
|
|
180
|
+
|
|
181
|
+
body = Services::SseStream.stream_session(entry, last_event_id: last_event_id)
|
|
182
|
+
r.halt [200, response.headers, body]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# GET /v1/sessions/:id/events?offset=N&limit=M
|
|
187
|
+
r.is do
|
|
188
|
+
r.get do
|
|
189
|
+
entry = manager.get_session(session_id)
|
|
190
|
+
params = r.params
|
|
191
|
+
offset = (params['offset'] || '0').to_i
|
|
192
|
+
limit = params['limit']&.to_i
|
|
193
|
+
|
|
194
|
+
events = entry.get_events(offset: offset, limit: limit)
|
|
195
|
+
json_response({
|
|
196
|
+
sessionId: session_id,
|
|
197
|
+
events: events.map { |e| serialize_event(e) },
|
|
198
|
+
nextOffset: events.empty? ? offset : events.last.index + 1
|
|
199
|
+
})
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# POST /v1/sessions/:id/messages
|
|
205
|
+
r.on 'messages' do
|
|
206
|
+
# GET /v1/sessions/:id/messages/stream (legacy SSE)
|
|
207
|
+
r.on 'stream' do
|
|
208
|
+
r.get do
|
|
209
|
+
entry = manager.get_session(session_id)
|
|
210
|
+
last_event_id = env['HTTP_LAST_EVENT_ID']
|
|
211
|
+
|
|
212
|
+
response['content-type'] = 'text/event-stream'
|
|
213
|
+
response['cache-control'] = 'no-cache'
|
|
214
|
+
response['x-accel-buffering'] = 'no'
|
|
215
|
+
|
|
216
|
+
body = Services::SseStream.stream_session(entry, last_event_id: last_event_id)
|
|
217
|
+
r.halt [200, response.headers, body]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
r.is do
|
|
222
|
+
r.post do
|
|
223
|
+
entry = manager.get_session(session_id)
|
|
224
|
+
params = parse_json_body(request)
|
|
225
|
+
prompt = params['prompt'] || params[:prompt]
|
|
226
|
+
raise ArgumentError, 'Missing required field: prompt' unless prompt
|
|
227
|
+
|
|
228
|
+
entry.last_activity = Time.now
|
|
229
|
+
entry.client.query(prompt)
|
|
230
|
+
json_response({ status: 'sent', sessionId: session_id })
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
r.on 'interrupt' do
|
|
236
|
+
r.post do
|
|
237
|
+
entry = manager.get_session(session_id)
|
|
238
|
+
entry.client.interrupt
|
|
239
|
+
entry.last_activity = Time.now
|
|
240
|
+
json_response({ status: 'interrupted', sessionId: session_id })
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
r.on 'model' do
|
|
245
|
+
r.post do
|
|
246
|
+
entry = manager.get_session(session_id)
|
|
247
|
+
params = parse_json_body(request)
|
|
248
|
+
model = params['model'] || params[:model]
|
|
249
|
+
raise ArgumentError, 'Missing required field: model' unless model
|
|
250
|
+
|
|
251
|
+
entry.client.set_model(model)
|
|
252
|
+
entry.last_activity = Time.now
|
|
253
|
+
json_response({ status: 'model_changed', model: model, sessionId: session_id })
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
r.on 'mcp-status' do
|
|
258
|
+
r.get do
|
|
259
|
+
entry = manager.get_session(session_id)
|
|
260
|
+
mcp_status = entry.client.get_mcp_status
|
|
261
|
+
json_response({ sessionId: session_id, mcpStatus: mcp_status })
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
r.on 'history' do
|
|
266
|
+
r.get do
|
|
267
|
+
entry = manager.get_session(session_id)
|
|
268
|
+
events = entry.events
|
|
269
|
+
json_response({
|
|
270
|
+
sessionId: session_id,
|
|
271
|
+
messages: events.map { |e| serialize_event(e) }
|
|
272
|
+
})
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
private
|
|
281
|
+
|
|
282
|
+
def json_response(data)
|
|
283
|
+
response['content-type'] = 'application/json'
|
|
284
|
+
JSON.generate(data)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def parse_json_body(request)
|
|
288
|
+
body = request.body.read
|
|
289
|
+
return {} if body.nil? || body.empty?
|
|
290
|
+
|
|
291
|
+
JSON.parse(body)
|
|
292
|
+
rescue JSON::ParserError => e
|
|
293
|
+
raise ArgumentError, "Invalid JSON body: #{e.message}"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def serialize_session_info(entry)
|
|
297
|
+
{
|
|
298
|
+
id: entry.id,
|
|
299
|
+
status: entry.status.to_s,
|
|
300
|
+
createdAt: entry.created_at.iso8601,
|
|
301
|
+
lastActivity: entry.last_activity.iso8601,
|
|
302
|
+
messageCount: entry.message_count
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def serialize_event(event)
|
|
307
|
+
{
|
|
308
|
+
index: event.index,
|
|
309
|
+
timestamp: event.timestamp.iso8601,
|
|
310
|
+
message: Services::MessageSerializer.serialize(event.message)
|
|
311
|
+
}
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def serialize_sdk_session(session)
|
|
315
|
+
{
|
|
316
|
+
sessionId: session.session_id,
|
|
317
|
+
summary: session.summary,
|
|
318
|
+
lastModified: session.last_modified,
|
|
319
|
+
fileSize: session.file_size,
|
|
320
|
+
customTitle: session.custom_title,
|
|
321
|
+
firstPrompt: session.first_prompt,
|
|
322
|
+
gitBranch: session.git_branch,
|
|
323
|
+
cwd: session.cwd
|
|
324
|
+
}.compact
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def serialize_session_message(msg)
|
|
328
|
+
{
|
|
329
|
+
type: msg.type,
|
|
330
|
+
uuid: msg.uuid,
|
|
331
|
+
sessionId: msg.session_id,
|
|
332
|
+
message: msg.message,
|
|
333
|
+
parentToolUseId: msg.parent_tool_use_id
|
|
334
|
+
}.compact
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgentServer
|
|
4
|
+
class Config
|
|
5
|
+
attr_accessor :host, :port, :auth_token, :cors_origins, :session_ttl,
|
|
6
|
+
:max_sessions, :default_sdk_options, :log_level
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@host = ENV.fetch('CLAUDE_SERVER_HOST', '0.0.0.0')
|
|
10
|
+
@port = ENV.fetch('CLAUDE_SERVER_PORT', '9292').to_i
|
|
11
|
+
@auth_token = ENV.fetch('CLAUDE_SERVER_AUTH_TOKEN', nil)
|
|
12
|
+
@cors_origins = ENV.fetch('CLAUDE_SERVER_CORS_ORIGINS', '*')
|
|
13
|
+
@session_ttl = ENV.fetch('CLAUDE_SERVER_SESSION_TTL', '3600').to_i
|
|
14
|
+
@max_sessions = ENV.fetch('CLAUDE_SERVER_MAX_SESSIONS', '100').to_i
|
|
15
|
+
@default_sdk_options = {}
|
|
16
|
+
@log_level = ENV.fetch('CLAUDE_SERVER_LOG_LEVEL', 'info')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def auth_enabled?
|
|
20
|
+
!@auth_token.nil? && !@auth_token.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def allowed_origins
|
|
24
|
+
return ['*'] if @cors_origins == '*'
|
|
25
|
+
|
|
26
|
+
@cors_origins.split(',').map(&:strip)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgentServer
|
|
4
|
+
URN_PREFIX = 'urn:claude-agent-server:error'
|
|
5
|
+
|
|
6
|
+
class ServerError < StandardError
|
|
7
|
+
def error_type = 'server_error'
|
|
8
|
+
def status_code = 500
|
|
9
|
+
def title = 'Server Error'
|
|
10
|
+
def urn = "#{URN_PREFIX}:#{error_type}"
|
|
11
|
+
|
|
12
|
+
def to_problem_details
|
|
13
|
+
{
|
|
14
|
+
type: urn,
|
|
15
|
+
title: title,
|
|
16
|
+
status: status_code,
|
|
17
|
+
detail: message
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ConfigError < ServerError
|
|
23
|
+
def error_type = 'config_error'
|
|
24
|
+
def status_code = 400
|
|
25
|
+
def title = 'Configuration Error'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class SessionNotFoundError < ServerError
|
|
29
|
+
def error_type = 'session_not_found'
|
|
30
|
+
def status_code = 404
|
|
31
|
+
def title = 'Session Not Found'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class SessionAlreadyExistsError < ServerError
|
|
35
|
+
def error_type = 'session_already_exists'
|
|
36
|
+
def status_code = 409
|
|
37
|
+
def title = 'Session Already Exists'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class SessionLimitError < ServerError
|
|
41
|
+
def error_type = 'session_limit_reached'
|
|
42
|
+
def status_code = 429
|
|
43
|
+
def title = 'Session Limit Reached'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class AuthenticationError < ServerError
|
|
47
|
+
def error_type = 'unauthorized'
|
|
48
|
+
def status_code = 401
|
|
49
|
+
def title = 'Unauthorized'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module ClaudeAgentServer
|
|
7
|
+
module Middleware
|
|
8
|
+
class Authentication
|
|
9
|
+
SKIP_PATHS = %w[/health /v1/health].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
return @app.call(env) unless ClaudeAgentServer.config.auth_enabled?
|
|
17
|
+
return @app.call(env) if skip_auth?(env)
|
|
18
|
+
|
|
19
|
+
token = extract_token(env)
|
|
20
|
+
return unauthorized_response unless token && timing_safe_compare(token, ClaudeAgentServer.config.auth_token)
|
|
21
|
+
|
|
22
|
+
@app.call(env)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def skip_auth?(env)
|
|
28
|
+
SKIP_PATHS.include?(env['PATH_INFO'])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def extract_token(env)
|
|
32
|
+
auth_header = env['HTTP_AUTHORIZATION']
|
|
33
|
+
return nil unless auth_header
|
|
34
|
+
|
|
35
|
+
match = auth_header.match(/\ABearer\s+(.+)\z/i)
|
|
36
|
+
match&.captures&.first
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def timing_safe_compare(provided, expected)
|
|
40
|
+
OpenSSL.fixed_length_secure_compare(
|
|
41
|
+
OpenSSL::Digest::SHA256.digest(provided),
|
|
42
|
+
OpenSSL::Digest::SHA256.digest(expected)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unauthorized_response
|
|
47
|
+
body = JSON.generate({
|
|
48
|
+
type: "#{URN_PREFIX}:unauthorized",
|
|
49
|
+
title: 'Unauthorized',
|
|
50
|
+
status: 401,
|
|
51
|
+
detail: 'Invalid or missing authentication token'
|
|
52
|
+
})
|
|
53
|
+
[401, { 'content-type' => 'application/problem+json' }, [body]]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgentServer
|
|
4
|
+
module Middleware
|
|
5
|
+
class Cors
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
origin = env['HTTP_ORIGIN']
|
|
12
|
+
|
|
13
|
+
return preflight_response(origin) if env['REQUEST_METHOD'] == 'OPTIONS'
|
|
14
|
+
|
|
15
|
+
status, headers, body = @app.call(env)
|
|
16
|
+
add_cors_headers(headers, origin)
|
|
17
|
+
[status, headers, body]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def preflight_response(origin)
|
|
23
|
+
headers = {
|
|
24
|
+
'content-type' => 'text/plain',
|
|
25
|
+
'access-control-max-age' => '86400'
|
|
26
|
+
}
|
|
27
|
+
add_cors_headers(headers, origin)
|
|
28
|
+
headers['access-control-allow-methods'] = 'GET, POST, DELETE, OPTIONS'
|
|
29
|
+
headers['access-control-allow-headers'] = 'Content-Type, Authorization, X-Request-Id'
|
|
30
|
+
[204, headers, []]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_cors_headers(headers, origin)
|
|
34
|
+
allowed = ClaudeAgentServer.config.allowed_origins
|
|
35
|
+
|
|
36
|
+
if allowed.include?('*')
|
|
37
|
+
headers['access-control-allow-origin'] = '*'
|
|
38
|
+
elsif origin && allowed.include?(origin)
|
|
39
|
+
headers['access-control-allow-origin'] = origin
|
|
40
|
+
headers['vary'] = 'Origin'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
headers['access-control-expose-headers'] = 'X-Request-Id'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ClaudeAgentServer
|
|
6
|
+
module Middleware
|
|
7
|
+
class ErrorHandler
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
@app.call(env)
|
|
14
|
+
rescue ServerError => e
|
|
15
|
+
problem_response(e.status_code, e.to_problem_details)
|
|
16
|
+
rescue ClaudeAgentSDK::CLINotFoundError => e
|
|
17
|
+
problem_response(503, problem(503, 'cli_not_found', 'CLI Not Found', e.message))
|
|
18
|
+
rescue ClaudeAgentSDK::CLIConnectionError => e
|
|
19
|
+
problem_response(502, problem(502, 'cli_connection_error', 'CLI Connection Error', e.message))
|
|
20
|
+
rescue ClaudeAgentSDK::ProcessError => e
|
|
21
|
+
problem_response(502, problem(502, 'cli_process_error', 'CLI Process Error', e.message))
|
|
22
|
+
rescue ClaudeAgentSDK::ClaudeSDKError => e
|
|
23
|
+
problem_response(502, problem(502, 'sdk_error', 'SDK Error', e.message))
|
|
24
|
+
rescue ArgumentError => e
|
|
25
|
+
problem_response(400, problem(400, 'invalid_request', 'Invalid Request', e.message))
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
problem_response(500, problem(500, 'internal_error', 'Internal Error', e.message))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def problem(status, error_type, title, detail)
|
|
33
|
+
{
|
|
34
|
+
type: "#{URN_PREFIX}:#{error_type}",
|
|
35
|
+
title: title,
|
|
36
|
+
status: status,
|
|
37
|
+
detail: detail
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def problem_response(status, body)
|
|
42
|
+
[status, { 'content-type' => 'application/problem+json' }, [JSON.generate(body)]]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module ClaudeAgentServer
|
|
6
|
+
module Middleware
|
|
7
|
+
class RequestId
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
request_id = env['HTTP_X_REQUEST_ID'] || SecureRandom.uuid
|
|
14
|
+
env['claude_agent_server.request_id'] = request_id
|
|
15
|
+
|
|
16
|
+
status, headers, body = @app.call(env)
|
|
17
|
+
headers['x-request-id'] = request_id
|
|
18
|
+
[status, headers, body]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|