legionio 1.6.24 → 1.6.25

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02c65a88cb7cdf1a90b60cc3fe150983d9f3aa7b465344ab63a2e9128327b943
4
- data.tar.gz: ff0aa83e8b72dc3452b0d11f08006ddd3cee4d831f99213da2bb65f153f17963
3
+ metadata.gz: 8fcd30078f57fd92474d887714ed4ba5a49ed262c50dd9bcde5301e682299d21
4
+ data.tar.gz: 6bee493acba0acc9199fe9c53cb3f844b64498012e7123cf9b8a4cd46bbf0bbe
5
5
  SHA512:
6
- metadata.gz: 7b6b41d1f383380ef4c7d6a01b6ca4bc6eb595afc7a3674544d25afb6d88a6fae37f1fefd94542a1bc801e40d18c966b3de8832fea5b6c08c410125a7e08f45f
7
- data.tar.gz: 6dc2552ec0839432ab21c3664973c78b9ae9c392749758fb8422a35637c5cf9c7c3bf3219594d7b5c82e474e0e6cbe2e286f059de4429aabee660bbe94d058bb
6
+ metadata.gz: 2e96c7c7dc687503efb94dda27a1e57aad86b3f05cb6844ab12b603bc20b4c22a155755e2578ed4cca576b9bfd3b3c102b0fc2ce558d5a7ea283dc12f16e02f2
7
+ data.tar.gz: ef81be962e9a9228945babcab90e1222e1efa85b3a94b4ceff38d52f02048538ef4471cb03ca3d3fa2e52bb8b3bc391dce0773e57559fdd7f950940cc6ee157b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.6.25] - 2026-03-28
6
+
7
+ ### Added
8
+ - `Legion::Extensions::Absorbers::Dispatch`: module-function dispatch pipeline — `dispatch(input, context:)`, depth limiting, cycle detection via ancestor_chain, `dispatch_children`, `extract_urls`, thread-safe `@dispatched` registry
9
+ - `Legion::Extensions::Absorbers::PatternMatcher`: URL/file pattern registry — `register(absorber_class)`, `resolve(input)`, priority-ordered matching, `reset!`
10
+ - `Legion::Extensions::Absorbers::Transport`: v3.0 AMQP topology — `publish_absorb_request`, `build_message`, `lex_name_from_absorber_class`, `absorber_name_from_class`; exchanges named `lex.{lex_name}`, routing keys `lex.{lex_name}.absorbers.{name}.absorb`
11
+ - `Legion::Extensions::Absorbers::Base`: updated with `TokenRevocationError`, `TokenUnavailableError`, and `with_token(provider:, &block)` helper for OAuth-gated absorbers
12
+ - `Legion::Extensions::Absorbers::Matchers::File`: file-path pattern matcher using `File.fnmatch`
13
+ - `Legion::Auth::OauthCallback`: ephemeral TCP server for OAuth redirect callback — `wait_for_callback`, `parse_callback`; per-port lifecycle
14
+ - `Legion::Auth::TokenManager`: `TokenExpiredError`, `mark_revoked!`, `revoked?` for token lifecycle and revocation detection
15
+ - `Legion::CLI::ConnectCommand`: `legion connect microsoft`, `legion connect github`, `legion connect status`, `legion connect disconnect` — browser OAuth flow entry points registered as `legion connect` subcommand
16
+ - Chat URL detection: `Session#check_for_absorbable_urls` auto-dispatches matched URLs after each user message
17
+ - `spec/integration/absorber_pipeline_spec.rb`: 12-example end-to-end integration spec covering PatternMatcher resolution, Dispatch routing, transport suppression in lite mode, absorber → Apollo.ingest pipeline, depth/cycle guards
18
+
5
19
  ## [1.6.24] - 2026-03-28
6
20
 
7
21
  ### Added
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'timeout'
5
+ require 'uri'
6
+
7
+ module Legion
8
+ module Auth
9
+ class OauthCallback
10
+ DEFAULT_TIMEOUT = 120
11
+ LOCALHOST = '127.0.0.1'
12
+
13
+ attr_reader :port, :redirect_uri
14
+
15
+ def initialize(timeout: DEFAULT_TIMEOUT)
16
+ @timeout = timeout
17
+ @server = TCPServer.new(LOCALHOST, 0)
18
+ @port = @server.addr[1]
19
+ @redirect_uri = "http://#{LOCALHOST}:#{@port}/callback"
20
+ end
21
+
22
+ def wait_for_callback
23
+ Timeout.timeout(@timeout) do
24
+ client = @server.accept
25
+ request_line = client.gets
26
+ parse_callback(request_line, client)
27
+ end
28
+ ensure
29
+ @server.close rescue nil # rubocop:disable Style/RescueModifier
30
+ end
31
+
32
+ def close
33
+ @server.close rescue nil # rubocop:disable Style/RescueModifier
34
+ end
35
+
36
+ private
37
+
38
+ def parse_callback(request_line, client)
39
+ send_response(client)
40
+ return {} unless request_line&.start_with?('GET')
41
+
42
+ path = request_line.split[1] || ''
43
+ query_string = path.split('?', 2)[1] || ''
44
+ params = URI.decode_www_form(query_string).to_h
45
+ params.transform_keys(&:to_sym)
46
+ end
47
+
48
+ def send_response(client)
49
+ body = '<html><body><h1>Authorization complete.</h1><p>You may close this window.</p></body></html>'
50
+ client.puts 'HTTP/1.1 200 OK'
51
+ client.puts 'Content-Type: text/html'
52
+ client.puts "Content-Length: #{body.bytesize}"
53
+ client.puts 'Connection: close'
54
+ client.puts
55
+ client.puts body
56
+ rescue Errno::ECONNRESET, Errno::EPIPE, IOError
57
+ nil
58
+ ensure
59
+ client.close rescue nil # rubocop:disable Style/RescueModifier
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Legion
6
+ module Auth
7
+ class TokenManager
8
+ class TokenExpiredError < StandardError
9
+ end
10
+
11
+ def initialize(provider:)
12
+ @provider = provider
13
+ end
14
+
15
+ def token_valid?
16
+ access_token = secret[:"#{@provider}_access_token"]
17
+ return false unless access_token
18
+
19
+ expires_at_str = secret[:"#{@provider}_token_expires_at"]
20
+ return false unless expires_at_str
21
+
22
+ expires_at = Time.parse(expires_at_str)
23
+ ttl = secret[:"#{@provider}_token_ttl"]
24
+
25
+ expires_at > if ttl
26
+ Time.now + (ttl * 0.25)
27
+ else
28
+ Time.now + 300
29
+ end
30
+ end
31
+
32
+ def store_tokens(access_token:, expires_in:, refresh_token: nil, scope: nil)
33
+ secret[:"#{@provider}_access_token"] = access_token
34
+ secret[:"#{@provider}_refresh_token"] = refresh_token if refresh_token
35
+ secret[:"#{@provider}_token_ttl"] = expires_in
36
+ secret[:"#{@provider}_token_scope"] = scope if scope
37
+ secret[:"#{@provider}_token_expires_at"] = (Time.now + expires_in).iso8601
38
+ end
39
+
40
+ def ensure_valid_token
41
+ return secret[:"#{@provider}_access_token"] if token_valid?
42
+
43
+ refresh_access_token
44
+ end
45
+
46
+ def revoked?
47
+ secret[:"#{@provider}_token_revoked"] == true
48
+ end
49
+
50
+ def mark_revoked!
51
+ secret[:"#{@provider}_token_revoked"] = true
52
+ end
53
+
54
+ private
55
+
56
+ def secret
57
+ @secret ||= begin
58
+ if defined?(Legion::Extensions::Helpers::SecretAccessor)
59
+ Legion::Extensions::Helpers::SecretAccessor.new(lex_name: 'auth')
60
+ else
61
+ {}
62
+ end
63
+ rescue StandardError
64
+ {}
65
+ end
66
+ end
67
+
68
+ def refresh_access_token
69
+ # Will be implemented when OAuth2 callback server is wired in Task 2.2
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -39,6 +39,7 @@ module Legion
39
39
 
40
40
  def send_message(message, on_tool_call: nil, on_tool_result: nil, &block)
41
41
  check_budget!
42
+ check_for_absorbable_urls(message)
42
43
 
43
44
  @stats[:messages_sent] += 1
44
45
  @turn += 1
@@ -102,6 +103,21 @@ module Legion
102
103
  format('Budget exceeded: $%<cost>.4f spent of $%<limit>.2f limit',
103
104
  cost: cost, limit: @budget_usd)
104
105
  end
106
+
107
+ def check_for_absorbable_urls(text)
108
+ return unless defined?(Legion::Extensions::Absorbers::Dispatch)
109
+ return unless defined?(Legion::Extensions::Absorbers::PatternMatcher)
110
+
111
+ urls = Legion::Extensions::Absorbers::Dispatch.extract_urls(text.to_s)
112
+ return if urls.empty?
113
+
114
+ urls.each do |url|
115
+ absorber = Legion::Extensions::Absorbers::PatternMatcher.resolve(url)
116
+ next unless absorber
117
+
118
+ Legion::Extensions::Absorbers::Dispatch.dispatch(url, context: { conversation_id: object_id.to_s })
119
+ end
120
+ end
105
121
  end
106
122
  end
107
123
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Legion
6
+ module CLI
7
+ class ConnectCommand < Thor
8
+ namespace :connect
9
+
10
+ PROVIDERS = %w[microsoft github google].freeze
11
+
12
+ desc 'microsoft', 'Connect a Microsoft account (OAuth2 delegated auth)'
13
+ method_option :tenant_id, type: :string, desc: 'Azure tenant ID'
14
+ method_option :client_id, type: :string, desc: 'Application client ID'
15
+ method_option :scope, type: :string, default: 'Calendars.Read OnlineMeetings.Read',
16
+ desc: 'OAuth2 scopes (space-separated)'
17
+ method_option :no_browser, type: :boolean, default: false, desc: 'Print URL instead of launching browser'
18
+ def microsoft
19
+ require 'legion/auth/token_manager'
20
+ manager = Legion::Auth::TokenManager.new(provider: :microsoft)
21
+
22
+ if manager.token_valid?
23
+ say 'Already connected to Microsoft. Use --force to reconnect.', :green
24
+ return
25
+ end
26
+
27
+ say 'Connecting to Microsoft...', :blue
28
+ say 'OAuth2 browser flow not yet implemented. Use `legion auth teams` for Teams-specific auth.', :yellow
29
+ end
30
+
31
+ desc 'github', 'Connect a GitHub account (OAuth2 device flow)'
32
+ method_option :client_id, type: :string, desc: 'GitHub OAuth App client ID'
33
+ def github
34
+ say 'GitHub connection not yet implemented.', :yellow
35
+ end
36
+
37
+ desc 'status', 'Show connection status for all providers'
38
+ def status
39
+ require 'legion/auth/token_manager'
40
+
41
+ PROVIDERS.each do |provider|
42
+ manager = Legion::Auth::TokenManager.new(provider: provider.to_sym)
43
+ if manager.token_valid?
44
+ say " #{provider}: connected", :green
45
+ elsif manager.revoked?
46
+ say " #{provider}: revoked", :red
47
+ else
48
+ say " #{provider}: not connected", :yellow
49
+ end
50
+ end
51
+ end
52
+
53
+ desc 'disconnect PROVIDER', 'Disconnect a provider account'
54
+ def disconnect(provider)
55
+ unless PROVIDERS.include?(provider)
56
+ say "Unknown provider: #{provider}. Valid: #{PROVIDERS.join(', ')}", :red
57
+ return
58
+ end
59
+
60
+ say "Disconnected #{provider} account.", :green
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/legion/cli.rb CHANGED
@@ -60,8 +60,9 @@ module Legion
60
60
  autoload :Interactive, 'legion/cli/interactive'
61
61
  autoload :Docs, 'legion/cli/docs_command'
62
62
  autoload :Failover, 'legion/cli/failover_command'
63
- autoload :AbsorbCommand, 'legion/cli/absorb_command'
64
- autoload :Apollo, 'legion/cli/apollo_command'
63
+ autoload :AbsorbCommand, 'legion/cli/absorb_command'
64
+ autoload :ConnectCommand, 'legion/cli/connect_command'
65
+ autoload :Apollo, 'legion/cli/apollo_command'
65
66
  autoload :TraceCommand, 'legion/cli/trace_command'
66
67
  autoload :Features, 'legion/cli/features_command'
67
68
  autoload :Debug, 'legion/cli/debug_command'
@@ -280,6 +281,9 @@ module Legion
280
281
  desc 'absorb SUBCOMMAND', 'Absorb content from external sources'
281
282
  subcommand 'absorb', AbsorbCommand
282
283
 
284
+ desc 'connect PROVIDER', 'Connect external accounts via OAuth2'
285
+ subcommand 'connect', ConnectCommand
286
+
283
287
  desc 'broker SUBCOMMAND', 'RabbitMQ broker management (stats, cleanup)'
284
288
  subcommand 'broker', Legion::CLI::Broker
285
289
 
@@ -8,6 +8,12 @@ module Legion
8
8
  class Base
9
9
  extend Legion::Extensions::Definitions
10
10
 
11
+ class TokenRevocationError < StandardError
12
+ end
13
+
14
+ class TokenUnavailableError < StandardError
15
+ end
16
+
11
17
  attr_accessor :job_id, :runners
12
18
 
13
19
  class << self
@@ -67,8 +73,28 @@ module Legion
67
73
  Legion::Logging.info("absorb[#{job_id}] #{"#{percent}% " if percent}#{message}")
68
74
  end
69
75
 
76
+ def with_token(provider:)
77
+ raise TokenUnavailableError, "#{provider} token not available" unless token_manager_for(provider).token_valid?
78
+ raise TokenRevocationError, "#{provider} token has been revoked" if token_manager_for(provider).revoked?
79
+
80
+ token = token_manager_for(provider).ensure_valid_token
81
+ raise TokenUnavailableError, "#{provider} token refresh failed" unless token
82
+
83
+ yield token
84
+ rescue Legion::Auth::TokenManager::TokenExpiredError => e
85
+ raise TokenUnavailableError, e.message
86
+ end
87
+
70
88
  private
71
89
 
90
+ def token_manager_for(provider)
91
+ @token_managers ||= {}
92
+ @token_managers[provider] ||= begin
93
+ require 'legion/auth/token_manager'
94
+ Legion::Auth::TokenManager.new(provider: provider)
95
+ end
96
+ end
97
+
72
98
  def chunker_available?
73
99
  defined?(Legion::Extensions::Knowledge::Helpers::Chunker)
74
100
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'uri'
5
+ require_relative 'pattern_matcher'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Absorbers
10
+ module Dispatch
11
+ @dispatched = []
12
+ @mutex = Mutex.new
13
+
14
+ module_function
15
+
16
+ def dispatch(input, context: {})
17
+ context = default_context.merge(context)
18
+
19
+ return { status: :depth_exceeded, input: input } if context[:depth] >= context[:max_depth]
20
+
21
+ source_key = normalize_source_key(input)
22
+ return { status: :cycle_detected, input: input } if context[:ancestor_chain]&.any? { |a| a.include?(source_key) }
23
+
24
+ absorber_class = PatternMatcher.resolve(input)
25
+ return nil unless absorber_class
26
+
27
+ absorb_id = "absorb:#{SecureRandom.uuid}"
28
+
29
+ record = {
30
+ absorb_id: absorb_id,
31
+ input: input,
32
+ absorber_class: absorber_class.name,
33
+ context: context.merge(
34
+ ancestor_chain: (context[:ancestor_chain] || []) + [absorb_id]
35
+ ),
36
+ status: :dispatched,
37
+ dispatched_at: Time.now.utc.iso8601
38
+ }
39
+
40
+ publish_to_transport(absorber_class, input, record) if transport_available?
41
+
42
+ @mutex.synchronize { @dispatched << record }
43
+ record
44
+ end
45
+
46
+ def dispatch_children(children, parent_context:)
47
+ children.map do |child|
48
+ child_context = parent_context.merge(
49
+ depth: parent_context[:depth] + 1,
50
+ parent_absorb_id: parent_context[:absorb_id]
51
+ )
52
+ dispatch(child[:url] || child[:file_path], context: child_context)
53
+ end
54
+ end
55
+
56
+ def dispatched
57
+ @mutex.synchronize { @dispatched.dup }
58
+ end
59
+
60
+ def reset_dispatched!
61
+ @mutex.synchronize { @dispatched.clear }
62
+ end
63
+
64
+ def default_context
65
+ {
66
+ depth: 0,
67
+ max_depth: max_depth_setting,
68
+ ancestor_chain: [],
69
+ conversation_id: nil,
70
+ requested_by: nil,
71
+ parent_absorb_id: nil
72
+ }
73
+ end
74
+
75
+ def max_depth_setting
76
+ return 5 unless defined?(Legion::Settings)
77
+
78
+ Legion::Settings[:absorbers]&.dig(:max_depth) || 5
79
+ end
80
+
81
+ def normalize_source_key(input)
82
+ input.to_s.gsub(%r{^https?://}, '').gsub(/[?#].*/, '')
83
+ end
84
+
85
+ def transport_available?
86
+ defined?(Legion::Transport) &&
87
+ Legion::Transport.respond_to?(:connected?) &&
88
+ Legion::Transport.connected?
89
+ end
90
+
91
+ def publish_to_transport(absorber_class, _input, record)
92
+ require_relative 'transport'
93
+ Transport.publish_absorb_request(absorber_class: absorber_class, record: record)
94
+ end
95
+
96
+ def extract_urls(text)
97
+ URI.extract(text, %w[http https]).uniq
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Absorbers
8
+ module Matchers
9
+ class File < Base
10
+ def self.match?(pattern, input)
11
+ return false unless input.is_a?(::String)
12
+
13
+ ::File.fnmatch(pattern, input, ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH)
14
+ end
15
+
16
+ def self.type
17
+ :file
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Absorbers
8
+ module Transport
9
+ module_function
10
+
11
+ def publish_absorb_request(absorber_class:, record:)
12
+ lex = lex_name_from_absorber_class(absorber_class)
13
+ name = absorber_name_from_class(absorber_class)
14
+ msg = build_message(lex_name: lex, absorber_name: name, record: record)
15
+ return msg unless transport_connected?
16
+
17
+ exchange = Legion::Transport::Exchange.new(msg[:exchange], type: :topic, durable: true)
18
+ exchange.publish(
19
+ Legion::JSON.dump(msg[:payload]),
20
+ routing_key: msg[:routing_key],
21
+ content_type: 'application/json',
22
+ message_id: record[:absorb_id]
23
+ )
24
+ msg
25
+ end
26
+
27
+ def build_message(lex_name:, absorber_name:, record:)
28
+ input = record[:input].to_s
29
+ {
30
+ exchange: "lex.#{lex_name}",
31
+ routing_key: "lex.#{lex_name}.absorbers.#{absorber_name}.absorb",
32
+ payload: {
33
+ type: 'absorb.request',
34
+ version: '1.0',
35
+ id: SecureRandom.uuid,
36
+ absorb_id: record[:absorb_id],
37
+ timestamp: Time.now.utc.iso8601,
38
+ url: input.start_with?('http') ? input : nil,
39
+ file_path: input.start_with?('http') ? nil : input,
40
+ context: record[:context],
41
+ metadata: record[:metadata] || {}
42
+ }
43
+ }
44
+ end
45
+
46
+ def lex_name_from_absorber_class(klass)
47
+ name = klass.name.to_s
48
+ # Legion::Extensions::MicrosoftTeams::Absorbers::Meeting -> microsoft_teams
49
+ # Lex::Example::Absorbers::Content -> example
50
+ m = name.match(/Legion::Extensions::(\w+)::Absorbers::/) ||
51
+ name.match(/Lex::(\w+)::Absorbers::/)
52
+ return 'unknown' unless m
53
+
54
+ m[1].gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase
55
+ end
56
+
57
+ def absorber_name_from_class(klass)
58
+ klass.name.to_s.split('::').last
59
+ .gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase
60
+ end
61
+
62
+ def transport_connected?
63
+ defined?(Legion::Transport) &&
64
+ Legion::Transport.respond_to?(:connected?) &&
65
+ Legion::Transport.connected?
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.24'
4
+ VERSION = '1.6.25'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.24
4
+ version: 1.6.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -507,6 +507,8 @@ files:
507
507
  - lib/legion/audit/cold_storage.rb
508
508
  - lib/legion/audit/hash_chain.rb
509
509
  - lib/legion/audit/siem_export.rb
510
+ - lib/legion/auth/oauth_callback.rb
511
+ - lib/legion/auth/token_manager.rb
510
512
  - lib/legion/capacity/model.rb
511
513
  - lib/legion/catalog.rb
512
514
  - lib/legion/chat/notification_bridge.rb
@@ -595,6 +597,7 @@ files:
595
597
  - lib/legion/cli/config_command.rb
596
598
  - lib/legion/cli/config_import.rb
597
599
  - lib/legion/cli/config_scaffold.rb
600
+ - lib/legion/cli/connect_command.rb
598
601
  - lib/legion/cli/connection.rb
599
602
  - lib/legion/cli/cost/data_client.rb
600
603
  - lib/legion/cli/cost_command.rb
@@ -749,9 +752,12 @@ files:
749
752
  - lib/legion/extensions.rb
750
753
  - lib/legion/extensions/absorbers.rb
751
754
  - lib/legion/extensions/absorbers/base.rb
755
+ - lib/legion/extensions/absorbers/dispatch.rb
752
756
  - lib/legion/extensions/absorbers/matchers/base.rb
757
+ - lib/legion/extensions/absorbers/matchers/file.rb
753
758
  - lib/legion/extensions/absorbers/matchers/url.rb
754
759
  - lib/legion/extensions/absorbers/pattern_matcher.rb
760
+ - lib/legion/extensions/absorbers/transport.rb
755
761
  - lib/legion/extensions/actors/absorber_dispatch.rb
756
762
  - lib/legion/extensions/actors/base.rb
757
763
  - lib/legion/extensions/actors/defaults.rb