legionio 1.4.59 → 1.4.60

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: bb8c466919694ff3ee71e207099e6cdbc7d93ec3a641c7213a9efa5f624fceec
4
- data.tar.gz: 714ca199c88b04b1f6b84680dbffe2c6f5d953d09d6114d115878cc98a10e3fe
3
+ metadata.gz: d0b17b55ad4c040c0b2f9e4d23d811b2611791b930dfbf434c5ed99136bae3f6
4
+ data.tar.gz: 7ca5c1823f303b3f54096c5c342c067923bb17ed90e6c891bdff55825209d1b4
5
5
  SHA512:
6
- metadata.gz: '08cce649982a2b0fabb240cf1ce373bf8b98c7807d5608d4e05caea9f26458b56c98f7b62584950d15376112d3f6396dbd3bd5c91e2779ca61867e1debaab2c5'
7
- data.tar.gz: a7d37673e26e87ef65a04100884720da7c762f5fbd8c15f116febd7dc02314df77e0f1243acd2c383bc3e55e4ac940f3ca70ae49e0dfa76ece907871f39e7892
6
+ metadata.gz: c77d435c1fdb2ce380d06d7c36e7099e96f002d3036579e35a49c8c4ce4de419f160dbef83244f529b86cdba7591afe5eea43464943f596c37fdf3a0ee87a16f
7
+ data.tar.gz: 7f31653651939165a61d378d4aa0ecf7cc1db427e922288bb7604bafaf4493828c55b4548618b252fcf0000bb54d84b63922759ebf8dd41facfa0eba067c4e3f
data/.rubocop.yml CHANGED
@@ -39,6 +39,7 @@ Metrics/BlockLength:
39
39
  - 'lib/legion/api/auth.rb'
40
40
  - 'lib/legion/api/auth_worker.rb'
41
41
  - 'lib/legion/api/auth_human.rb'
42
+ - 'lib/legion/cli/auth_command.rb'
42
43
 
43
44
  Metrics/AbcSize:
44
45
  Max: 60
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.60] - 2026-03-18
4
+
5
+ ### Fixed
6
+ - Empty Enter in chat REPL no longer exits the session; returns empty string instead of nil to disambiguate from Ctrl+D (EOF)
7
+
3
8
  ## [1.4.59] - 2026-03-17
4
9
 
5
10
  ### Added
data/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/LegionIO
11
11
  **Gem**: `legionio`
12
- **Version**: 1.4.52
12
+ **Version**: 1.4.60
13
13
  **License**: Apache-2.0
14
14
  **Docker**: `legionio/legion`
15
15
  **Ruby**: >= 3.4
@@ -711,7 +711,7 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
711
711
 
712
712
  ```bash
713
713
  bundle install
714
- bundle exec rspec # 1208 examples, 0 failures
714
+ bundle exec rspec # 1357 examples, 0 failures
715
715
  bundle exec rubocop # 396 files, 0 offenses
716
716
  ```
717
717
 
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module AuthKerberos
7
+ def self.registered(app)
8
+ register_negotiate(app)
9
+ end
10
+
11
+ def self.resolve_kerberos_role_map
12
+ return {} unless defined?(Legion::Settings)
13
+
14
+ Legion::Settings.dig(:kerberos, :role_map) || {}
15
+ rescue StandardError
16
+ {}
17
+ end
18
+
19
+ def self.kerberos_available?
20
+ defined?(Legion::Extensions::Kerberos::Client) &&
21
+ defined?(Legion::Rbac::KerberosClaimsMapper)
22
+ end
23
+
24
+ def self.register_negotiate(app)
25
+ app.get '/api/auth/negotiate' do
26
+ auth_header = request.env['HTTP_AUTHORIZATION']
27
+
28
+ unless auth_header&.match?(/\ANegotiate\s+/i)
29
+ headers['WWW-Authenticate'] = 'Negotiate'
30
+ halt 401, json_error('negotiate_required', 'Negotiate token required', status_code: 401)
31
+ end
32
+
33
+ halt 501, json_error('kerberos_not_available', 'Kerberos extension is not loaded', status_code: 501) unless Routes::AuthKerberos.kerberos_available?
34
+
35
+ token = auth_header.sub(/\ANegotiate\s+/i, '')
36
+
37
+ auth_result = begin
38
+ client = Legion::Extensions::Kerberos::Client.new
39
+ client.authenticate(token: token)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ unless auth_result&.dig(:success)
45
+ headers['WWW-Authenticate'] = 'Negotiate'
46
+ halt 401, json_error('kerberos_auth_failed', 'Kerberos authentication failed', status_code: 401)
47
+ end
48
+
49
+ role_map = Routes::AuthKerberos.resolve_kerberos_role_map
50
+ mapped = Legion::Rbac::KerberosClaimsMapper.map_with_fallback(
51
+ principal: auth_result[:principal],
52
+ groups: auth_result[:groups] || [],
53
+ role_map: role_map
54
+ )
55
+
56
+ ttl = 28_800
57
+ legion_token = Legion::API::Token.issue_human_token(
58
+ msid: mapped[:sub], name: mapped[:name], roles: mapped[:roles], ttl: ttl
59
+ )
60
+
61
+ output_token = auth_result[:output_token]
62
+ headers['WWW-Authenticate'] = "Negotiate #{output_token}" if output_token
63
+
64
+ json_response({
65
+ token: legion_token,
66
+ principal: auth_result[:principal],
67
+ roles: mapped[:roles],
68
+ auth_method: 'kerberos'
69
+ })
70
+ end
71
+ end
72
+
73
+ class << self
74
+ private :register_negotiate
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -4,11 +4,12 @@ module Legion
4
4
  class API < Sinatra::Base
5
5
  module Middleware
6
6
  class Auth
7
- SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
8
- /api/auth/authorize /api/auth/callback].freeze
9
- AUTH_HEADER = 'HTTP_AUTHORIZATION'
10
- BEARER_PATTERN = /\ABearer\s+(.+)\z/i
11
- API_KEY_HEADER = 'HTTP_X_API_KEY'
7
+ SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
8
+ /api/auth/authorize /api/auth/callback /api/auth/negotiate].freeze
9
+ AUTH_HEADER = 'HTTP_AUTHORIZATION'
10
+ BEARER_PATTERN = /\ABearer\s+(.+)\z/i
11
+ NEGOTIATE_PATTERN = /\ANegotiate\s+(.+)\z/i
12
+ API_KEY_HEADER = 'HTTP_X_API_KEY'
12
13
 
13
14
  def initialize(app, opts = {})
14
15
  @app = app
@@ -21,6 +22,10 @@ module Legion
21
22
  return @app.call(env) unless @enabled
22
23
  return @app.call(env) if skip_path?(env['PATH_INFO'])
23
24
 
25
+ # Try Negotiate/SPNEGO first (Kerberos)
26
+ result = try_negotiate(env)
27
+ return result if result
28
+
24
29
  # Try Bearer JWT first
25
30
  token = extract_token(env)
26
31
  if token
@@ -54,10 +59,76 @@ module Legion
54
59
 
55
60
  private
56
61
 
62
+ def try_negotiate(env)
63
+ negotiate_token = extract_negotiate_token(env)
64
+ return nil unless negotiate_token
65
+
66
+ negotiate_result = verify_negotiate(negotiate_token)
67
+ unless negotiate_result
68
+ return kerberos_available? ? unauthorized('Kerberos authentication failed') : nil
69
+ end
70
+
71
+ env['legion.auth'] = negotiate_result[:claims]
72
+ env['legion.auth_method'] = 'kerberos'
73
+ env['legion.owner_msid'] = negotiate_result[:claims][:sub]
74
+ status, app_headers, body = @app.call(env)
75
+ response_headers = app_headers.dup
76
+ response_headers['WWW-Authenticate'] = "Negotiate #{negotiate_result[:output_token]}" if negotiate_result[:output_token]
77
+ [status, response_headers, body]
78
+ end
79
+
57
80
  def skip_path?(path)
58
81
  SKIP_PATHS.any? { |p| path.start_with?(p) }
59
82
  end
60
83
 
84
+ def extract_negotiate_token(env)
85
+ header = env[AUTH_HEADER]
86
+ return nil unless header
87
+
88
+ match = header.match(NEGOTIATE_PATTERN)
89
+ match&.captures&.first
90
+ end
91
+
92
+ def verify_negotiate(token)
93
+ return nil unless kerberos_available?
94
+
95
+ client = Legion::Extensions::Kerberos::Client.new
96
+ auth_result = client.authenticate(token: token)
97
+ return nil unless auth_result[:success]
98
+
99
+ claims = Legion::Rbac::KerberosClaimsMapper.map_with_fallback(
100
+ principal: auth_result[:principal],
101
+ groups: auth_result[:groups] || [],
102
+ role_map: kerberos_role_map,
103
+ fallback: kerberos_fallback
104
+ )
105
+
106
+ { claims: claims, output_token: auth_result[:output_token] }
107
+ rescue StandardError
108
+ nil
109
+ end
110
+
111
+ def kerberos_available?
112
+ defined?(Legion::Extensions::Kerberos::Client) &&
113
+ defined?(Legion::Rbac::KerberosClaimsMapper)
114
+ end
115
+
116
+ def kerberos_role_map
117
+ return {} unless defined?(Legion::Settings)
118
+
119
+ Legion::Settings.dig(:kerberos, :role_map) || {}
120
+ rescue StandardError
121
+ {}
122
+ end
123
+
124
+ def kerberos_fallback
125
+ return :entra unless defined?(Legion::Settings)
126
+
127
+ Legion::Settings.dig(:kerberos, :fallback) || :entra
128
+ rescue StandardError
129
+ :entra
130
+ end
131
+
61
132
  def extract_api_key(env)
62
133
  env[API_KEY_HEADER]
63
134
  end
data/lib/legion/api.rb CHANGED
@@ -29,6 +29,7 @@ require_relative 'api/rbac'
29
29
  require_relative 'api/auth'
30
30
  require_relative 'api/auth_worker'
31
31
  require_relative 'api/auth_human'
32
+ require_relative 'api/auth_kerberos'
32
33
  require_relative 'api/capacity'
33
34
  require_relative 'api/audit'
34
35
  require_relative 'api/metrics'
@@ -103,6 +104,7 @@ module Legion
103
104
  register Routes::Auth
104
105
  register Routes::AuthWorker
105
106
  register Routes::AuthHuman
107
+ register Routes::AuthKerberos
106
108
  register Routes::Capacity
107
109
  register Routes::Audit
108
110
  register Routes::Metrics
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'thor'
4
+ require 'uri'
5
+ require 'fileutils'
4
6
 
5
7
  module Legion
6
8
  module CLI
@@ -72,6 +74,38 @@ module Legion
72
74
  out.json({ authenticated: true, scopes: scopes, expires_in: body['expires_in'] })
73
75
  end
74
76
 
77
+ desc 'kerberos', 'Authenticate using Kerberos TGT from your workstation'
78
+ method_option :api_url, type: :string, desc: 'Legion API base URL'
79
+ method_option :realm, type: :string, desc: 'Kerberos realm override'
80
+ def kerberos
81
+ klist_output = `klist 2>&1`
82
+ unless $CHILD_STATUS&.success?
83
+ say 'No Kerberos ticket found. Run kinit first or check your domain connection.', :red
84
+ return
85
+ end
86
+
87
+ principal_match = klist_output.match(/Principal:\s+(\S+)/)
88
+ unless principal_match
89
+ say 'Could not detect Kerberos principal from klist output.', :red
90
+ return
91
+ end
92
+
93
+ principal = principal_match[1]
94
+ realm = options[:realm] || principal.split('@', 2).last
95
+ say 'Detected Kerberos ticket:', :green
96
+ say " Principal: #{principal}"
97
+ say " Realm: #{realm}"
98
+
99
+ api_url = resolve_api_url
100
+ say "Authenticating to #{api_url}..."
101
+
102
+ token = build_spnego_token(api_url)
103
+ response = send_negotiate_request(api_url, token)
104
+ handle_negotiate_response(response)
105
+ rescue StandardError => e
106
+ say "Kerberos auth error: #{e.message}", :red
107
+ end
108
+
75
109
  default_task :teams
76
110
 
77
111
  no_commands do
@@ -81,6 +115,56 @@ module Legion
81
115
  color: !options[:no_color]
82
116
  )
83
117
  end
118
+
119
+ def resolve_api_url
120
+ url = options[:api_url]
121
+ url ||= Legion::Settings.dig(:api, :url) if defined?(Legion::Settings)
122
+ url || 'http://127.0.0.1:4567'
123
+ end
124
+
125
+ def build_spnego_token(api_url)
126
+ require 'gssapi'
127
+ require 'base64'
128
+ host = ::URI.parse(api_url).host
129
+ spnego = GSSAPI::Simple.new(host, 'HTTP')
130
+ ::Base64.strict_encode64(spnego.init_context)
131
+ end
132
+
133
+ def send_negotiate_request(api_url, token)
134
+ require 'net/http'
135
+ uri = ::URI.parse("#{api_url}/api/auth/negotiate")
136
+ http = ::Net::HTTP.new(uri.host, uri.port)
137
+ request = ::Net::HTTP::Get.new(uri.request_uri)
138
+ request['Authorization'] = "Negotiate #{token}"
139
+ http.request(request)
140
+ end
141
+
142
+ def handle_negotiate_response(response)
143
+ if response.code.to_i == 200
144
+ body = ::JSON.parse(response.body) rescue {} # rubocop:disable Style/RescueModifier
145
+ token_val = body.is_a?(Hash) ? (body['token'] || body.dig('data', 'token')) : nil
146
+ if token_val
147
+ save_credentials(token_val)
148
+ roles = body['roles'] || body.dig('data', 'roles') || []
149
+ say " Roles: #{Array(roles).join(', ')}", :green
150
+ say ' Token saved to ~/.legionio/credentials', :green
151
+ say 'Login successful (kerberos)', :green
152
+ else
153
+ say 'Authentication succeeded but no token in response', :yellow
154
+ end
155
+ else
156
+ say "Authentication failed: HTTP #{response.code}", :red
157
+ say response.body.to_s, :red
158
+ end
159
+ end
160
+
161
+ def save_credentials(token_val)
162
+ credentials_dir = ::File.join(::Dir.home, '.legionio')
163
+ ::FileUtils.mkdir_p(credentials_dir)
164
+ cred_path = ::File.join(credentials_dir, 'credentials')
165
+ ::File.write(cred_path, token_val)
166
+ ::File.chmod(0o600, cred_path)
167
+ end
84
168
  end
85
169
  end
86
170
  end
@@ -335,7 +335,7 @@ module Legion
335
335
  end
336
336
 
337
337
  result = lines.join("\n")
338
- result.strip.empty? ? nil : result
338
+ result.strip.empty? ? '' : result
339
339
  rescue Interrupt
340
340
  raise if first_line
341
341
 
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'legion/cli/output'
5
+
6
+ module Legion
7
+ module CLI
8
+ class Tty < Thor
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
14
+ class_option :config_dir, type: :string, desc: 'Config directory (~/.legionio/settings)'
15
+ class_option :skip_rain, type: :boolean, default: false, desc: 'Skip the digital rain intro'
16
+
17
+ default_task :interactive
18
+
19
+ desc 'interactive', 'Launch the rich terminal UI (default)'
20
+ long_desc <<~DESC
21
+ Launches the Legion TTY - a rich terminal interface with:
22
+ - Onboarding wizard (first run)
23
+ - AI chat shell with streaming responses
24
+ - Operational dashboard (Ctrl+D or /dashboard)
25
+ - Session persistence across runs
26
+
27
+ Similar to tools like Claude Code (CLI) and OpenAI Codex,
28
+ but purpose-built for LegionIO's async cognition engine.
29
+
30
+ First run: walks you through identity detection (Kerberos/GitHub),
31
+ provider selection, and API key setup.
32
+
33
+ Subsequent runs: loads saved identity, re-scans environment,
34
+ and drops straight into the chat shell.
35
+ DESC
36
+ def interactive
37
+ require_tty_gem
38
+ config_dir = options[:config_dir] || Legion::TTY::App::CONFIG_DIR
39
+ app = Legion::TTY::App.new(config_dir: config_dir)
40
+ app.start
41
+ rescue Interrupt
42
+ app&.shutdown
43
+ end
44
+
45
+ desc 'reset', 'Clear saved identity and credentials (re-run onboarding)'
46
+ option :confirm, type: :boolean, default: false, aliases: ['-y'], desc: 'Skip confirmation'
47
+ def reset
48
+ out = formatter
49
+ config_dir = options[:config_dir] || File.expand_path('~/.legionio/settings')
50
+
51
+ identity = File.join(config_dir, 'identity.json')
52
+ credentials = File.join(config_dir, 'credentials.json')
53
+
54
+ unless options[:confirm]
55
+ out.warn('This will delete your saved identity and credentials.')
56
+ out.warn('You will need to re-run onboarding.')
57
+ require 'tty-prompt'
58
+ prompt = ::TTY::Prompt.new
59
+ return unless prompt.yes?('Continue?')
60
+ end
61
+
62
+ [identity, credentials].each do |path|
63
+ if File.exist?(path)
64
+ File.delete(path)
65
+ out.success("Deleted #{File.basename(path)}")
66
+ end
67
+ end
68
+ end
69
+
70
+ desc 'sessions', 'List saved chat sessions'
71
+ def sessions
72
+ out = formatter
73
+ require_tty_gem
74
+
75
+ store = Legion::TTY::SessionStore.new
76
+ list = store.list
77
+
78
+ if list.empty?
79
+ out.detail('No saved sessions.')
80
+ return
81
+ end
82
+
83
+ list.each do |session|
84
+ name = session[:name]
85
+ count = session[:message_count]
86
+ saved = session[:saved_at] || 'unknown'
87
+ puts " #{name.ljust(30)} #{count} messages #{saved}"
88
+ end
89
+ end
90
+
91
+ desc 'version', 'Show legion-tty version'
92
+ def version
93
+ require_tty_gem
94
+ puts "legion-tty #{Legion::TTY::VERSION}"
95
+ end
96
+
97
+ no_commands do
98
+ def formatter
99
+ @formatter ||= Output::Formatter.new(
100
+ json: false,
101
+ color: !options[:no_color]
102
+ )
103
+ end
104
+
105
+ private
106
+
107
+ def require_tty_gem
108
+ require 'legion/tty'
109
+ rescue LoadError => e
110
+ formatter.error("legion-tty gem not installed: #{e.message}")
111
+ formatter.detail('Install with: gem install legion-tty')
112
+ raise SystemExit, 1
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
data/lib/legion/cli.rb CHANGED
@@ -41,6 +41,7 @@ module Legion
41
41
  autoload :Cost, 'legion/cli/cost_command'
42
42
  autoload :Marketplace, 'legion/cli/marketplace_command'
43
43
  autoload :Notebook, 'legion/cli/notebook_command'
44
+ autoload :Tty, 'legion/cli/tty_command'
44
45
 
45
46
  class Main < Thor
46
47
  def self.exit_on_failure?
@@ -227,6 +228,9 @@ module Legion
227
228
  desc 'notebook', 'Read and export Jupyter notebooks'
228
229
  subcommand 'notebook', Legion::CLI::Notebook
229
230
 
231
+ desc 'tty', 'Rich terminal UI (onboarding, AI chat, dashboard)'
232
+ subcommand 'tty', Legion::CLI::Tty
233
+
230
234
  desc 'tree', 'Print a tree of all available commands'
231
235
  def tree
232
236
  legion_print_command_tree(self.class, 'legion', '')
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.59'
4
+ VERSION = '1.4.60'
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.4.59
4
+ version: 1.4.60
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -319,6 +319,7 @@ files:
319
319
  - lib/legion/api/audit.rb
320
320
  - lib/legion/api/auth.rb
321
321
  - lib/legion/api/auth_human.rb
322
+ - lib/legion/api/auth_kerberos.rb
322
323
  - lib/legion/api/auth_worker.rb
323
324
  - lib/legion/api/capacity.rb
324
325
  - lib/legion/api/chains.rb
@@ -479,6 +480,7 @@ files:
479
480
  - lib/legion/cli/theme.rb
480
481
  - lib/legion/cli/trace_command.rb
481
482
  - lib/legion/cli/trigger.rb
483
+ - lib/legion/cli/tty_command.rb
482
484
  - lib/legion/cli/update_command.rb
483
485
  - lib/legion/cli/version.rb
484
486
  - lib/legion/cli/worker_command.rb