language-operator 0.1.70 → 0.1.80

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.
@@ -3,7 +3,10 @@
3
3
  require 'rack'
4
4
  require 'rackup'
5
5
  require 'mcp'
6
+ require 'fileutils'
7
+ require 'securerandom'
6
8
  require_relative 'executor'
9
+ require_relative 'prompt_builder'
7
10
 
8
11
  module LanguageOperator
9
12
  module Agent
@@ -29,6 +32,7 @@ module LanguageOperator
29
32
  @routes = {}
30
33
  @mcp_server = nil
31
34
  @mcp_transport = nil
35
+ @execution_state = nil # Initialized when register_execute_endpoint called
32
36
 
33
37
  # Initialize executor pool to prevent MCP connection leaks
34
38
  @executor_pool_size = ENV.fetch('EXECUTOR_POOL_SIZE', '4').to_i
@@ -110,16 +114,22 @@ module LanguageOperator
110
114
 
111
115
  # Register chat completion endpoint
112
116
  #
113
- # Sets up OpenAI-compatible chat completion endpoint.
114
- # Agents can be used as drop-in LLM replacements.
117
+ # Sets up OpenAI-compatible chat completion endpoint for all agents.
118
+ # Every agent automatically gets identity-aware chat capabilities.
115
119
  #
116
- # @param chat_endpoint_def [LanguageOperator::Dsl::ChatEndpointDefinition] Chat endpoint definition
117
120
  # @param agent [LanguageOperator::Agent::Base] The agent instance
118
121
  # @return [void]
119
- def register_chat_endpoint(chat_endpoint_def, agent)
120
- @chat_endpoint = chat_endpoint_def
122
+ def register_chat_endpoint(agent)
121
123
  @chat_agent = agent
122
124
 
125
+ # Create simple chat configuration (identity awareness always enabled)
126
+ @chat_config = {
127
+ model_name: ENV.fetch('AGENT_NAME', agent.config&.dig('agent', 'name') || 'agent'),
128
+ system_prompt: build_default_system_prompt(agent),
129
+ temperature: 0.7,
130
+ max_tokens: 2000
131
+ }
132
+
123
133
  # Register OpenAI-compatible endpoint
124
134
  register_route('/v1/chat/completions', method: :post) do |context|
125
135
  handle_chat_completion(context)
@@ -131,19 +141,78 @@ module LanguageOperator
131
141
  object: 'list',
132
142
  data: [
133
143
  {
134
- id: chat_endpoint_def.model_name,
144
+ id: @chat_config[:model_name],
135
145
  object: 'model',
136
146
  created: Time.now.to_i,
137
147
  owned_by: 'language-operator',
138
148
  permission: [],
139
- root: chat_endpoint_def.model_name,
149
+ root: @chat_config[:model_name],
140
150
  parent: nil
141
151
  }
142
152
  ]
143
153
  }
144
154
  end
145
155
 
146
- puts "Registered chat completion endpoint as model: #{chat_endpoint_def.model_name}"
156
+ puts "Registered identity-aware chat endpoint as model: #{@chat_config[:model_name]}"
157
+ end
158
+
159
+ # Register execution trigger endpoint
160
+ #
161
+ # Enables scheduled/reactive agents to execute tasks via HTTP POST.
162
+ # Prevents concurrent executions via ExecutionState.
163
+ #
164
+ # @param agent [LanguageOperator::Agent::Base] The agent instance
165
+ # @param agent_def [LanguageOperator::Dsl::AgentDefinition, nil] Optional agent definition
166
+ # @return [void]
167
+ def register_execute_endpoint(agent, agent_def = nil)
168
+ require_relative 'execution_state'
169
+
170
+ @execute_agent = agent
171
+ @execute_agent_def = agent_def
172
+ @execution_state = LanguageOperator::Agent::ExecutionState.new
173
+
174
+ register_route('/api/v1/execute', method: :post) do |context|
175
+ handle_execute_request(context)
176
+ end
177
+
178
+ puts 'Registered /api/v1/execute endpoint for triggered execution'
179
+ end
180
+
181
+ # Register workspace file management endpoints
182
+ #
183
+ # @param agent [LanguageOperator::Agent::Base] The agent instance
184
+ def register_workspace_endpoints(agent)
185
+ return unless agent.workspace_available?
186
+
187
+ @workspace_agent = agent
188
+ @workspace_path = agent.workspace_path
189
+
190
+ # List directory contents
191
+ register_route('/api/v1/workspace/files', method: :get) do |context|
192
+ handle_workspace_list(context)
193
+ end
194
+
195
+ # View file contents
196
+ register_route('/api/v1/workspace/files/view', method: :get) do |context|
197
+ handle_workspace_view(context)
198
+ end
199
+
200
+ # Upload file
201
+ register_route('/api/v1/workspace/files', method: :post) do |context|
202
+ handle_workspace_upload(context)
203
+ end
204
+
205
+ # Delete file
206
+ register_route('/api/v1/workspace/files', method: :delete) do |context|
207
+ handle_workspace_delete(context)
208
+ end
209
+
210
+ # Download file/directory
211
+ register_route('/api/v1/workspace/files/download', method: :get) do |context|
212
+ handle_workspace_download(context)
213
+ end
214
+
215
+ puts 'Registered /api/v1/workspace/* endpoints for file management'
147
216
  end
148
217
 
149
218
  # Handle incoming HTTP request
@@ -193,6 +262,163 @@ module LanguageOperator
193
262
 
194
263
  private
195
264
 
265
+ # Build default system prompt for agent
266
+ #
267
+ # Creates a basic system prompt based on agent description
268
+ #
269
+ # @param agent [LanguageOperator::Agent::Base] The agent instance
270
+ # @return [String] Default system prompt
271
+ def build_default_system_prompt(agent)
272
+ description = agent.config&.dig('agent', 'instructions') ||
273
+ agent.config&.dig('agent', 'description') ||
274
+ 'AI assistant'
275
+
276
+ if description.downcase.start_with?('you are')
277
+ description
278
+ else
279
+ "You are #{description.downcase}. Provide helpful assistance based on your capabilities."
280
+ end
281
+ end
282
+
283
+ # Handle POST /api/v1/execute request
284
+ #
285
+ # @param context [Hash] Request context with :body, :headers, :request
286
+ # @return [Hash] Response data
287
+ def handle_execute_request(context)
288
+ puts "Received execute request: #{context[:body]}"
289
+
290
+ # Check for concurrent execution
291
+ if @execution_state.running?
292
+ info = @execution_state.current_info
293
+ puts "Execution already running: #{info}"
294
+ return {
295
+ status: 409,
296
+ body: {
297
+ error: 'ExecutionInProgress',
298
+ message: 'Agent is currently executing a task',
299
+ current_execution: info
300
+ }.to_json,
301
+ headers: { 'Content-Type' => 'application/json' }
302
+ }
303
+ end
304
+
305
+ # Parse request
306
+ request_data = JSON.parse(context[:body] || '{}')
307
+ puts "Parsed request data: #{request_data.inspect}"
308
+
309
+ execution_id = "exec-#{SecureRandom.hex(8)}"
310
+ puts "Generated execution ID: #{execution_id}"
311
+
312
+ wait_for_completion = request_data.fetch('wait', true)
313
+ puts "Execution details - ID: #{execution_id}, wait: #{wait_for_completion}"
314
+
315
+ # Start execution
316
+ puts 'About to start execution state...'
317
+ @execution_state.start_execution(execution_id)
318
+ puts "Started execution state for #{execution_id}"
319
+
320
+ if wait_for_completion
321
+ execute_sync(execution_id, request_data['context'])
322
+ else
323
+ execute_async(execution_id, request_data['context'])
324
+ end
325
+ rescue JSON::ParserError
326
+ execute_error_response(400, 'InvalidJSON', 'Request body must be valid JSON')
327
+ rescue LanguageOperator::Agent::ExecutionInProgressError => e
328
+ execute_error_response(409, 'ExecutionInProgress', e.message)
329
+ rescue StandardError => e
330
+ puts "Exception in handle_execute_request: #{e.class}: #{e.message}"
331
+ puts "Exception backtrace: #{e.backtrace.first(5).join('\n')}"
332
+ @execution_state&.fail_execution(e)
333
+ execute_error_response(500, 'ExecutionError', e.message)
334
+ end
335
+
336
+ # Execute task synchronously
337
+ #
338
+ # @param execution_id [String] Execution identifier
339
+ # @param context_data [Hash] Additional context data
340
+ # @return [Hash] Response data
341
+ def execute_sync(execution_id, _context_data)
342
+ start_time = Time.now
343
+ puts "Starting execution #{execution_id}"
344
+
345
+ # Execute agent main block (convention: all DSL agents have main blocks)
346
+ puts 'About to execute main block...'
347
+ $stdout.flush
348
+
349
+ # Test logging capture during execution
350
+ original_stdout = $stdout
351
+ original_stderr = $stderr
352
+ puts "Original stdout: #{original_stdout.class}"
353
+ puts "Original stderr: #{original_stderr.class}"
354
+ $stdout.flush
355
+
356
+ result = LanguageOperator::Agent.execute_main_block(@execute_agent, @execute_agent_def)
357
+
358
+ puts "Main block execution returned: #{result.inspect}"
359
+ puts "Stdout after execution: #{$stdout.class}"
360
+ puts "Stderr after execution: #{$stderr.class}"
361
+ $stdout.flush
362
+
363
+ puts "Execution #{execution_id} completed with result: #{result.inspect}"
364
+ @execution_state.complete_execution(result)
365
+
366
+ {
367
+ status: 200,
368
+ body: {
369
+ status: 'completed',
370
+ result: result,
371
+ execution_id: execution_id,
372
+ started_at: start_time.iso8601,
373
+ completed_at: Time.now.iso8601,
374
+ duration_seconds: (Time.now - start_time).round(2)
375
+ }.to_json,
376
+ headers: { 'Content-Type' => 'application/json' }
377
+ }
378
+ end
379
+
380
+ # Execute task asynchronously
381
+ #
382
+ # @param execution_id [String] Execution identifier
383
+ # @param context_data [Hash] Additional context data
384
+ # @return [Hash] Response data
385
+ def execute_async(execution_id, _context_data)
386
+ Thread.new do
387
+ result = LanguageOperator::Agent.execute_main_block(@execute_agent, @execute_agent_def)
388
+ @execution_state.complete_execution(result)
389
+ rescue StandardError => e
390
+ @execution_state.fail_execution(e)
391
+ logger.error('Async execution failed', error: e.message, execution_id: execution_id)
392
+ end
393
+
394
+ {
395
+ status: 202,
396
+ body: {
397
+ status: 'accepted',
398
+ execution_id: execution_id,
399
+ message: 'Execution started asynchronously'
400
+ }.to_json,
401
+ headers: { 'Content-Type' => 'application/json' }
402
+ }
403
+ end
404
+
405
+ # Generate error response for execute endpoint
406
+ #
407
+ # @param status [Integer] HTTP status code
408
+ # @param error_type [String] Error type identifier
409
+ # @param message [String] Error message
410
+ # @return [Hash] Error response data
411
+ def execute_error_response(status, error_type, message)
412
+ {
413
+ status: status,
414
+ body: {
415
+ error: error_type,
416
+ message: message
417
+ }.to_json,
418
+ headers: { 'Content-Type' => 'application/json' }
419
+ }
420
+ end
421
+
196
422
  # Setup executor pool for connection reuse
197
423
  #
198
424
  # Creates a thread-safe queue pre-populated with executor instances
@@ -406,7 +632,7 @@ module LanguageOperator
406
632
  # @param context [Hash] Request context
407
633
  # @return [Array, Hash] Rack response or hash for streaming
408
634
  def handle_chat_completion(context)
409
- return error_response(StandardError.new('Chat endpoint not configured')) unless @chat_endpoint
635
+ return error_response(StandardError.new('Chat endpoint not configured')) unless @chat_config
410
636
 
411
637
  # Parse request body
412
638
  request_data = JSON.parse(context[:body])
@@ -444,7 +670,7 @@ module LanguageOperator
444
670
  id: "chatcmpl-#{SecureRandom.hex(12)}",
445
671
  object: 'chat.completion',
446
672
  created: Time.now.to_i,
447
- model: @chat_endpoint.model_name,
673
+ model: @chat_config[:model_name],
448
674
  choices: [
449
675
  {
450
676
  index: 0,
@@ -480,41 +706,79 @@ module LanguageOperator
480
706
  'Cache-Control' => 'no-cache',
481
707
  'Connection' => 'keep-alive'
482
708
  },
483
- StreamingBody.new(@chat_agent, prompt, @chat_endpoint.model_name)
709
+ StreamingBody.new(@chat_agent, prompt, @chat_config[:model_name])
484
710
  ]
485
711
  end
486
712
 
487
- # Build prompt from OpenAI message format
713
+ # Build prompt from OpenAI message format with identity awareness
488
714
  #
489
715
  # @param messages [Array<Hash>] Array of message objects
490
- # @return [String] Combined prompt
716
+ # @return [String] Combined prompt with agent identity context
491
717
  def build_prompt_from_messages(messages)
492
- # Combine all messages into a single prompt
493
- # System messages become instructions
494
- # User/assistant messages become conversation
495
718
  prompt_parts = []
496
719
 
497
- # Add system prompt if configured
498
- prompt_parts << "System: #{@chat_endpoint.system_prompt}" if @chat_endpoint.system_prompt
720
+ # Build identity-aware system prompt (always enabled)
721
+ system_prompt = build_identity_aware_system_prompt
722
+ prompt_parts << "System: #{system_prompt}" if system_prompt
723
+
724
+ # Add conversation context (always enabled)
725
+ conversation_context = build_conversation_context
726
+ prompt_parts << conversation_context if conversation_context
499
727
 
500
- # Add conversation history
728
+ # Add conversation history (skip system messages from original array since we handle them above)
501
729
  messages.each do |msg|
502
730
  role = msg['role']
503
731
  content = msg['content']
504
732
 
505
733
  case role
506
- when 'system'
507
- prompt_parts << "System: #{content}"
508
734
  when 'user'
509
735
  prompt_parts << "User: #{content}"
510
736
  when 'assistant'
511
737
  prompt_parts << "Assistant: #{content}"
738
+ # Skip system messages - we handle them via PromptBuilder
512
739
  end
513
740
  end
514
741
 
515
742
  prompt_parts.join("\n\n")
516
743
  end
517
744
 
745
+ # Build identity-aware system prompt using PromptBuilder
746
+ #
747
+ # @return [String] Dynamic system prompt with agent identity
748
+ def build_identity_aware_system_prompt
749
+ # Create prompt builder with identity awareness always enabled
750
+ builder = PromptBuilder.new(
751
+ @chat_agent,
752
+ nil, # No chat config needed
753
+ template: :standard, # Good default
754
+ enable_identity_awareness: true
755
+ )
756
+
757
+ builder.build_system_prompt
758
+ rescue StandardError => e
759
+ # Log error and fall back to static prompt
760
+ puts "Warning: Failed to build identity-aware system prompt: #{e.message}"
761
+ @chat_config[:system_prompt]
762
+ end
763
+
764
+ # Build conversation context for ongoing chats
765
+ #
766
+ # @return [String, nil] Conversation context
767
+ def build_conversation_context
768
+ builder = PromptBuilder.new(
769
+ @chat_agent,
770
+ nil, # No chat config needed
771
+ enable_identity_awareness: true
772
+ )
773
+
774
+ context = builder.build_conversation_context
775
+ context ? "Context: #{context}" : nil
776
+ rescue StandardError => e
777
+ # Log error and continue without context
778
+ puts "Warning: Failed to build conversation context: #{e.message}"
779
+ nil
780
+ end
781
+
518
782
  # Estimate token count (rough approximation)
519
783
  #
520
784
  # @param text [String] Text to estimate
@@ -701,6 +965,331 @@ module LanguageOperator
701
965
  ensure
702
966
  stream.close
703
967
  end
968
+
969
+ # Workspace file management handlers
970
+
971
+ # Handle GET /api/v1/workspace/files - list directory contents
972
+ def handle_workspace_list(context)
973
+ request = context[:request]
974
+ path = request.params['path'] || '/'
975
+
976
+ # Sanitize path to prevent directory traversal
977
+ safe_path = sanitize_workspace_path(path)
978
+ full_path = File.join(@workspace_path, safe_path)
979
+
980
+ return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
981
+
982
+ if File.directory?(full_path)
983
+ list_directory_contents(full_path, path)
984
+ else
985
+ # If it's a file, return file info
986
+ get_file_info(full_path, path)
987
+ end
988
+ rescue StandardError => e
989
+ workspace_error_response(500, 'InternalError', e.message)
990
+ end
991
+
992
+ # Handle GET /api/v1/workspace/files/view - view file contents
993
+ def handle_workspace_view(context)
994
+ request = context[:request]
995
+ path = request.params['path']
996
+
997
+ return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
998
+
999
+ # Sanitize path to prevent directory traversal
1000
+ safe_path = sanitize_workspace_path(path)
1001
+ full_path = File.join(@workspace_path, safe_path)
1002
+
1003
+ return workspace_error_response(404, 'NotFound', "File not found: #{path}") unless File.exist?(full_path)
1004
+
1005
+ return workspace_error_response(400, 'BadRequest', "Path is not a file: #{path}") unless File.file?(full_path)
1006
+
1007
+ # Check file size (limit to 10MB for API responses)
1008
+ file_size = File.size(full_path)
1009
+ return workspace_error_response(413, 'FileTooLarge', 'File too large for viewing (max 10MB)') if file_size > 10 * 1024 * 1024
1010
+
1011
+ # Read file contents
1012
+ contents = File.read(full_path)
1013
+
1014
+ {
1015
+ status: 200,
1016
+ body: {
1017
+ path: path,
1018
+ size: file_size,
1019
+ modified: File.mtime(full_path).iso8601,
1020
+ content: contents,
1021
+ content_type: detect_content_type(full_path)
1022
+ }.to_json,
1023
+ headers: { 'Content-Type' => 'application/json' }
1024
+ }
1025
+ rescue StandardError => e
1026
+ workspace_error_response(500, 'InternalError', e.message)
1027
+ end
1028
+
1029
+ # Handle POST /api/v1/workspace/files - upload file
1030
+ def handle_workspace_upload(context)
1031
+ request = context[:request]
1032
+
1033
+ # Check for multipart form data
1034
+ content_type = request.env['CONTENT_TYPE']
1035
+ unless content_type&.start_with?('multipart/form-data')
1036
+ return workspace_error_response(400, 'BadRequest', 'Content-Type must be multipart/form-data')
1037
+ end
1038
+
1039
+ # Parse multipart data
1040
+ begin
1041
+ # Get file from params (Rack automatically parses multipart data)
1042
+ file_param = request.params['file']
1043
+ path_param = request.params['path']
1044
+
1045
+ return workspace_error_response(400, 'BadRequest', 'file parameter required') unless file_param
1046
+
1047
+ return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path_param
1048
+
1049
+ # Handle file upload object
1050
+ return workspace_error_response(400, 'BadRequest', 'Invalid file upload format') unless file_param.is_a?(Hash) && file_param[:tempfile]
1051
+
1052
+ # Rack file upload format
1053
+ tempfile = file_param[:tempfile]
1054
+ original_filename = file_param[:filename]
1055
+ content_type = file_param[:type]
1056
+
1057
+ # Sanitize target path
1058
+ safe_path = sanitize_workspace_path(path_param)
1059
+ full_path = File.join(@workspace_path, safe_path)
1060
+
1061
+ # Ensure target directory exists
1062
+ target_dir = File.dirname(full_path)
1063
+ FileUtils.mkdir_p(target_dir) unless File.directory?(target_dir)
1064
+
1065
+ # Check file size (limit to 100MB)
1066
+ file_size = tempfile.size
1067
+ return workspace_error_response(413, 'FileTooLarge', 'File too large for upload (max 100MB)') if file_size > 100 * 1024 * 1024
1068
+
1069
+ # Copy uploaded file to workspace
1070
+ tempfile.rewind
1071
+ File.open(full_path, 'wb') do |dest_file|
1072
+ IO.copy_stream(tempfile, dest_file)
1073
+ end
1074
+
1075
+ # Set reasonable permissions
1076
+ File.chmod(0o644, full_path)
1077
+
1078
+ {
1079
+ status: 201,
1080
+ body: {
1081
+ path: path_param,
1082
+ size: file_size,
1083
+ original_filename: original_filename,
1084
+ content_type: content_type,
1085
+ message: 'File uploaded successfully'
1086
+ }.to_json,
1087
+ headers: { 'Content-Type' => 'application/json' }
1088
+ }
1089
+ rescue StandardError => e
1090
+ workspace_error_response(500, 'UploadError', "Upload failed: #{e.message}")
1091
+ ensure
1092
+ # Clean up temporary file
1093
+ tempfile&.close if tempfile.respond_to?(:close)
1094
+ end
1095
+ end
1096
+
1097
+ # Handle DELETE /api/v1/workspace/files - delete file
1098
+ def handle_workspace_delete(context)
1099
+ request = context[:request]
1100
+ path = request.params['path']
1101
+
1102
+ return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
1103
+
1104
+ # Sanitize path to prevent directory traversal
1105
+ safe_path = sanitize_workspace_path(path)
1106
+ full_path = File.join(@workspace_path, safe_path)
1107
+
1108
+ return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
1109
+
1110
+ begin
1111
+ deleted_info = {
1112
+ path: path,
1113
+ type: File.directory?(full_path) ? 'directory' : 'file',
1114
+ size: File.directory?(full_path) ? nil : File.size(full_path)
1115
+ }
1116
+
1117
+ if File.directory?(full_path)
1118
+ # Delete directory and all contents
1119
+ FileUtils.rm_rf(full_path)
1120
+ deleted_info[:message] = 'Directory deleted successfully'
1121
+ else
1122
+ # Delete single file
1123
+ File.delete(full_path)
1124
+ deleted_info[:message] = 'File deleted successfully'
1125
+ end
1126
+
1127
+ {
1128
+ status: 200,
1129
+ body: deleted_info.to_json,
1130
+ headers: { 'Content-Type' => 'application/json' }
1131
+ }
1132
+ rescue StandardError => e
1133
+ workspace_error_response(500, 'DeleteError', "Delete failed: #{e.message}")
1134
+ end
1135
+ end
1136
+
1137
+ # Handle GET /api/v1/workspace/files/download - download file/archive
1138
+ def handle_workspace_download(context)
1139
+ request = context[:request]
1140
+ path = request.params['path']
1141
+
1142
+ return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
1143
+
1144
+ # Sanitize path to prevent directory traversal
1145
+ safe_path = sanitize_workspace_path(path)
1146
+ full_path = File.join(@workspace_path, safe_path)
1147
+
1148
+ return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
1149
+
1150
+ begin
1151
+ if File.directory?(full_path)
1152
+ # Create temporary tar.gz archive for directory
1153
+ archive_name = "#{File.basename(safe_path.empty? ? 'workspace' : safe_path)}.tar.gz"
1154
+ temp_archive = "/tmp/#{SecureRandom.hex(8)}_#{archive_name}"
1155
+
1156
+ # Create tar archive
1157
+ system('tar', '-czf', temp_archive, '-C', File.dirname(full_path), File.basename(full_path))
1158
+
1159
+ return workspace_error_response(500, 'ArchiveError', 'Failed to create archive') unless File.exist?(temp_archive)
1160
+
1161
+ # Read archive contents
1162
+ archive_data = File.binread(temp_archive)
1163
+
1164
+ # Clean up temporary file
1165
+ File.delete(temp_archive)
1166
+
1167
+ {
1168
+ status: 200,
1169
+ body: archive_data,
1170
+ headers: {
1171
+ 'Content-Type' => 'application/gzip',
1172
+ 'Content-Disposition' => "attachment; filename=\"#{archive_name}\"",
1173
+ 'Content-Length' => archive_data.length.to_s
1174
+ }
1175
+ }
1176
+ else
1177
+ # Direct file download
1178
+ file_data = File.binread(full_path)
1179
+ filename = File.basename(safe_path)
1180
+ content_type = detect_content_type(full_path)
1181
+
1182
+ # For binary files, use application/octet-stream
1183
+ content_disposition = if content_type == 'application/octet-stream'
1184
+ "attachment; filename=\"#{filename}\""
1185
+ else
1186
+ # For text files, allow inline viewing
1187
+ "inline; filename=\"#{filename}\""
1188
+ end
1189
+
1190
+ {
1191
+ status: 200,
1192
+ body: file_data,
1193
+ headers: {
1194
+ 'Content-Type' => content_type,
1195
+ 'Content-Disposition' => content_disposition,
1196
+ 'Content-Length' => file_data.length.to_s
1197
+ }
1198
+ }
1199
+ end
1200
+ rescue StandardError => e
1201
+ workspace_error_response(500, 'DownloadError', "Download failed: #{e.message}")
1202
+ end
1203
+ end
1204
+
1205
+ private
1206
+
1207
+ # Sanitize workspace path to prevent directory traversal attacks
1208
+ def sanitize_workspace_path(path)
1209
+ # Remove leading slash and resolve relative paths
1210
+ clean_path = path.sub(%r{^/+}, '')
1211
+
1212
+ # Resolve and normalize the path
1213
+ normalized = File.expand_path(clean_path, '/')
1214
+
1215
+ # Ensure it doesn't escape the root
1216
+ return '' if normalized == '/' || !normalized.start_with?('/')
1217
+
1218
+ # Remove leading slash for joining with workspace_path
1219
+ normalized[1..]
1220
+ end
1221
+
1222
+ # List directory contents
1223
+ def list_directory_contents(full_path, requested_path)
1224
+ entries = Dir.entries(full_path).reject { |entry| entry.start_with?('.') }
1225
+
1226
+ files = entries.map do |entry|
1227
+ entry_path = File.join(full_path, entry)
1228
+ relative_path = File.join(requested_path == '/' ? '' : requested_path, entry)
1229
+
1230
+ {
1231
+ name: entry,
1232
+ path: "/#{relative_path}".gsub(%r{/+}, '/'),
1233
+ type: File.directory?(entry_path) ? 'directory' : 'file',
1234
+ size: File.directory?(entry_path) ? nil : File.size(entry_path),
1235
+ modified: File.mtime(entry_path).iso8601
1236
+ }
1237
+ end
1238
+
1239
+ files.sort_by! { |f| [f[:type] == 'directory' ? 0 : 1, f[:name]] }
1240
+
1241
+ {
1242
+ status: 200,
1243
+ body: {
1244
+ path: requested_path,
1245
+ files: files,
1246
+ total: files.length
1247
+ }.to_json,
1248
+ headers: { 'Content-Type' => 'application/json' }
1249
+ }
1250
+ end
1251
+
1252
+ # Get file info for a single file
1253
+ def get_file_info(full_path, requested_path)
1254
+ {
1255
+ status: 200,
1256
+ body: {
1257
+ path: requested_path,
1258
+ type: 'file',
1259
+ size: File.size(full_path),
1260
+ modified: File.mtime(full_path).iso8601
1261
+ }.to_json,
1262
+ headers: { 'Content-Type' => 'application/json' }
1263
+ }
1264
+ end
1265
+
1266
+ # Detect content type based on file extension
1267
+ def detect_content_type(file_path)
1268
+ case File.extname(file_path).downcase
1269
+ when '.txt', '.log' then 'text/plain'
1270
+ when '.json' then 'application/json'
1271
+ when '.yml', '.yaml' then 'application/x-yaml'
1272
+ when '.md' then 'text/markdown'
1273
+ when '.html' then 'text/html'
1274
+ when '.css' then 'text/css'
1275
+ when '.js' then 'application/javascript'
1276
+ when '.py' then 'text/x-python'
1277
+ when '.rb' then 'text/x-ruby'
1278
+ else 'application/octet-stream'
1279
+ end
1280
+ end
1281
+
1282
+ # Generate workspace API error response
1283
+ def workspace_error_response(status, error_type, message)
1284
+ {
1285
+ status: status,
1286
+ body: {
1287
+ error: error_type,
1288
+ message: message
1289
+ }.to_json,
1290
+ headers: { 'Content-Type' => 'application/json' }
1291
+ }
1292
+ end
704
1293
  end
705
1294
  end
706
1295
  end