signalwire_agents 1.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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +79 -0
  3. data/bin/swaig-test +310 -0
  4. data/lib/signalwire_agents/agent/agent_base.rb +1171 -0
  5. data/lib/signalwire_agents/contexts/context_builder.rb +622 -0
  6. data/lib/signalwire_agents/datamap/data_map.rb +279 -0
  7. data/lib/signalwire_agents/logging.rb +92 -0
  8. data/lib/signalwire_agents/prefabs/concierge.rb +92 -0
  9. data/lib/signalwire_agents/prefabs/faq_bot.rb +67 -0
  10. data/lib/signalwire_agents/prefabs/info_gatherer.rb +79 -0
  11. data/lib/signalwire_agents/prefabs/receptionist.rb +74 -0
  12. data/lib/signalwire_agents/prefabs/survey.rb +75 -0
  13. data/lib/signalwire_agents/relay/action.rb +291 -0
  14. data/lib/signalwire_agents/relay/call.rb +523 -0
  15. data/lib/signalwire_agents/relay/client.rb +671 -0
  16. data/lib/signalwire_agents/relay/constants.rb +124 -0
  17. data/lib/signalwire_agents/relay/message.rb +137 -0
  18. data/lib/signalwire_agents/relay/relay_event.rb +670 -0
  19. data/lib/signalwire_agents/rest/http_client.rb +147 -0
  20. data/lib/signalwire_agents/rest/namespaces/addresses.rb +19 -0
  21. data/lib/signalwire_agents/rest/namespaces/calling.rb +179 -0
  22. data/lib/signalwire_agents/rest/namespaces/chat.rb +18 -0
  23. data/lib/signalwire_agents/rest/namespaces/compat.rb +229 -0
  24. data/lib/signalwire_agents/rest/namespaces/datasphere.rb +39 -0
  25. data/lib/signalwire_agents/rest/namespaces/fabric.rb +175 -0
  26. data/lib/signalwire_agents/rest/namespaces/imported_numbers.rb +18 -0
  27. data/lib/signalwire_agents/rest/namespaces/logs.rb +46 -0
  28. data/lib/signalwire_agents/rest/namespaces/lookup.rb +18 -0
  29. data/lib/signalwire_agents/rest/namespaces/mfa.rb +26 -0
  30. data/lib/signalwire_agents/rest/namespaces/number_groups.rb +32 -0
  31. data/lib/signalwire_agents/rest/namespaces/phone_numbers.rb +20 -0
  32. data/lib/signalwire_agents/rest/namespaces/project.rb +33 -0
  33. data/lib/signalwire_agents/rest/namespaces/pubsub.rb +18 -0
  34. data/lib/signalwire_agents/rest/namespaces/queues.rb +28 -0
  35. data/lib/signalwire_agents/rest/namespaces/recordings.rb +18 -0
  36. data/lib/signalwire_agents/rest/namespaces/registry.rb +67 -0
  37. data/lib/signalwire_agents/rest/namespaces/short_codes.rb +26 -0
  38. data/lib/signalwire_agents/rest/namespaces/sip_profile.rb +22 -0
  39. data/lib/signalwire_agents/rest/namespaces/verified_callers.rb +24 -0
  40. data/lib/signalwire_agents/rest/namespaces/video.rb +129 -0
  41. data/lib/signalwire_agents/rest/signalwire_client.rb +110 -0
  42. data/lib/signalwire_agents/security/session_manager.rb +124 -0
  43. data/lib/signalwire_agents/server/agent_server.rb +260 -0
  44. data/lib/signalwire_agents/skills/builtin/api_ninjas_trivia.rb +91 -0
  45. data/lib/signalwire_agents/skills/builtin/claude_skills.rb +92 -0
  46. data/lib/signalwire_agents/skills/builtin/custom_skills.rb +54 -0
  47. data/lib/signalwire_agents/skills/builtin/datasphere.rb +141 -0
  48. data/lib/signalwire_agents/skills/builtin/datasphere_serverless.rb +107 -0
  49. data/lib/signalwire_agents/skills/builtin/datetime.rb +97 -0
  50. data/lib/signalwire_agents/skills/builtin/google_maps.rb +168 -0
  51. data/lib/signalwire_agents/skills/builtin/info_gatherer.rb +189 -0
  52. data/lib/signalwire_agents/skills/builtin/joke.rb +65 -0
  53. data/lib/signalwire_agents/skills/builtin/math.rb +176 -0
  54. data/lib/signalwire_agents/skills/builtin/mcp_gateway.rb +121 -0
  55. data/lib/signalwire_agents/skills/builtin/native_vector_search.rb +116 -0
  56. data/lib/signalwire_agents/skills/builtin/play_background_file.rb +86 -0
  57. data/lib/signalwire_agents/skills/builtin/spider.rb +142 -0
  58. data/lib/signalwire_agents/skills/builtin/swml_transfer.rb +118 -0
  59. data/lib/signalwire_agents/skills/builtin/weather_api.rb +85 -0
  60. data/lib/signalwire_agents/skills/builtin/web_search.rb +123 -0
  61. data/lib/signalwire_agents/skills/builtin/wikipedia_search.rb +109 -0
  62. data/lib/signalwire_agents/skills/skill_base.rb +58 -0
  63. data/lib/signalwire_agents/skills/skill_manager.rb +85 -0
  64. data/lib/signalwire_agents/skills/skill_registry.rb +76 -0
  65. data/lib/signalwire_agents/swaig/function_result.rb +777 -0
  66. data/lib/signalwire_agents/swml/document.rb +84 -0
  67. data/lib/signalwire_agents/swml/schema.json +12250 -0
  68. data/lib/signalwire_agents/swml/schema.rb +81 -0
  69. data/lib/signalwire_agents/swml/service.rb +304 -0
  70. data/lib/signalwire_agents/version.rb +5 -0
  71. data/lib/signalwire_agents.rb +19 -0
  72. metadata +212 -0
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWireAgents
4
+ module REST
5
+ module Namespaces
6
+ # Video room management with streams.
7
+ class VideoRooms < CrudResource
8
+ self.update_method = 'PUT'
9
+
10
+ def list_streams(room_id, **params)
11
+ @http.get(_path(room_id, 'streams'), params.empty? ? nil : params)
12
+ end
13
+
14
+ def create_stream(room_id, **kwargs)
15
+ @http.post(_path(room_id, 'streams'), kwargs)
16
+ end
17
+ end
18
+
19
+ # Video room token generation.
20
+ class VideoRoomTokens < BaseResource
21
+ def create(**kwargs)
22
+ @http.post(@base_path, kwargs)
23
+ end
24
+ end
25
+
26
+ # Video room session management.
27
+ class VideoRoomSessions < BaseResource
28
+ def list(**params)
29
+ @http.get(@base_path, params.empty? ? nil : params)
30
+ end
31
+
32
+ def get(session_id)
33
+ @http.get(_path(session_id))
34
+ end
35
+
36
+ def list_events(session_id, **params)
37
+ @http.get(_path(session_id, 'events'), params.empty? ? nil : params)
38
+ end
39
+
40
+ def list_members(session_id, **params)
41
+ @http.get(_path(session_id, 'members'), params.empty? ? nil : params)
42
+ end
43
+
44
+ def list_recordings(session_id, **params)
45
+ @http.get(_path(session_id, 'recordings'), params.empty? ? nil : params)
46
+ end
47
+ end
48
+
49
+ # Video room recording management.
50
+ class VideoRoomRecordings < BaseResource
51
+ def list(**params)
52
+ @http.get(@base_path, params.empty? ? nil : params)
53
+ end
54
+
55
+ def get(recording_id)
56
+ @http.get(_path(recording_id))
57
+ end
58
+
59
+ def delete(recording_id)
60
+ @http.delete(_path(recording_id))
61
+ end
62
+
63
+ def list_events(recording_id, **params)
64
+ @http.get(_path(recording_id, 'events'), params.empty? ? nil : params)
65
+ end
66
+ end
67
+
68
+ # Video conference management with tokens and streams.
69
+ class VideoConferences < CrudResource
70
+ self.update_method = 'PUT'
71
+
72
+ def list_conference_tokens(conference_id, **params)
73
+ @http.get(_path(conference_id, 'conference_tokens'), params.empty? ? nil : params)
74
+ end
75
+
76
+ def list_streams(conference_id, **params)
77
+ @http.get(_path(conference_id, 'streams'), params.empty? ? nil : params)
78
+ end
79
+
80
+ def create_stream(conference_id, **kwargs)
81
+ @http.post(_path(conference_id, 'streams'), kwargs)
82
+ end
83
+ end
84
+
85
+ # Video conference token management.
86
+ class VideoConferenceTokens < BaseResource
87
+ def get(token_id)
88
+ @http.get(_path(token_id))
89
+ end
90
+
91
+ def reset(token_id)
92
+ @http.post(_path(token_id, 'reset'))
93
+ end
94
+ end
95
+
96
+ # Video stream management.
97
+ class VideoStreams < BaseResource
98
+ def get(stream_id)
99
+ @http.get(_path(stream_id))
100
+ end
101
+
102
+ def update(stream_id, **kwargs)
103
+ @http.put(_path(stream_id), kwargs)
104
+ end
105
+
106
+ def delete(stream_id)
107
+ @http.delete(_path(stream_id))
108
+ end
109
+ end
110
+
111
+ # Video API namespace.
112
+ class VideoNamespace
113
+ attr_reader :rooms, :room_tokens, :room_sessions, :room_recordings,
114
+ :conferences, :conference_tokens, :streams
115
+
116
+ def initialize(http)
117
+ base = '/api/video'
118
+ @rooms = VideoRooms.new(http, "#{base}/rooms")
119
+ @room_tokens = VideoRoomTokens.new(http, "#{base}/room_tokens")
120
+ @room_sessions = VideoRoomSessions.new(http, "#{base}/room_sessions")
121
+ @room_recordings = VideoRoomRecordings.new(http, "#{base}/room_recordings")
122
+ @conferences = VideoConferences.new(http, "#{base}/conferences")
123
+ @conference_tokens = VideoConferenceTokens.new(http, "#{base}/conference_tokens")
124
+ @streams = VideoStreams.new(http, "#{base}/streams")
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'http_client'
4
+ require_relative 'namespaces/fabric'
5
+ require_relative 'namespaces/calling'
6
+ require_relative 'namespaces/phone_numbers'
7
+ require_relative 'namespaces/addresses'
8
+ require_relative 'namespaces/queues'
9
+ require_relative 'namespaces/recordings'
10
+ require_relative 'namespaces/number_groups'
11
+ require_relative 'namespaces/verified_callers'
12
+ require_relative 'namespaces/sip_profile'
13
+ require_relative 'namespaces/lookup'
14
+ require_relative 'namespaces/short_codes'
15
+ require_relative 'namespaces/imported_numbers'
16
+ require_relative 'namespaces/mfa'
17
+ require_relative 'namespaces/registry'
18
+ require_relative 'namespaces/datasphere'
19
+ require_relative 'namespaces/video'
20
+ require_relative 'namespaces/logs'
21
+ require_relative 'namespaces/project'
22
+ require_relative 'namespaces/pubsub'
23
+ require_relative 'namespaces/chat'
24
+ require_relative 'namespaces/compat'
25
+
26
+ module SignalWireAgents
27
+ module REST
28
+ # REST client for the SignalWire platform APIs.
29
+ #
30
+ # Usage:
31
+ # client = SignalWireAgents::REST::SignalWireClient.new(
32
+ # project: 'your-project-id',
33
+ # token: 'your-api-token',
34
+ # host: 'your-space.signalwire.com'
35
+ # )
36
+ #
37
+ # # Or use environment variables:
38
+ # # SIGNALWIRE_PROJECT_ID, SIGNALWIRE_API_TOKEN, SIGNALWIRE_SPACE
39
+ # client = SignalWireAgents::REST::SignalWireClient.new
40
+ #
41
+ # # Use namespaced resources
42
+ # client.fabric.ai_agents.list
43
+ # client.calling.play(call_id, play: [...])
44
+ # client.phone_numbers.search(area_code: '512')
45
+ # client.video.rooms.create(name: 'standup')
46
+ # client.compat.calls.list
47
+ class SignalWireClient
48
+ attr_reader :fabric, :calling, :phone_numbers, :datasphere, :video,
49
+ :compat, :addresses, :queues, :recordings, :number_groups,
50
+ :verified_callers, :sip_profile, :lookup, :short_codes,
51
+ :imported_numbers, :mfa, :registry, :logs, :project,
52
+ :pubsub, :chat
53
+
54
+ def initialize(project: nil, token: nil, host: nil)
55
+ project_id = project || ENV['SIGNALWIRE_PROJECT_ID'] || ''
56
+ api_token = token || ENV['SIGNALWIRE_API_TOKEN'] || ''
57
+ space = host || ENV['SIGNALWIRE_SPACE'] || ''
58
+
59
+ if project_id.empty? || api_token.empty? || space.empty?
60
+ raise ArgumentError,
61
+ 'project, token, and host are required. ' \
62
+ 'Provide them as arguments or set SIGNALWIRE_PROJECT_ID, ' \
63
+ 'SIGNALWIRE_API_TOKEN, and SIGNALWIRE_SPACE environment variables.'
64
+ end
65
+
66
+ @project_id = project_id
67
+ @http = HttpClient.new(project_id, api_token, space)
68
+
69
+ # Fabric API
70
+ @fabric = Namespaces::FabricNamespace.new(@http)
71
+
72
+ # Calling API
73
+ @calling = Namespaces::CallingNamespace.new(@http)
74
+
75
+ # Relay REST resources
76
+ @phone_numbers = Namespaces::PhoneNumbersResource.new(@http)
77
+ @addresses = Namespaces::AddressesResource.new(@http)
78
+ @queues = Namespaces::QueuesResource.new(@http)
79
+ @recordings = Namespaces::RecordingsResource.new(@http)
80
+ @number_groups = Namespaces::NumberGroupsResource.new(@http)
81
+ @verified_callers = Namespaces::VerifiedCallersResource.new(@http)
82
+ @sip_profile = Namespaces::SipProfileResource.new(@http)
83
+ @lookup = Namespaces::LookupResource.new(@http)
84
+ @short_codes = Namespaces::ShortCodesResource.new(@http)
85
+ @imported_numbers = Namespaces::ImportedNumbersResource.new(@http)
86
+ @mfa = Namespaces::MfaResource.new(@http)
87
+ @registry = Namespaces::RegistryNamespace.new(@http)
88
+
89
+ # Datasphere API
90
+ @datasphere = Namespaces::DatasphereNamespace.new(@http)
91
+
92
+ # Video API
93
+ @video = Namespaces::VideoNamespace.new(@http)
94
+
95
+ # Logs
96
+ @logs = Namespaces::LogsNamespace.new(@http)
97
+
98
+ # Project management
99
+ @project = Namespaces::ProjectNamespace.new(@http)
100
+
101
+ # PubSub & Chat
102
+ @pubsub = Namespaces::PubSubResource.new(@http)
103
+ @chat = Namespaces::ChatResource.new(@http)
104
+
105
+ # Compatibility (Twilio-compatible) API
106
+ @compat = Namespaces::CompatNamespace.new(@http, project_id)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+
8
+ require 'openssl'
9
+ require 'securerandom'
10
+ require 'base64'
11
+
12
+ module SignalWireAgents
13
+ module Security
14
+ # Stateless HMAC-SHA256 session manager for secure SWAIG tool tokens.
15
+ #
16
+ # Tokens are self-contained: all information needed for validation is
17
+ # encoded inside the token itself. No server-side session state is stored.
18
+ #
19
+ # mgr = SessionManager.new(token_expiry_secs: 900)
20
+ # token = mgr.create_token("lookup_order", "call-abc-123")
21
+ # mgr.validate_token("lookup_order", token, "call-abc-123") # => true
22
+ #
23
+ class SessionManager
24
+ # @param token_expiry_secs [Integer] seconds until tokens expire (minimum 1)
25
+ # @param secret_key [String, nil] hex-encoded secret; generated if omitted
26
+ def initialize(token_expiry_secs: 3600, secret_key: nil)
27
+ @token_expiry_secs = [token_expiry_secs, 1].max
28
+ @secret_key = secret_key || SecureRandom.hex(32)
29
+ end
30
+
31
+ # Create a secure, self-contained token for a function call.
32
+ #
33
+ # Token format (before Base64):
34
+ # call_id.function_name.expiry_timestamp.nonce.hmac_hex
35
+ #
36
+ # @param function_name [String]
37
+ # @param call_id [String]
38
+ # @return [String] URL-safe Base64-encoded token
39
+ def create_token(function_name, call_id)
40
+ expiry = (Time.now.to_i + @token_expiry_secs).to_s
41
+ nonce = SecureRandom.hex(8)
42
+
43
+ message = "#{call_id}:#{function_name}:#{expiry}:#{nonce}"
44
+ signature = compute_hmac(message)
45
+
46
+ token_raw = "#{call_id}.#{function_name}.#{expiry}.#{nonce}.#{signature}"
47
+ Base64.urlsafe_encode64(token_raw, padding: false)
48
+ end
49
+
50
+ # Validate a function-call token.
51
+ #
52
+ # Checks:
53
+ # 1. Correct Base64 / structure (5 dot-separated parts)
54
+ # 2. HMAC signature (timing-safe comparison)
55
+ # 3. Function name matches
56
+ # 4. Call ID matches
57
+ # 5. Token not expired
58
+ #
59
+ # @param function_name [String]
60
+ # @param token [String] the token to validate
61
+ # @param call_id [String]
62
+ # @return [Boolean]
63
+ def validate_token(function_name, token, call_id)
64
+ return false if token.nil? || token.empty?
65
+ return false if call_id.nil? || call_id.empty?
66
+
67
+ decoded = Base64.urlsafe_decode64(token)
68
+ parts = decoded.split(".")
69
+ return false unless parts.length == 5
70
+
71
+ token_call_id, token_function, token_expiry, token_nonce, token_signature = parts
72
+
73
+ # Verify function name
74
+ return false unless token_function == function_name
75
+
76
+ # Verify call ID
77
+ return false unless token_call_id == call_id
78
+
79
+ # Check expiry
80
+ expiry = Integer(token_expiry)
81
+ return false if expiry < Time.now.to_i
82
+
83
+ # Recompute HMAC and compare with timing-safe comparison
84
+ message = "#{token_call_id}:#{token_function}:#{token_expiry}:#{token_nonce}"
85
+ expected_signature = compute_hmac(message)
86
+
87
+ secure_compare(token_signature, expected_signature)
88
+ rescue ArgumentError, TypeError
89
+ # Bad Base64, bad integer, etc.
90
+ false
91
+ end
92
+
93
+ private
94
+
95
+ # Compute HMAC-SHA256 of +message+ using the instance secret key.
96
+ # @return [String] hex digest
97
+ def compute_hmac(message)
98
+ OpenSSL::HMAC.hexdigest("SHA256", @secret_key, message)
99
+ end
100
+
101
+ # Timing-safe string comparison.
102
+ #
103
+ # Uses OpenSSL.fixed_length_secure_compare when the strings are the same
104
+ # length (which they should be for hex HMAC digests). Falls back to a
105
+ # double-HMAC comparison for differing lengths.
106
+ def secure_compare(a, b)
107
+ return false if a.nil? || b.nil?
108
+
109
+ if a.bytesize == b.bytesize
110
+ OpenSSL.fixed_length_secure_compare(a, b)
111
+ else
112
+ # Different length => definitely not equal, but still constant-time
113
+ false
114
+ end
115
+ rescue NoMethodError
116
+ # Fallback for older Ruby without fixed_length_secure_compare:
117
+ # compare HMAC of both values so timing doesn't leak content.
118
+ ha = OpenSSL::HMAC.digest("SHA256", @secret_key, a.to_s)
119
+ hb = OpenSSL::HMAC.digest("SHA256", @secret_key, b.to_s)
120
+ ha == hb
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+
8
+ require 'json'
9
+ require 'thread'
10
+
11
+ module SignalWireAgents
12
+ # Multi-agent hosting on a single Rack application.
13
+ #
14
+ # server = AgentServer.new(host: '0.0.0.0', port: 3000)
15
+ # server.register(my_agent, route: '/agent1')
16
+ # server.register(my_agent2, route: '/agent2')
17
+ # server.run
18
+ #
19
+ class AgentServer
20
+ attr_reader :host, :port
21
+
22
+ # MIME types for static file serving.
23
+ MIME_TYPES = {
24
+ '.html' => 'text/html',
25
+ '.htm' => 'text/html',
26
+ '.css' => 'text/css',
27
+ '.js' => 'application/javascript',
28
+ '.json' => 'application/json',
29
+ '.png' => 'image/png',
30
+ '.jpg' => 'image/jpeg',
31
+ '.jpeg' => 'image/jpeg',
32
+ '.gif' => 'image/gif',
33
+ '.svg' => 'image/svg+xml',
34
+ '.ico' => 'image/x-icon',
35
+ '.txt' => 'text/plain',
36
+ '.xml' => 'application/xml',
37
+ '.woff' => 'font/woff',
38
+ '.woff2' => 'font/woff2',
39
+ '.ttf' => 'font/ttf',
40
+ '.eot' => 'application/vnd.ms-fontobject',
41
+ '.map' => 'application/json',
42
+ '.webp' => 'image/webp',
43
+ '.pdf' => 'application/pdf'
44
+ }.freeze
45
+
46
+ # Security headers applied to static file responses.
47
+ STATIC_SECURITY_HEADERS = {
48
+ 'x-content-type-options' => 'nosniff',
49
+ 'x-frame-options' => 'DENY',
50
+ 'cache-control' => 'no-store, no-cache, must-revalidate'
51
+ }.freeze
52
+
53
+ def initialize(host: '0.0.0.0', port: 3000)
54
+ @host = host
55
+ @port = port
56
+ @agents = {} # route => agent object
57
+ @sip_routes = {} # username => route
58
+ @static_routes = {} # route => directory
59
+ @mutex = Mutex.new
60
+ end
61
+
62
+ # Register an agent at a given route.
63
+ # @param agent [Object] an agent object (e.g. AgentBase or prefab)
64
+ # @param route [String, nil] HTTP route; defaults to agent.route if available
65
+ def register(agent, route: nil)
66
+ route ||= agent.respond_to?(:route) ? agent.route : "/#{agent.object_id}"
67
+ route = "/#{route}" unless route.start_with?('/')
68
+
69
+ @mutex.synchronize do
70
+ raise ArgumentError, "Route already registered: #{route}" if @agents.key?(route)
71
+ @agents[route] = agent
72
+ end
73
+ self
74
+ end
75
+
76
+ # Unregister an agent by route.
77
+ # @param route [String]
78
+ # @return [Object, nil] the removed agent
79
+ def unregister(route)
80
+ route = "/#{route}" unless route.start_with?('/')
81
+ @mutex.synchronize { @agents.delete(route) }
82
+ end
83
+
84
+ # Get all registered agents.
85
+ # @return [Hash] route => agent
86
+ def get_agents
87
+ @mutex.synchronize { @agents.dup }
88
+ end
89
+
90
+ # Get a specific agent by route.
91
+ # @param route [String]
92
+ # @return [Object, nil]
93
+ def get_agent(route)
94
+ route = "/#{route}" unless route.start_with?('/')
95
+ @mutex.synchronize { @agents[route] }
96
+ end
97
+
98
+ # Set up SIP-based routing.
99
+ # @param route [String] the route to handle SIP requests
100
+ # @param auto_map [Boolean] automatically map agent names as SIP usernames
101
+ def setup_sip_routing(route: '/sip', auto_map: true)
102
+ @sip_route = route
103
+ if auto_map
104
+ @mutex.synchronize do
105
+ @agents.each do |r, agent|
106
+ username = r.sub(%r{^/}, '').tr('/', '_')
107
+ @sip_routes[username] = r
108
+ end
109
+ end
110
+ end
111
+ self
112
+ end
113
+
114
+ # Register a SIP username mapping to a route.
115
+ def register_sip_username(username, route)
116
+ route = "/#{route}" unless route.start_with?('/')
117
+ @mutex.synchronize { @sip_routes[username] = route }
118
+ self
119
+ end
120
+
121
+ # Serve static files from a directory at a given route.
122
+ #
123
+ # @param directory [String] absolute or relative path to the directory
124
+ # @param route [String] the URL prefix to serve files at
125
+ # @return [self]
126
+ def serve_static_files(directory, route)
127
+ route = "/#{route}" unless route.start_with?('/')
128
+ route = route.chomp('/')
129
+ resolved = File.expand_path(directory)
130
+ raise ArgumentError, "Directory does not exist: #{resolved}" unless File.directory?(resolved)
131
+
132
+ @mutex.synchronize { @static_routes[route] = resolved }
133
+ self
134
+ end
135
+
136
+ # Run the server using WEBrick (stdlib).
137
+ def run
138
+ app = rack_app
139
+ require 'webrick'
140
+ server = WEBrick::HTTPServer.new(
141
+ Host: @host,
142
+ Port: @port,
143
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
144
+ AccessLog: []
145
+ )
146
+ server.mount('/', Rack::Handler::WEBrick, app) if defined?(Rack::Handler::WEBrick)
147
+ trap('INT') { server.shutdown }
148
+ trap('TERM') { server.shutdown }
149
+ server.start
150
+ end
151
+
152
+ # Build a Rack application that routes requests to the appropriate agent.
153
+ # @return [Proc] a Rack-compatible app
154
+ def rack_app
155
+ agents = @agents
156
+ sip_routes = @sip_routes
157
+ static_routes = @static_routes
158
+ server = self
159
+
160
+ Proc.new do |env|
161
+ path = env['PATH_INFO'] || '/'
162
+
163
+ case path
164
+ when '/health', '/healthz'
165
+ body = { status: 'ok', agents: agents.keys }.to_json
166
+ ['200', { 'Content-Type' => 'application/json' }, [body]]
167
+
168
+ when '/'
169
+ body = {
170
+ service: 'SignalWire Agent Server',
171
+ agents: agents.keys,
172
+ version: defined?(SignalWireAgents::VERSION) ? SignalWireAgents::VERSION : '1.0.0'
173
+ }.to_json
174
+ ['200', { 'Content-Type' => 'application/json' }, [body]]
175
+
176
+ else
177
+ # Check static routes first (longest prefix match)
178
+ static_result = server._try_serve_static(path, static_routes)
179
+ if static_result
180
+ static_result
181
+ else
182
+ # Find the matching agent by longest prefix match
183
+ agent = nil
184
+ matched_route = nil
185
+
186
+ agents.each do |route, a|
187
+ if path == route || path.start_with?("#{route}/")
188
+ if matched_route.nil? || route.length > matched_route.length
189
+ matched_route = route
190
+ agent = a
191
+ end
192
+ end
193
+ end
194
+
195
+ if agent
196
+ if agent.respond_to?(:call)
197
+ agent.call(env)
198
+ elsif agent.respond_to?(:rack_app)
199
+ agent.rack_app.call(env)
200
+ else
201
+ body = { agent: matched_route, status: 'registered' }.to_json
202
+ ['200', { 'Content-Type' => 'application/json' }, [body]]
203
+ end
204
+ else
205
+ body = { error: 'Not found', path: path }.to_json
206
+ ['404', { 'Content-Type' => 'application/json' }, [body]]
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ # @api private
214
+ # Attempt to serve a static file. Returns a Rack response or nil.
215
+ def _try_serve_static(path, static_routes)
216
+ matched_route = nil
217
+ matched_dir = nil
218
+
219
+ static_routes.each do |route, directory|
220
+ if path == route || path.start_with?("#{route}/")
221
+ if matched_route.nil? || route.length > matched_route.length
222
+ matched_route = route
223
+ matched_dir = directory
224
+ end
225
+ end
226
+ end
227
+
228
+ return nil unless matched_dir
229
+
230
+ # Extract the relative path after the route prefix
231
+ relative = path.sub(matched_route, '')
232
+ relative = '/index.html' if relative.empty? || relative == '/'
233
+
234
+ # Path traversal protection: reject any path containing ".."
235
+ if relative.include?('..')
236
+ body = JSON.generate({ error: 'Forbidden' })
237
+ return ['403', STATIC_SECURITY_HEADERS.merge('Content-Type' => 'application/json'), [body]]
238
+ end
239
+
240
+ file_path = File.join(matched_dir, relative)
241
+ resolved = File.expand_path(file_path)
242
+
243
+ # Ensure resolved path is still under the served directory
244
+ unless resolved.start_with?(matched_dir + '/') || resolved == matched_dir
245
+ body = JSON.generate({ error: 'Forbidden' })
246
+ return ['403', STATIC_SECURITY_HEADERS.merge('Content-Type' => 'application/json'), [body]]
247
+ end
248
+
249
+ if File.file?(resolved) && File.readable?(resolved)
250
+ ext = File.extname(resolved).downcase
251
+ content_type = MIME_TYPES[ext] || 'application/octet-stream'
252
+ content = File.binread(resolved)
253
+ headers = STATIC_SECURITY_HEADERS.merge('Content-Type' => content_type, 'Content-Length' => content.bytesize.to_s)
254
+ ['200', headers, [content]]
255
+ else
256
+ nil
257
+ end
258
+ end
259
+ end
260
+ end