legionio 1.6.31 → 1.6.32
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 +4 -4
- data/CHANGELOG.md +7 -0
- data/lib/legion/api/absorbers.rb +37 -0
- data/lib/legion/api/auth_teams.rb +157 -0
- data/lib/legion/api/logs.rb +89 -0
- data/lib/legion/api.rb +6 -0
- data/lib/legion/cli/absorb_command.rb +47 -10
- data/lib/legion/cli/auth_command.rb +44 -41
- data/lib/legion/cli/connect_command.rb +2 -10
- data/lib/legion/cli/error_forwarder.rb +64 -0
- data/lib/legion/cli.rb +6 -0
- data/lib/legion/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 382eadc81bfd7449fe1d3d8c5c9e833795cb17deaab9d6b00c04a0d10508ad88
|
|
4
|
+
data.tar.gz: c3df12bdc9300f8c1cf4ace607b623140286c78c7615b35f774809c5ecb7e53e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb50b2ab0354df1dbcf053bb6794944ec29265c482bc274dafdc2c42a288e0349a187d07ff5172ad36ed203729a6714e5c2a9601e032c3592e1f8df22276ce39
|
|
7
|
+
data.tar.gz: c0a172f265bd7bf5e07bf34460d7f9a5b7be2b872c11e21f5de47e7354a67a0e4e425a7b3ef70d9bc9b54c61ccb920f820ab9b33ab059a38ff9977f0852f560d
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.6.32] - 2026-03-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `POST /api/logs` endpoint (`Routes::Logs`) — accepts `error`/`warn` level messages from CLI, normalizes with server-side metadata (timestamp, node, legion_versions, ruby_version, pid), computes `error_fingerprint` via `EventBuilder.fingerprint` when `exception_class` is present, and publishes to the `legion.logging` exchange with routing key `legion.logging.exception.{level}.cli.{source}` or `legion.logging.log.{level}.cli.{source}`
|
|
9
|
+
- `Legion::CLI::ErrorForwarder` module — fire-and-forget HTTP helper that POSTs CLI errors/warnings to the daemon API; silently swallows all failures so daemon unavailability never crashes the CLI
|
|
10
|
+
- `ErrorForwarder.forward_error` wired into both rescue blocks in `CLI::Main.start` (fires before `exit(1)`)
|
|
11
|
+
|
|
5
12
|
## [1.6.31] - 2026-03-28
|
|
6
13
|
|
|
7
14
|
### Fixed
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module Routes
|
|
6
|
+
module Absorbers
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
app.get '/api/absorbers' do
|
|
9
|
+
patterns = Legion::Extensions::Absorbers::PatternMatcher.list
|
|
10
|
+
items = patterns.map do |p|
|
|
11
|
+
{
|
|
12
|
+
type: p[:type],
|
|
13
|
+
value: p[:value],
|
|
14
|
+
priority: p[:priority],
|
|
15
|
+
description: p[:description],
|
|
16
|
+
absorber_class: p[:absorber_class]&.name
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
json_response(items)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
app.get '/api/absorbers/resolve' do
|
|
23
|
+
input = params[:url] || params[:input]
|
|
24
|
+
halt 400, json_error('missing_param', 'url parameter is required') unless input
|
|
25
|
+
|
|
26
|
+
absorber = Legion::Extensions::Absorbers::PatternMatcher.resolve(input)
|
|
27
|
+
json_response({
|
|
28
|
+
input: input,
|
|
29
|
+
match: !absorber.nil?,
|
|
30
|
+
absorber: absorber&.name
|
|
31
|
+
})
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module Routes
|
|
8
|
+
module AuthTeams
|
|
9
|
+
# In-memory pending auth states (state -> { verifier:, created_at:, result: })
|
|
10
|
+
@pending = {}
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
attr_reader :pending, :mutex
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.registered(app)
|
|
18
|
+
register_store_helper(app)
|
|
19
|
+
register_authorize(app)
|
|
20
|
+
register_status(app)
|
|
21
|
+
register_callback(app)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.register_authorize(app)
|
|
25
|
+
app.post '/api/auth/teams/authorize' do
|
|
26
|
+
teams_settings = Legion::Settings[:microsoft_teams] || {}
|
|
27
|
+
auth_settings = teams_settings[:auth] || {}
|
|
28
|
+
|
|
29
|
+
tenant_id = teams_settings[:tenant_id] || auth_settings[:tenant_id]
|
|
30
|
+
client_id = teams_settings[:client_id] || auth_settings[:client_id]
|
|
31
|
+
|
|
32
|
+
halt 422, json_error('missing_config', 'microsoft_teams.tenant_id and client_id required', status_code: 422) unless tenant_id && client_id
|
|
33
|
+
|
|
34
|
+
body = parse_request_body
|
|
35
|
+
delegated = auth_settings[:delegated] || {}
|
|
36
|
+
scopes = body[:scopes] || delegated[:scopes] ||
|
|
37
|
+
'OnlineMeetings.Read OnlineMeetingTranscript.Read.All offline_access'
|
|
38
|
+
|
|
39
|
+
state = SecureRandom.hex(32)
|
|
40
|
+
verifier = SecureRandom.urlsafe_base64(32)
|
|
41
|
+
challenge = Base64.urlsafe_encode64(
|
|
42
|
+
Digest::SHA256.digest(verifier), padding: false
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
port = Legion::Settings.dig(:api, :port) || 4567
|
|
46
|
+
redirect_uri = "http://127.0.0.1:#{port}/api/auth/teams/callback"
|
|
47
|
+
|
|
48
|
+
authorize_url = "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/authorize?" \
|
|
49
|
+
"client_id=#{client_id}&response_type=code&redirect_uri=#{::URI.encode_www_form_component(redirect_uri)}" \
|
|
50
|
+
"&scope=#{::URI.encode_www_form_component(scopes)}" \
|
|
51
|
+
"&state=#{state}&code_challenge=#{challenge}&code_challenge_method=S256"
|
|
52
|
+
|
|
53
|
+
AuthTeams.mutex.synchronize do
|
|
54
|
+
AuthTeams.pending[state] = { verifier: verifier, created_at: Time.now, result: nil,
|
|
55
|
+
tenant_id: tenant_id, client_id: client_id,
|
|
56
|
+
redirect_uri: redirect_uri, scopes: scopes }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
json_response({ authorize_url: authorize_url, state: state })
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.register_status(app)
|
|
64
|
+
app.get '/api/auth/teams/status' do
|
|
65
|
+
state = params[:state]
|
|
66
|
+
halt 422, json_error('missing_state', 'state parameter required', status_code: 422) unless state
|
|
67
|
+
|
|
68
|
+
entry = AuthTeams.mutex.synchronize { AuthTeams.pending[state] }
|
|
69
|
+
halt 404, json_error('unknown_state', 'no pending auth for this state', status_code: 404) unless entry
|
|
70
|
+
|
|
71
|
+
if entry[:result]
|
|
72
|
+
AuthTeams.mutex.synchronize { AuthTeams.pending.delete(state) }
|
|
73
|
+
json_response(entry[:result])
|
|
74
|
+
else
|
|
75
|
+
json_response({ authenticated: false, waiting: true })
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.register_callback(app)
|
|
81
|
+
app.get '/api/auth/teams/callback' do
|
|
82
|
+
code = params[:code]
|
|
83
|
+
state = params[:state]
|
|
84
|
+
error = params[:error]
|
|
85
|
+
|
|
86
|
+
entry = AuthTeams.mutex.synchronize { AuthTeams.pending[state] }
|
|
87
|
+
|
|
88
|
+
if error || !entry
|
|
89
|
+
msg = error || 'unknown state'
|
|
90
|
+
AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: msg } } if entry
|
|
91
|
+
content_type :html
|
|
92
|
+
return '<html><body><h2>Authentication failed.</h2><p>You can close this tab.</p></body></html>'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Exchange code for token
|
|
96
|
+
require 'net/http'
|
|
97
|
+
token_uri = ::URI.parse("https://login.microsoftonline.com/#{entry[:tenant_id]}/oauth2/v2.0/token")
|
|
98
|
+
token_response = ::Net::HTTP.post_form(token_uri, {
|
|
99
|
+
'client_id' => entry[:client_id],
|
|
100
|
+
'grant_type' => 'authorization_code',
|
|
101
|
+
'code' => code,
|
|
102
|
+
'redirect_uri' => entry[:redirect_uri],
|
|
103
|
+
'code_verifier' => entry[:verifier],
|
|
104
|
+
'scope' => entry[:scopes]
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
token_body = Legion::JSON.load(token_response.body)
|
|
108
|
+
|
|
109
|
+
if token_body[:access_token]
|
|
110
|
+
# Store token via TokenCache if available
|
|
111
|
+
store_teams_token(token_body, entry[:scopes])
|
|
112
|
+
AuthTeams.mutex.synchronize { entry[:result] = { authenticated: true } }
|
|
113
|
+
content_type :html
|
|
114
|
+
'<html><body><h2>Authentication successful!</h2><p>You can close this tab.</p></body></html>'
|
|
115
|
+
else
|
|
116
|
+
err = token_body[:error_description] || token_body[:error] || 'token exchange failed'
|
|
117
|
+
Legion::Logging.error "Teams OAuth token exchange failed: #{err}" if defined?(Legion::Logging)
|
|
118
|
+
AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: err } }
|
|
119
|
+
content_type :html
|
|
120
|
+
"<html><body><h2>Authentication failed.</h2><p>#{err}</p></body></html>"
|
|
121
|
+
end
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
Legion::Logging.error "Teams OAuth callback error: #{e.message}" if defined?(Legion::Logging)
|
|
124
|
+
AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: e.message } } if entry
|
|
125
|
+
content_type :html
|
|
126
|
+
'<html><body><h2>Authentication error.</h2><p>Check daemon logs.</p></body></html>'
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
module TeamsTokenHelper
|
|
131
|
+
def store_teams_token(token_body, scopes)
|
|
132
|
+
require 'legion/extensions/microsoft_teams/helpers/token_cache'
|
|
133
|
+
cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
|
|
134
|
+
cache.store_delegated_token(
|
|
135
|
+
access_token: token_body[:access_token],
|
|
136
|
+
refresh_token: token_body[:refresh_token],
|
|
137
|
+
expires_in: token_body[:expires_in] || 3600,
|
|
138
|
+
scopes: scopes
|
|
139
|
+
)
|
|
140
|
+
cache.save_to_vault
|
|
141
|
+
Legion::Logging.info 'Teams delegated token stored' if defined?(Legion::Logging)
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
Legion::Logging.warn "Failed to store Teams token: #{e.message}" if defined?(Legion::Logging)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.register_store_helper(app)
|
|
148
|
+
app.helpers TeamsTokenHelper
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class << self
|
|
152
|
+
private :register_authorize, :register_status, :register_callback, :register_store_helper
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module Routes
|
|
6
|
+
module Logs
|
|
7
|
+
VALID_LEVELS = %w[error warn].freeze
|
|
8
|
+
|
|
9
|
+
def self.registered(app)
|
|
10
|
+
register_ingest(app)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.register_ingest(app)
|
|
14
|
+
app.post '/api/logs' do
|
|
15
|
+
body = parse_request_body
|
|
16
|
+
Legion::API::Routes::Logs.validate_log_request!(self, body)
|
|
17
|
+
|
|
18
|
+
level = body[:level].to_s
|
|
19
|
+
source = body[:source].to_s.then { |s| s.empty? ? 'unknown' : s }
|
|
20
|
+
payload = Legion::API::Routes::Logs.build_log_payload(body, level, source)
|
|
21
|
+
key = Legion::API::Routes::Logs.routing_key_for(body, level, source)
|
|
22
|
+
|
|
23
|
+
Legion::Transport::Messages::Dynamic.new(
|
|
24
|
+
exchange: 'legion.logging', routing_key: key, **payload
|
|
25
|
+
).publish
|
|
26
|
+
|
|
27
|
+
json_response({ published: true, routing_key: key }, status_code: 201)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Legion::Logging.error "API POST /api/logs: #{e.class} - #{e.message}" if defined?(Legion::Logging)
|
|
30
|
+
halt 500, json_error('publish_error', e.message, status_code: 500)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.validate_log_request!(ctx, body)
|
|
35
|
+
unless VALID_LEVELS.include?(body[:level].to_s)
|
|
36
|
+
Legion::Logging.warn 'API POST /api/logs returned 422: level must be error or warn' if defined?(Legion::Logging)
|
|
37
|
+
ctx.halt 422, ctx.json_error('invalid_level', 'level must be "error" or "warn"', status_code: 422)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
return unless body[:message].to_s.strip.empty?
|
|
41
|
+
|
|
42
|
+
Legion::Logging.warn 'API POST /api/logs returned 422: message is required' if defined?(Legion::Logging)
|
|
43
|
+
ctx.halt 422, ctx.json_error('missing_field', 'message is required', status_code: 422)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.build_log_payload(body, level, source)
|
|
47
|
+
payload = {
|
|
48
|
+
level: level,
|
|
49
|
+
message: body[:message].to_s,
|
|
50
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
51
|
+
node: Legion::Settings[:client][:name],
|
|
52
|
+
legion_versions: Legion::Logging::EventBuilder.send(:legion_versions),
|
|
53
|
+
ruby_version: "#{RUBY_VERSION} #{RUBY_PLATFORM}",
|
|
54
|
+
pid: ::Process.pid,
|
|
55
|
+
component_type: body[:component_type].to_s.then { |t| t.empty? ? 'cli' : t },
|
|
56
|
+
source: source
|
|
57
|
+
}
|
|
58
|
+
payload[:exception_class] = body[:exception_class] if body[:exception_class]
|
|
59
|
+
payload[:backtrace] = body[:backtrace] if body[:backtrace]
|
|
60
|
+
payload[:command] = body[:command] if body[:command]
|
|
61
|
+
payload[:error_fingerprint] = fingerprint_for(body, payload) if body[:exception_class]
|
|
62
|
+
payload
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.fingerprint_for(body, payload)
|
|
66
|
+
Legion::Logging::EventBuilder.fingerprint(
|
|
67
|
+
exception_class: body[:exception_class].to_s,
|
|
68
|
+
message: body[:message].to_s,
|
|
69
|
+
caller_file: '',
|
|
70
|
+
caller_line: 0,
|
|
71
|
+
caller_function: '',
|
|
72
|
+
gem_name: '',
|
|
73
|
+
component_type: payload[:component_type],
|
|
74
|
+
backtrace: Array(body[:backtrace])
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.routing_key_for(body, level, source)
|
|
79
|
+
kind = body[:exception_class] ? 'exception' : 'log'
|
|
80
|
+
"legion.logging.#{kind}.#{level}.cli.#{source}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
private :register_ingest, :fingerprint_for
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/legion/api.rb
CHANGED
|
@@ -26,6 +26,7 @@ require_relative 'api/gaia'
|
|
|
26
26
|
require_relative 'api/openapi'
|
|
27
27
|
require_relative 'api/rbac'
|
|
28
28
|
require_relative 'api/auth'
|
|
29
|
+
require_relative 'api/auth_teams'
|
|
29
30
|
require_relative 'api/auth_worker'
|
|
30
31
|
require_relative 'api/auth_human'
|
|
31
32
|
require_relative 'api/auth_saml'
|
|
@@ -44,7 +45,9 @@ require_relative 'api/apollo'
|
|
|
44
45
|
require_relative 'api/costs'
|
|
45
46
|
require_relative 'api/traces'
|
|
46
47
|
require_relative 'api/stats'
|
|
48
|
+
require_relative 'api/absorbers'
|
|
47
49
|
require_relative 'api/codegen'
|
|
50
|
+
require_relative 'api/logs'
|
|
48
51
|
require_relative 'api/router'
|
|
49
52
|
require_relative 'api/library_routes'
|
|
50
53
|
require_relative 'api/sync_dispatch'
|
|
@@ -152,6 +155,7 @@ module Legion
|
|
|
152
155
|
register Routes::Gaia unless router.library_names.include?('gaia')
|
|
153
156
|
register Routes::Rbac unless router.library_names.include?('rbac')
|
|
154
157
|
register Routes::Auth
|
|
158
|
+
register Routes::AuthTeams
|
|
155
159
|
register Routes::AuthWorker
|
|
156
160
|
register Routes::AuthHuman
|
|
157
161
|
register Routes::AuthSaml
|
|
@@ -169,7 +173,9 @@ module Legion
|
|
|
169
173
|
register Routes::Costs
|
|
170
174
|
register Routes::Traces
|
|
171
175
|
register Routes::Stats
|
|
176
|
+
register Routes::Absorbers
|
|
172
177
|
register Routes::Codegen
|
|
178
|
+
register Routes::Logs
|
|
173
179
|
register Routes::GraphQL if defined?(Routes::GraphQL)
|
|
174
180
|
|
|
175
181
|
use Legion::API::Middleware::RequestLogger
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
4
|
-
require '
|
|
5
|
-
require '
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
6
|
|
|
7
7
|
module Legion
|
|
8
8
|
module CLI
|
|
@@ -18,6 +18,10 @@ module Legion
|
|
|
18
18
|
option :scope, type: :string, default: 'global', desc: 'Knowledge scope (global/local/all)'
|
|
19
19
|
def url(input_url)
|
|
20
20
|
Connection.ensure_settings
|
|
21
|
+
require 'legion/extensions/absorbers'
|
|
22
|
+
require 'legion/extensions/absorbers/pattern_matcher'
|
|
23
|
+
require 'legion/extensions/actors/absorber_dispatch'
|
|
24
|
+
|
|
21
25
|
out = formatter
|
|
22
26
|
result = Legion::Extensions::Actors::AbsorberDispatch.dispatch(
|
|
23
27
|
input: input_url,
|
|
@@ -36,9 +40,8 @@ module Legion
|
|
|
36
40
|
|
|
37
41
|
desc 'list', 'List registered absorber patterns'
|
|
38
42
|
def list
|
|
39
|
-
Connection.ensure_settings
|
|
40
43
|
out = formatter
|
|
41
|
-
patterns =
|
|
44
|
+
patterns = fetch_absorbers
|
|
42
45
|
|
|
43
46
|
if options[:json]
|
|
44
47
|
out.json(patterns.map { |p| { type: p[:type], value: p[:value], description: p[:description] } })
|
|
@@ -56,14 +59,13 @@ module Legion
|
|
|
56
59
|
|
|
57
60
|
desc 'resolve URL', 'Show which absorber would handle a URL (dry run)'
|
|
58
61
|
def resolve(input_url)
|
|
59
|
-
Connection.ensure_settings
|
|
60
62
|
out = formatter
|
|
61
|
-
|
|
63
|
+
result = fetch_resolve(input_url)
|
|
62
64
|
|
|
63
65
|
if options[:json]
|
|
64
|
-
out.json(
|
|
65
|
-
elsif
|
|
66
|
-
out.success("#{input_url} -> #{absorber
|
|
66
|
+
out.json(result)
|
|
67
|
+
elsif result[:match]
|
|
68
|
+
out.success("#{input_url} -> #{result[:absorber]}")
|
|
67
69
|
else
|
|
68
70
|
out.warn("No absorber registered for: #{input_url}")
|
|
69
71
|
end
|
|
@@ -73,6 +75,41 @@ module Legion
|
|
|
73
75
|
def formatter
|
|
74
76
|
@formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
|
|
75
77
|
end
|
|
78
|
+
|
|
79
|
+
def api_port
|
|
80
|
+
Connection.ensure_settings
|
|
81
|
+
api_settings = Legion::Settings[:api]
|
|
82
|
+
(api_settings.is_a?(Hash) && api_settings[:port]) || 4567
|
|
83
|
+
rescue StandardError
|
|
84
|
+
4567
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def api_get(path)
|
|
88
|
+
uri = URI("http://127.0.0.1:#{api_port}#{path}")
|
|
89
|
+
response = Net::HTTP.get_response(uri)
|
|
90
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
91
|
+
formatter.error("API returned #{response.code} for #{path}")
|
|
92
|
+
raise SystemExit, 1
|
|
93
|
+
end
|
|
94
|
+
body = ::JSON.parse(response.body, symbolize_names: true)
|
|
95
|
+
body[:data]
|
|
96
|
+
rescue Errno::ECONNREFUSED
|
|
97
|
+
formatter.error('Daemon not running. Start with: legionio start')
|
|
98
|
+
raise SystemExit, 1
|
|
99
|
+
rescue SystemExit
|
|
100
|
+
raise
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
formatter.error("API request failed: #{e.message}")
|
|
103
|
+
raise SystemExit, 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def fetch_absorbers
|
|
107
|
+
api_get('/api/absorbers')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fetch_resolve(input_url)
|
|
111
|
+
api_get("/api/absorbers/resolve?url=#{URI.encode_www_form_component(input_url)}")
|
|
112
|
+
end
|
|
76
113
|
end
|
|
77
114
|
end
|
|
78
115
|
end
|
|
@@ -20,58 +20,61 @@ module Legion
|
|
|
20
20
|
method_option :scopes, type: :string, desc: 'OAuth scopes to request'
|
|
21
21
|
def teams
|
|
22
22
|
out = formatter
|
|
23
|
-
|
|
24
|
-
Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader)
|
|
23
|
+
Connection.ensure_settings
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
port = begin
|
|
26
|
+
Legion::Settings.dig(:api, :port) || 4567
|
|
27
|
+
rescue StandardError
|
|
28
|
+
4567
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
out.header('Microsoft Teams Authentication')
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
scopes = options[:scopes] || delegated[:scopes] ||
|
|
32
|
-
'OnlineMeetings.Read OnlineMeetingTranscript.Read.All offline_access'
|
|
33
|
+
require 'net/http'
|
|
34
|
+
require 'legion/json'
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
# Ask the daemon for the authorize URL
|
|
37
|
+
uri = ::URI.parse("http://127.0.0.1:#{port}/api/auth/teams/authorize")
|
|
38
|
+
params = {}
|
|
39
|
+
params[:scopes] = options[:scopes] if options[:scopes]
|
|
40
|
+
response = ::Net::HTTP.post(uri, Legion::JSON.dump(params), 'Content-Type' => 'application/json')
|
|
41
|
+
parsed = Legion::JSON.load(response.body)
|
|
42
|
+
|
|
43
|
+
unless response.code.to_i == 200 && parsed.dig(:data, :authorize_url)
|
|
44
|
+
error_msg = parsed.dig(:error, :message) || "HTTP #{response.code}"
|
|
45
|
+
out.error("Daemon returned: #{error_msg}")
|
|
36
46
|
raise SystemExit, 1
|
|
37
47
|
end
|
|
38
48
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
url = parsed[:data][:authorize_url]
|
|
50
|
+
out.info('Opening browser for Microsoft login...')
|
|
51
|
+
system('open', url) || out.warn("Open this URL manually:\n #{url}")
|
|
52
|
+
out.info('Waiting for callback on daemon...')
|
|
53
|
+
|
|
54
|
+
# Poll daemon for auth result
|
|
55
|
+
poll_uri = ::URI.parse("http://127.0.0.1:#{port}/api/auth/teams/status?state=#{parsed.dig(:data, :state)}")
|
|
56
|
+
30.times do
|
|
57
|
+
sleep 2
|
|
58
|
+
poll_response = ::Net::HTTP.get_response(poll_uri)
|
|
59
|
+
poll_data = Legion::JSON.load(poll_response.body)
|
|
60
|
+
|
|
61
|
+
if poll_data.dig(:data, :authenticated)
|
|
62
|
+
out.success('Authentication successful! Token stored by daemon.')
|
|
63
|
+
return
|
|
64
|
+
end
|
|
45
65
|
|
|
46
|
-
|
|
47
|
-
result = browser_auth.authenticate
|
|
66
|
+
next unless poll_data.dig(:data, :error)
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
out.error("Authentication failed: #{result[:error]} - #{result[:description]}")
|
|
68
|
+
out.error("Authentication failed: #{poll_data[:data][:error]}")
|
|
51
69
|
raise SystemExit, 1
|
|
52
70
|
end
|
|
53
71
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
access_token: body['access_token'],
|
|
61
|
-
refresh_token: body['refresh_token'],
|
|
62
|
-
expires_in: body['expires_in'] || 3600,
|
|
63
|
-
scopes: scopes
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
if cache.save_to_vault
|
|
67
|
-
out.success('Token saved to Vault')
|
|
68
|
-
else
|
|
69
|
-
out.warn('Could not save token to Vault (Vault may not be connected)')
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
return unless options[:json]
|
|
73
|
-
|
|
74
|
-
out.json({ authenticated: true, scopes: scopes, expires_in: body['expires_in'] })
|
|
72
|
+
out.error('Timed out waiting for authentication (60s)')
|
|
73
|
+
raise SystemExit, 1
|
|
74
|
+
rescue Errno::ECONNREFUSED
|
|
75
|
+
out = formatter
|
|
76
|
+
out.error('Daemon not running. Start it first: legionio start')
|
|
77
|
+
raise SystemExit, 1
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
desc 'kerberos', 'Authenticate using Kerberos TGT from your workstation'
|
|
@@ -16,16 +16,8 @@ module Legion
|
|
|
16
16
|
desc: 'OAuth2 scopes (space-separated)'
|
|
17
17
|
method_option :no_browser, type: :boolean, default: false, desc: 'Print URL instead of launching browser'
|
|
18
18
|
def microsoft
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
19
|
+
say 'Delegating to Teams OAuth2 browser auth...', :blue
|
|
20
|
+
Legion::CLI::Auth.start(['teams'] + ARGV.select { |a| a.start_with?('--') })
|
|
29
21
|
end
|
|
30
22
|
|
|
31
23
|
desc 'github', 'Connect a GitHub account (OAuth2 device flow)'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module CLI
|
|
7
|
+
module ErrorForwarder
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def forward_error(exception, command: nil)
|
|
11
|
+
payload = {
|
|
12
|
+
level: 'error',
|
|
13
|
+
message: exception.message.to_s,
|
|
14
|
+
exception_class: exception.class.name,
|
|
15
|
+
backtrace: Array(exception.backtrace).first(10),
|
|
16
|
+
component_type: 'cli',
|
|
17
|
+
source: ::File.basename($PROGRAM_NAME)
|
|
18
|
+
}
|
|
19
|
+
payload[:command] = command if command
|
|
20
|
+
post_to_daemon(payload)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
# silently swallow — forwarding must never crash the CLI
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def forward_warning(message, command: nil)
|
|
26
|
+
payload = {
|
|
27
|
+
level: 'warn',
|
|
28
|
+
message: message.to_s,
|
|
29
|
+
component_type: 'cli',
|
|
30
|
+
source: ::File.basename($PROGRAM_NAME)
|
|
31
|
+
}
|
|
32
|
+
payload[:command] = command if command
|
|
33
|
+
post_to_daemon(payload)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
# silently swallow — forwarding must never crash the CLI
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def post_to_daemon(payload)
|
|
39
|
+
port = daemon_port
|
|
40
|
+
uri = URI("http://localhost:#{port}/api/logs")
|
|
41
|
+
|
|
42
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
43
|
+
http.open_timeout = 2
|
|
44
|
+
http.read_timeout = 2
|
|
45
|
+
|
|
46
|
+
request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
|
|
47
|
+
request.body = ::JSON.generate(payload)
|
|
48
|
+
|
|
49
|
+
http.request(request)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def daemon_port
|
|
55
|
+
require 'legion/settings'
|
|
56
|
+
Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader)
|
|
57
|
+
api_settings = Legion::Settings[:api]
|
|
58
|
+
(api_settings.is_a?(Hash) && api_settings[:port]) || 4567
|
|
59
|
+
rescue StandardError
|
|
60
|
+
4567
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/legion/cli.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'legion/cli/error'
|
|
|
6
6
|
require 'legion/cli/error_handler'
|
|
7
7
|
require 'legion/cli/output'
|
|
8
8
|
require 'legion/cli/connection'
|
|
9
|
+
require 'legion/cli/error_forwarder'
|
|
9
10
|
|
|
10
11
|
module Legion
|
|
11
12
|
module CLI
|
|
@@ -92,12 +93,14 @@ module Legion
|
|
|
92
93
|
Legion::Logging.error("CLI::Main.start CLI error: #{e.message}") if defined?(Legion::Logging)
|
|
93
94
|
formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color'))
|
|
94
95
|
ErrorHandler.format_error(e, formatter)
|
|
96
|
+
ErrorForwarder.forward_error(e, command: given_args.join(' '))
|
|
95
97
|
exit(1)
|
|
96
98
|
rescue StandardError => e
|
|
97
99
|
Legion::Logging.error("CLI::Main.start unexpected error: #{e.message}") if defined?(Legion::Logging)
|
|
98
100
|
wrapped = ErrorHandler.wrap(e)
|
|
99
101
|
formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color'))
|
|
100
102
|
ErrorHandler.format_error(wrapped, formatter)
|
|
103
|
+
ErrorForwarder.forward_error(e, command: given_args.join(' '))
|
|
101
104
|
exit(1)
|
|
102
105
|
end
|
|
103
106
|
|
|
@@ -282,6 +285,9 @@ module Legion
|
|
|
282
285
|
desc 'absorb SUBCOMMAND', 'Absorb content from external sources'
|
|
283
286
|
subcommand 'absorb', AbsorbCommand
|
|
284
287
|
|
|
288
|
+
desc 'auth SUBCOMMAND', 'Authenticate with external services (Teams, Kerberos)'
|
|
289
|
+
subcommand 'auth', Auth
|
|
290
|
+
|
|
285
291
|
desc 'connect PROVIDER', 'Connect external accounts via OAuth2'
|
|
286
292
|
subcommand 'connect', ConnectCommand
|
|
287
293
|
|
data/lib/legion/version.rb
CHANGED
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.
|
|
4
|
+
version: 1.6.32
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -438,12 +438,14 @@ files:
|
|
|
438
438
|
- lib/legion.rb
|
|
439
439
|
- lib/legion/alerts.rb
|
|
440
440
|
- lib/legion/api.rb
|
|
441
|
+
- lib/legion/api/absorbers.rb
|
|
441
442
|
- lib/legion/api/acp.rb
|
|
442
443
|
- lib/legion/api/apollo.rb
|
|
443
444
|
- lib/legion/api/audit.rb
|
|
444
445
|
- lib/legion/api/auth.rb
|
|
445
446
|
- lib/legion/api/auth_human.rb
|
|
446
447
|
- lib/legion/api/auth_saml.rb
|
|
448
|
+
- lib/legion/api/auth_teams.rb
|
|
447
449
|
- lib/legion/api/auth_worker.rb
|
|
448
450
|
- lib/legion/api/capacity.rb
|
|
449
451
|
- lib/legion/api/catalog.rb
|
|
@@ -471,6 +473,7 @@ files:
|
|
|
471
473
|
- lib/legion/api/lex_dispatch.rb
|
|
472
474
|
- lib/legion/api/library_routes.rb
|
|
473
475
|
- lib/legion/api/llm.rb
|
|
476
|
+
- lib/legion/api/logs.rb
|
|
474
477
|
- lib/legion/api/marketplace.rb
|
|
475
478
|
- lib/legion/api/metrics.rb
|
|
476
479
|
- lib/legion/api/middleware/api_version.rb
|
|
@@ -623,6 +626,7 @@ files:
|
|
|
623
626
|
- lib/legion/cli/doctor/vault_check.rb
|
|
624
627
|
- lib/legion/cli/doctor_command.rb
|
|
625
628
|
- lib/legion/cli/error.rb
|
|
629
|
+
- lib/legion/cli/error_forwarder.rb
|
|
626
630
|
- lib/legion/cli/error_handler.rb
|
|
627
631
|
- lib/legion/cli/eval_command.rb
|
|
628
632
|
- lib/legion/cli/failover_command.rb
|