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.
- checksums.yaml +7 -0
- data/README.md +79 -0
- data/bin/swaig-test +310 -0
- data/lib/signalwire_agents/agent/agent_base.rb +1171 -0
- data/lib/signalwire_agents/contexts/context_builder.rb +622 -0
- data/lib/signalwire_agents/datamap/data_map.rb +279 -0
- data/lib/signalwire_agents/logging.rb +92 -0
- data/lib/signalwire_agents/prefabs/concierge.rb +92 -0
- data/lib/signalwire_agents/prefabs/faq_bot.rb +67 -0
- data/lib/signalwire_agents/prefabs/info_gatherer.rb +79 -0
- data/lib/signalwire_agents/prefabs/receptionist.rb +74 -0
- data/lib/signalwire_agents/prefabs/survey.rb +75 -0
- data/lib/signalwire_agents/relay/action.rb +291 -0
- data/lib/signalwire_agents/relay/call.rb +523 -0
- data/lib/signalwire_agents/relay/client.rb +671 -0
- data/lib/signalwire_agents/relay/constants.rb +124 -0
- data/lib/signalwire_agents/relay/message.rb +137 -0
- data/lib/signalwire_agents/relay/relay_event.rb +670 -0
- data/lib/signalwire_agents/rest/http_client.rb +147 -0
- data/lib/signalwire_agents/rest/namespaces/addresses.rb +19 -0
- data/lib/signalwire_agents/rest/namespaces/calling.rb +179 -0
- data/lib/signalwire_agents/rest/namespaces/chat.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/compat.rb +229 -0
- data/lib/signalwire_agents/rest/namespaces/datasphere.rb +39 -0
- data/lib/signalwire_agents/rest/namespaces/fabric.rb +175 -0
- data/lib/signalwire_agents/rest/namespaces/imported_numbers.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/logs.rb +46 -0
- data/lib/signalwire_agents/rest/namespaces/lookup.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/mfa.rb +26 -0
- data/lib/signalwire_agents/rest/namespaces/number_groups.rb +32 -0
- data/lib/signalwire_agents/rest/namespaces/phone_numbers.rb +20 -0
- data/lib/signalwire_agents/rest/namespaces/project.rb +33 -0
- data/lib/signalwire_agents/rest/namespaces/pubsub.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/queues.rb +28 -0
- data/lib/signalwire_agents/rest/namespaces/recordings.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/registry.rb +67 -0
- data/lib/signalwire_agents/rest/namespaces/short_codes.rb +26 -0
- data/lib/signalwire_agents/rest/namespaces/sip_profile.rb +22 -0
- data/lib/signalwire_agents/rest/namespaces/verified_callers.rb +24 -0
- data/lib/signalwire_agents/rest/namespaces/video.rb +129 -0
- data/lib/signalwire_agents/rest/signalwire_client.rb +110 -0
- data/lib/signalwire_agents/security/session_manager.rb +124 -0
- data/lib/signalwire_agents/server/agent_server.rb +260 -0
- data/lib/signalwire_agents/skills/builtin/api_ninjas_trivia.rb +91 -0
- data/lib/signalwire_agents/skills/builtin/claude_skills.rb +92 -0
- data/lib/signalwire_agents/skills/builtin/custom_skills.rb +54 -0
- data/lib/signalwire_agents/skills/builtin/datasphere.rb +141 -0
- data/lib/signalwire_agents/skills/builtin/datasphere_serverless.rb +107 -0
- data/lib/signalwire_agents/skills/builtin/datetime.rb +97 -0
- data/lib/signalwire_agents/skills/builtin/google_maps.rb +168 -0
- data/lib/signalwire_agents/skills/builtin/info_gatherer.rb +189 -0
- data/lib/signalwire_agents/skills/builtin/joke.rb +65 -0
- data/lib/signalwire_agents/skills/builtin/math.rb +176 -0
- data/lib/signalwire_agents/skills/builtin/mcp_gateway.rb +121 -0
- data/lib/signalwire_agents/skills/builtin/native_vector_search.rb +116 -0
- data/lib/signalwire_agents/skills/builtin/play_background_file.rb +86 -0
- data/lib/signalwire_agents/skills/builtin/spider.rb +142 -0
- data/lib/signalwire_agents/skills/builtin/swml_transfer.rb +118 -0
- data/lib/signalwire_agents/skills/builtin/weather_api.rb +85 -0
- data/lib/signalwire_agents/skills/builtin/web_search.rb +123 -0
- data/lib/signalwire_agents/skills/builtin/wikipedia_search.rb +109 -0
- data/lib/signalwire_agents/skills/skill_base.rb +58 -0
- data/lib/signalwire_agents/skills/skill_manager.rb +85 -0
- data/lib/signalwire_agents/skills/skill_registry.rb +76 -0
- data/lib/signalwire_agents/swaig/function_result.rb +777 -0
- data/lib/signalwire_agents/swml/document.rb +84 -0
- data/lib/signalwire_agents/swml/schema.json +12250 -0
- data/lib/signalwire_agents/swml/schema.rb +81 -0
- data/lib/signalwire_agents/swml/service.rb +304 -0
- data/lib/signalwire_agents/version.rb +5 -0
- data/lib/signalwire_agents.rb +19 -0
- 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
|