rubyn-code 0.2.2 → 0.3.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 +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- metadata +53 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Agent
|
|
5
|
+
# Handles tool definition filtering, permission checks, and execution
|
|
6
|
+
# for the agent loop.
|
|
7
|
+
module ToolProcessor # rubocop:disable Metrics/ModuleLength -- tool filtering + permissions + execution + decision signals
|
|
8
|
+
CORE_TOOLS = %w[read_file write_file edit_file glob grep bash spawn_agent background_run].freeze
|
|
9
|
+
PLAN_MODE_RISK_LEVELS = %i[read].freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def tool_definitions
|
|
14
|
+
all_tools = @tool_executor.tool_definitions
|
|
15
|
+
return all_tools if all_tools.size <= CORE_TOOLS.size
|
|
16
|
+
|
|
17
|
+
@discovered_tools ||= Set.new
|
|
18
|
+
|
|
19
|
+
# Use DynamicToolSchema to filter based on detected task context
|
|
20
|
+
context = detect_task_context
|
|
21
|
+
if context
|
|
22
|
+
active = DynamicToolSchema.active_tools(task_context: context, discovered_tools: @discovered_tools)
|
|
23
|
+
return DynamicToolSchema.filter(all_tools, active_names: active)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
all_tools.select { |t| core_or_discovered?(t) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def detect_task_context # rubocop:disable Metrics/CyclomaticComplexity -- safe navigation chain
|
|
30
|
+
last_msg = @conversation&.messages&.reverse_each&.find { |m| m[:role] == 'user' } # rubocop:disable Style/SafeNavigationChainLength
|
|
31
|
+
return nil unless last_msg
|
|
32
|
+
|
|
33
|
+
text = last_msg[:content]
|
|
34
|
+
return nil unless text.is_a?(String)
|
|
35
|
+
|
|
36
|
+
DynamicToolSchema.detect_context(text)
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def core_or_discovered?(tool)
|
|
42
|
+
name = tool[:name] || tool['name']
|
|
43
|
+
CORE_TOOLS.include?(name) || @discovered_tools&.include?(name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def discover_tool(name)
|
|
47
|
+
(@discovered_tools ||= Set.new).add(name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def read_only_tool_definitions
|
|
51
|
+
Tools::Registry.all.select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }.map(&:to_schema)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def process_tool_calls(tool_calls)
|
|
55
|
+
aggregate_chars = 0
|
|
56
|
+
budget = Config::Defaults::MAX_MESSAGE_TOOL_RESULTS_CHARS
|
|
57
|
+
|
|
58
|
+
tool_calls.each do |tool_call|
|
|
59
|
+
result, is_error = run_single_tool(tool_call)
|
|
60
|
+
aggregate_chars += result.to_s.length
|
|
61
|
+
result = truncate_tool_result(result, aggregate_chars, budget)
|
|
62
|
+
notify_tool_result(field(tool_call, :name), result, is_error)
|
|
63
|
+
record_tool_result(tool_call, result, is_error)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def run_single_tool(tool_call)
|
|
68
|
+
tool_name = field(tool_call, :name)
|
|
69
|
+
tool_input = field(tool_call, :input) || {}
|
|
70
|
+
decision = Permissions::Policy.check(
|
|
71
|
+
tool_name: tool_name, tool_input: tool_input, tier: @permission_tier, deny_list: @deny_list
|
|
72
|
+
)
|
|
73
|
+
@on_tool_call&.call(tool_name, tool_input) rescue nil # rubocop:disable Style/RescueModifier
|
|
74
|
+
execute_with_permission(decision, tool_name, tool_input)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def truncate_tool_result(result, aggregate_chars, budget)
|
|
78
|
+
return result unless aggregate_chars > budget
|
|
79
|
+
|
|
80
|
+
remaining = [budget - (aggregate_chars - result.to_s.length), 500].max
|
|
81
|
+
RubynCode::Debug.token("Tool result budget exceeded: #{aggregate_chars}/#{budget} chars")
|
|
82
|
+
"#{result.to_s[0, remaining]}\n\n[truncated — tool result budget exceeded (#{budget} chars/message)]"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def notify_tool_result(tool_name, result, is_error)
|
|
86
|
+
@on_tool_result&.call(tool_name, result, is_error) rescue nil # rubocop:disable Style/RescueModifier
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def record_tool_result(tool_call, result, is_error)
|
|
90
|
+
tool_name = field(tool_call, :name)
|
|
91
|
+
@stall_detector.record(tool_name, field(tool_call, :input) || {})
|
|
92
|
+
@conversation.add_tool_result(field(tool_call, :id), tool_name, result, is_error: is_error)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def execute_with_permission(decision, tool_name, tool_input)
|
|
96
|
+
case decision
|
|
97
|
+
when :deny then ["Tool '#{tool_name}' is blocked by the deny list.", true]
|
|
98
|
+
when :ask then ask_and_execute(tool_name, tool_input)
|
|
99
|
+
when :allow then execute_tool(tool_name, tool_input)
|
|
100
|
+
else ["Unknown permission decision: #{decision}", true]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ask_and_execute(tool_name, tool_input)
|
|
105
|
+
if prompt_user(tool_name,
|
|
106
|
+
tool_input)
|
|
107
|
+
execute_tool(tool_name,
|
|
108
|
+
tool_input)
|
|
109
|
+
else
|
|
110
|
+
["User denied permission for '#{tool_name}'.", true]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def execute_tool(tool_name, tool_input)
|
|
115
|
+
discover_tool(tool_name)
|
|
116
|
+
@hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
|
|
117
|
+
result = @tool_executor.execute(tool_name, symbolize_keys(tool_input))
|
|
118
|
+
@hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
|
|
119
|
+
signal_decision_compactor(tool_name, tool_input, result)
|
|
120
|
+
[result.to_s, false]
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
["Error executing #{tool_name}: #{e.message}", true]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def signal_decision_compactor(tool_name, tool_input, result) # rubocop:disable Metrics/CyclomaticComplexity -- tool dispatch
|
|
126
|
+
return unless @decision_compactor
|
|
127
|
+
|
|
128
|
+
case tool_name
|
|
129
|
+
when 'edit_file', 'write_file'
|
|
130
|
+
path = tool_input[:path] || tool_input['path']
|
|
131
|
+
@decision_compactor.signal_file_edited!(path) if path
|
|
132
|
+
when 'run_specs'
|
|
133
|
+
@decision_compactor.signal_specs_passed! if result.to_s.include?('0 failures')
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def prompt_user(tool_name, tool_input)
|
|
140
|
+
risk = resolve_tool_risk(tool_name)
|
|
141
|
+
if risk == :destructive
|
|
142
|
+
Permissions::Prompter.confirm_destructive(tool_name,
|
|
143
|
+
tool_input)
|
|
144
|
+
else
|
|
145
|
+
Permissions::Prompter.confirm(
|
|
146
|
+
tool_name, tool_input
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def resolve_tool_risk(tool_name)
|
|
152
|
+
Tools::Registry.get(tool_name).risk_level
|
|
153
|
+
rescue ToolNotFoundError
|
|
154
|
+
:unknown
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Agent
|
|
5
|
+
# Tracks token usage and task budgets from LLM responses.
|
|
6
|
+
# Extracted from ResponseParser to keep module size manageable.
|
|
7
|
+
module UsageTracker
|
|
8
|
+
TASK_BUDGET_TOTAL = 100_000 # tokens per user message
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def track_usage(response)
|
|
13
|
+
usage = extract_usage(response)
|
|
14
|
+
return unless usage
|
|
15
|
+
|
|
16
|
+
log_usage(usage)
|
|
17
|
+
@context_manager.track_usage(usage)
|
|
18
|
+
rescue NoMethodError
|
|
19
|
+
# context_manager does not implement track_usage yet
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def extract_usage(response)
|
|
23
|
+
if response.respond_to?(:usage)
|
|
24
|
+
response.usage
|
|
25
|
+
elsif response.is_a?(Hash)
|
|
26
|
+
response[:usage] || response['usage']
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def log_usage(usage)
|
|
31
|
+
input_tokens = usage.respond_to?(:input_tokens) ? usage.input_tokens : usage[:input_tokens]
|
|
32
|
+
output_tokens = usage.respond_to?(:output_tokens) ? usage.output_tokens : usage[:output_tokens]
|
|
33
|
+
cache_info = build_cache_info(usage)
|
|
34
|
+
RubynCode::Debug.token("in=#{input_tokens} out=#{output_tokens}#{cache_info}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_cache_info(usage)
|
|
38
|
+
cache_create = usage.respond_to?(:cache_creation_input_tokens) ? usage.cache_creation_input_tokens.to_i : 0
|
|
39
|
+
cache_read = usage.respond_to?(:cache_read_input_tokens) ? usage.cache_read_input_tokens.to_i : 0
|
|
40
|
+
return '' unless cache_create.positive? || cache_read.positive?
|
|
41
|
+
|
|
42
|
+
" cache_create=#{cache_create} cache_read=#{cache_read}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update_task_budget(response)
|
|
46
|
+
usage = response.respond_to?(:usage) ? response.usage : nil
|
|
47
|
+
return unless usage
|
|
48
|
+
|
|
49
|
+
output = usage.respond_to?(:output_tokens) ? usage.output_tokens.to_i : 0
|
|
50
|
+
input = usage.respond_to?(:input_tokens) ? usage.input_tokens.to_i : 0
|
|
51
|
+
|
|
52
|
+
@task_budget_remaining ||= TASK_BUDGET_TOTAL
|
|
53
|
+
@task_budget_remaining = [@task_budget_remaining - input - output, 0].max
|
|
54
|
+
|
|
55
|
+
RubynCode::Debug.token("task_budget_remaining=#{@task_budget_remaining}/#{TASK_BUDGET_TOTAL}")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -25,25 +25,11 @@ module RubynCode
|
|
|
25
25
|
code_challenge = derive_code_challenge(code_verifier)
|
|
26
26
|
state = SecureRandom.hex(24)
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
callback_server = Server.new
|
|
31
|
-
open_browser(auth_url)
|
|
32
|
-
|
|
33
|
-
result = callback_server.wait_for_callback(timeout: 120)
|
|
34
|
-
|
|
35
|
-
unless secure_compare(result[:state], state)
|
|
36
|
-
raise StateMismatchError, 'OAuth state parameter mismatch — possible CSRF attack'
|
|
37
|
-
end
|
|
28
|
+
result = perform_browser_auth(code_challenge, state)
|
|
29
|
+
validate_state!(result[:state], state)
|
|
38
30
|
|
|
39
31
|
tokens = exchange_code(code: result[:code], code_verifier:)
|
|
40
|
-
|
|
41
|
-
TokenStore.save(
|
|
42
|
-
access_token: tokens[:access_token],
|
|
43
|
-
refresh_token: tokens[:refresh_token],
|
|
44
|
-
expires_at: Time.now + tokens[:expires_in].to_i
|
|
45
|
-
)
|
|
46
|
-
|
|
32
|
+
persist_tokens(tokens)
|
|
47
33
|
tokens
|
|
48
34
|
end
|
|
49
35
|
|
|
@@ -51,35 +37,13 @@ module RubynCode
|
|
|
51
37
|
stored = TokenStore.load
|
|
52
38
|
raise RefreshError, 'No stored refresh token available' unless stored&.dig(:refresh_token)
|
|
53
39
|
|
|
54
|
-
response =
|
|
55
|
-
|
|
56
|
-
req.body = URI.encode_www_form(
|
|
57
|
-
grant_type: 'refresh_token',
|
|
58
|
-
client_id: client_id,
|
|
59
|
-
refresh_token: stored[:refresh_token]
|
|
60
|
-
)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
unless response.success?
|
|
64
|
-
body = parse_json(response.body)
|
|
65
|
-
error_msg = body&.dig('error_description') || body&.dig('error') || response.body
|
|
66
|
-
raise RefreshError, "Token refresh failed (#{response.status}): #{error_msg}"
|
|
67
|
-
end
|
|
40
|
+
response = post_refresh_request(stored[:refresh_token])
|
|
41
|
+
raise_refresh_error(response) unless response.success?
|
|
68
42
|
|
|
69
43
|
body = parse_json(response.body)
|
|
70
44
|
raise RefreshError, 'Invalid response from token endpoint' unless body
|
|
71
45
|
|
|
72
|
-
|
|
73
|
-
access_token: body['access_token'],
|
|
74
|
-
refresh_token: body['refresh_token'] || stored[:refresh_token],
|
|
75
|
-
expires_at: Time.now + body['expires_in'].to_i
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
access_token: body['access_token'],
|
|
80
|
-
refresh_token: body['refresh_token'] || stored[:refresh_token],
|
|
81
|
-
expires_in: body['expires_in']
|
|
82
|
-
}
|
|
46
|
+
save_refreshed_tokens(body, stored)
|
|
83
47
|
end
|
|
84
48
|
|
|
85
49
|
private
|
|
@@ -108,40 +72,92 @@ module RubynCode
|
|
|
108
72
|
end
|
|
109
73
|
|
|
110
74
|
def exchange_code(code:, code_verifier:)
|
|
111
|
-
response =
|
|
75
|
+
response = post_code_exchange(code, code_verifier)
|
|
76
|
+
raise_exchange_error(response) unless response.success?
|
|
77
|
+
|
|
78
|
+
body = parse_json(response.body)
|
|
79
|
+
raise TokenExchangeError, 'Invalid response from token endpoint' unless body
|
|
80
|
+
|
|
81
|
+
{ access_token: body['access_token'], refresh_token: body['refresh_token'], expires_in: body['expires_in'] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def post_code_exchange(code, code_verifier)
|
|
85
|
+
http_client.post(token_url) do |req|
|
|
112
86
|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
113
87
|
req.body = URI.encode_www_form(
|
|
114
|
-
grant_type: 'authorization_code',
|
|
115
|
-
|
|
116
|
-
code: code,
|
|
117
|
-
redirect_uri: redirect_uri,
|
|
118
|
-
code_verifier: code_verifier
|
|
88
|
+
grant_type: 'authorization_code', client_id: client_id,
|
|
89
|
+
code: code, redirect_uri: redirect_uri, code_verifier: code_verifier
|
|
119
90
|
)
|
|
120
91
|
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def raise_exchange_error(response)
|
|
95
|
+
body = parse_json(response.body)
|
|
96
|
+
error_msg = body&.dig('error_description') || body&.dig('error') || response.body
|
|
97
|
+
raise TokenExchangeError, "Code exchange failed (#{response.status}): #{error_msg}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def perform_browser_auth(code_challenge, state)
|
|
101
|
+
auth_url = build_authorization_url(code_challenge:, state:)
|
|
102
|
+
callback_server = Server.new
|
|
103
|
+
open_browser(auth_url)
|
|
104
|
+
callback_server.wait_for_callback(timeout: 120)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_state!(received, expected)
|
|
108
|
+
return if secure_compare(received, expected)
|
|
109
|
+
|
|
110
|
+
raise StateMismatchError, 'OAuth state parameter mismatch — possible CSRF attack'
|
|
111
|
+
end
|
|
121
112
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
113
|
+
def persist_tokens(tokens)
|
|
114
|
+
TokenStore.save(
|
|
115
|
+
access_token: tokens[:access_token],
|
|
116
|
+
refresh_token: tokens[:refresh_token],
|
|
117
|
+
expires_at: Time.now + tokens[:expires_in].to_i
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def post_refresh_request(refresh_token)
|
|
122
|
+
http_client.post(token_url) do |req|
|
|
123
|
+
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
124
|
+
req.body = URI.encode_www_form(
|
|
125
|
+
grant_type: 'refresh_token',
|
|
126
|
+
client_id: client_id,
|
|
127
|
+
refresh_token: refresh_token
|
|
128
|
+
)
|
|
126
129
|
end
|
|
130
|
+
end
|
|
127
131
|
|
|
132
|
+
def raise_refresh_error(response)
|
|
128
133
|
body = parse_json(response.body)
|
|
129
|
-
|
|
134
|
+
error_msg = body&.dig('error_description') || body&.dig('error') || response.body
|
|
135
|
+
raise RefreshError, "Token refresh failed (#{response.status}): #{error_msg}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def save_refreshed_tokens(body, stored)
|
|
139
|
+
effective_refresh = body['refresh_token'] || stored[:refresh_token]
|
|
140
|
+
|
|
141
|
+
TokenStore.save(
|
|
142
|
+
access_token: body['access_token'],
|
|
143
|
+
refresh_token: effective_refresh,
|
|
144
|
+
expires_at: Time.now + body['expires_in'].to_i
|
|
145
|
+
)
|
|
130
146
|
|
|
131
147
|
{
|
|
132
148
|
access_token: body['access_token'],
|
|
133
|
-
refresh_token:
|
|
149
|
+
refresh_token: effective_refresh,
|
|
134
150
|
expires_in: body['expires_in']
|
|
135
151
|
}
|
|
136
152
|
end
|
|
137
153
|
|
|
138
154
|
def open_browser(url)
|
|
139
155
|
launcher = case RUBY_PLATFORM
|
|
140
|
-
when /darwin/
|
|
141
|
-
when /linux/
|
|
142
|
-
when /mingw|mswin/
|
|
143
|
-
else 'xdg-open'
|
|
156
|
+
when /darwin/ then 'open'
|
|
157
|
+
when /linux/ then 'xdg-open'
|
|
158
|
+
when /mingw|mswin/ then 'start'
|
|
144
159
|
end
|
|
160
|
+
launcher ||= 'xdg-open'
|
|
145
161
|
|
|
146
162
|
system(launcher, url, exception: false)
|
|
147
163
|
end
|
|
@@ -160,13 +176,13 @@ module RubynCode
|
|
|
160
176
|
nil
|
|
161
177
|
end
|
|
162
178
|
|
|
163
|
-
def secure_compare(
|
|
164
|
-
return false if
|
|
165
|
-
return false unless
|
|
179
|
+
def secure_compare(left, right) # rubocop:disable Naming/PredicateMethod -- constant-time comparison, not a predicate
|
|
180
|
+
return false if left.nil? || right.nil?
|
|
181
|
+
return false unless left.bytesize == right.bytesize
|
|
166
182
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
183
|
+
left_bytes = left.unpack('C*')
|
|
184
|
+
right_bytes = right.unpack('C*')
|
|
185
|
+
left_bytes.zip(right_bytes).reduce(0) { |acc, (lhs, rhs)| acc | (lhs ^ rhs) }.zero?
|
|
170
186
|
end
|
|
171
187
|
|
|
172
188
|
def client_id = Config::Defaults::OAUTH_CLIENT_ID
|
|
@@ -56,35 +56,32 @@ module RubynCode
|
|
|
56
56
|
|
|
57
57
|
def handle_callback(req, res, server)
|
|
58
58
|
params = parse_query(req.query_string)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if code
|
|
63
|
-
@mutex.synchronize do
|
|
64
|
-
@result = { code: code, state: state }
|
|
65
|
-
@condvar.signal
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
res.status = 200
|
|
69
|
-
res.content_type = 'text/html; charset=utf-8'
|
|
70
|
-
res.body = success_html
|
|
59
|
+
|
|
60
|
+
if params['code']
|
|
61
|
+
handle_success_callback(params, res)
|
|
71
62
|
else
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
handle_error_callback(params, res)
|
|
64
|
+
end
|
|
74
65
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
res.body = error_html(error, description)
|
|
66
|
+
Thread.new { sleep(0.5) && server.shutdown }
|
|
67
|
+
end
|
|
78
68
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
69
|
+
def handle_success_callback(params, res)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@result = { code: params['code'], state: params['state'] }
|
|
72
|
+
@condvar.signal
|
|
82
73
|
end
|
|
74
|
+
res.status = 200
|
|
75
|
+
res.content_type = 'text/html; charset=utf-8'
|
|
76
|
+
res.body = success_html
|
|
77
|
+
end
|
|
83
78
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
def handle_error_callback(params, res)
|
|
80
|
+
res.status = 400
|
|
81
|
+
res.content_type = 'text/html; charset=utf-8'
|
|
82
|
+
res.body = error_html(params['error'] || 'unknown',
|
|
83
|
+
params['error_description'] || 'No authorization code received')
|
|
84
|
+
@mutex.synchronize { @condvar.signal }
|
|
88
85
|
end
|
|
89
86
|
|
|
90
87
|
def parse_query(query_string)
|
|
@@ -20,6 +20,15 @@ module RubynCode
|
|
|
20
20
|
load_from_keychain || load_from_file || load_from_env
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Load API key for a given provider. Anthropic uses the full fallback chain.
|
|
24
|
+
def load_for_provider(provider)
|
|
25
|
+
return load if provider == 'anthropic'
|
|
26
|
+
|
|
27
|
+
env_key = resolve_env_key(provider)
|
|
28
|
+
api_key = ENV.fetch(env_key, nil)
|
|
29
|
+
api_key&.empty? == false ? { access_token: api_key, type: :api_key, source: :env } : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
23
32
|
def save(access_token:, refresh_token:, expires_at:)
|
|
24
33
|
ensure_directory!
|
|
25
34
|
|
|
@@ -34,68 +43,56 @@ module RubynCode
|
|
|
34
43
|
data
|
|
35
44
|
end
|
|
36
45
|
|
|
37
|
-
def clear!
|
|
46
|
+
def clear! # rubocop:disable Naming/PredicateMethod -- destructive action, not a predicate
|
|
38
47
|
FileUtils.rm_f(tokens_path)
|
|
39
48
|
true
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
def valid?
|
|
43
52
|
tokens = self.load
|
|
44
|
-
return false unless tokens
|
|
45
|
-
return false unless tokens[:access_token]
|
|
46
|
-
|
|
47
|
-
# API keys don't expire
|
|
53
|
+
return false unless tokens&.fetch(:access_token, nil)
|
|
48
54
|
return true if tokens[:type] == :api_key
|
|
49
|
-
|
|
50
|
-
# OAuth tokens need expiry check
|
|
51
55
|
return true unless tokens[:expires_at]
|
|
52
56
|
|
|
53
57
|
tokens[:expires_at] > Time.now + EXPIRY_BUFFER_SECONDS
|
|
54
58
|
end
|
|
55
59
|
|
|
56
|
-
def exists?
|
|
57
|
-
|
|
58
|
-
end
|
|
60
|
+
def exists? = valid?
|
|
61
|
+
def access_token = self.load&.fetch(:access_token, nil)
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
tokens = self.load
|
|
62
|
-
tokens&.fetch(:access_token, nil)
|
|
63
|
-
end
|
|
63
|
+
private
|
|
64
64
|
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
def resolve_env_key(provider)
|
|
66
|
+
default = Config::Defaults::PROVIDER_ENV_KEYS.fetch(provider, "#{provider.upcase}_API_KEY")
|
|
67
|
+
Config::Settings.new.provider_config(provider)&.fetch('env_key', nil) || default
|
|
68
|
+
rescue StandardError
|
|
69
|
+
default
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
private
|
|
71
|
-
|
|
72
|
-
# Read Claude Code's OAuth token from macOS Keychain
|
|
73
72
|
def load_from_keychain
|
|
74
73
|
return nil unless RUBY_PLATFORM.include?('darwin')
|
|
75
74
|
|
|
76
75
|
output = `security find-generic-password -s "#{KEYCHAIN_SERVICE}" -w 2>/dev/null`.strip
|
|
77
76
|
return nil if output.empty?
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return nil unless oauth && oauth['accessToken']
|
|
78
|
+
oauth = JSON.parse(output)['claudeAiOauth']
|
|
79
|
+
return nil unless oauth&.dig('accessToken')
|
|
82
80
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
build_keychain_tokens(oauth)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
86
85
|
|
|
86
|
+
def build_keychain_tokens(oauth)
|
|
87
87
|
{
|
|
88
88
|
access_token: oauth['accessToken'],
|
|
89
89
|
refresh_token: oauth['refreshToken'],
|
|
90
|
-
expires_at:
|
|
90
|
+
expires_at: oauth['expiresAt'] ? Time.at(oauth['expiresAt'] / 1000.0) : nil,
|
|
91
91
|
type: :oauth,
|
|
92
92
|
source: :keychain
|
|
93
93
|
}
|
|
94
|
-
rescue JSON::ParserError, StandardError
|
|
95
|
-
nil
|
|
96
94
|
end
|
|
97
95
|
|
|
98
|
-
# Read from local YAML token file
|
|
99
96
|
def load_from_file
|
|
100
97
|
return nil unless File.exist?(tokens_path)
|
|
101
98
|
|
|
@@ -114,28 +111,18 @@ module RubynCode
|
|
|
114
111
|
nil
|
|
115
112
|
end
|
|
116
113
|
|
|
117
|
-
# Fall back to ANTHROPIC_API_KEY environment variable
|
|
118
114
|
def load_from_env
|
|
119
115
|
api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
|
|
120
116
|
return nil unless api_key && !api_key.empty?
|
|
121
117
|
|
|
122
|
-
{
|
|
123
|
-
access_token: api_key,
|
|
124
|
-
refresh_token: nil,
|
|
125
|
-
expires_at: nil,
|
|
126
|
-
type: :api_key,
|
|
127
|
-
source: :env
|
|
128
|
-
}
|
|
118
|
+
{ access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
|
|
129
119
|
end
|
|
130
120
|
|
|
131
|
-
def tokens_path
|
|
132
|
-
Config::Defaults::TOKENS_FILE
|
|
133
|
-
end
|
|
121
|
+
def tokens_path = Config::Defaults::TOKENS_FILE
|
|
134
122
|
|
|
135
123
|
def ensure_directory!
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
File.chmod(0o700, dir)
|
|
124
|
+
FileUtils.mkdir_p(File.dirname(tokens_path))
|
|
125
|
+
File.chmod(0o700, File.dirname(tokens_path))
|
|
139
126
|
end
|
|
140
127
|
|
|
141
128
|
def parse_time(value)
|
|
@@ -37,24 +37,9 @@ module RubynCode
|
|
|
37
37
|
max_runs: 100, max_cost: 10.0, poll_interval: 5, idle_timeout: 60,
|
|
38
38
|
on_state_change: nil, on_task_complete: nil, on_task_error: nil
|
|
39
39
|
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@project_root = File.expand_path(project_root)
|
|
44
|
-
@task_manager = task_manager
|
|
45
|
-
@mailbox = mailbox
|
|
46
|
-
@max_runs = max_runs
|
|
47
|
-
@max_cost = max_cost
|
|
48
|
-
@poll_interval = poll_interval
|
|
49
|
-
@idle_timeout = idle_timeout
|
|
50
|
-
@on_state_change = on_state_change
|
|
51
|
-
@on_task_complete = on_task_complete
|
|
52
|
-
@on_task_error = on_task_error
|
|
53
|
-
|
|
54
|
-
@state = :spawned
|
|
55
|
-
@runs_completed = 0
|
|
56
|
-
@total_cost = 0.0
|
|
57
|
-
@stop_requested = false
|
|
40
|
+
assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:)
|
|
41
|
+
assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
|
|
42
|
+
assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
|
|
58
43
|
end
|
|
59
44
|
|
|
60
45
|
# Enters the work-idle-work cycle. Blocks the calling thread until
|
|
@@ -121,6 +106,32 @@ module RubynCode
|
|
|
121
106
|
|
|
122
107
|
# ── Signal handling ──────────────────────────────────────────
|
|
123
108
|
|
|
109
|
+
def assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:) # rubocop:disable Metrics/ParameterLists -- mirrors constructor keyword args
|
|
110
|
+
@agent_name = agent_name
|
|
111
|
+
@role = role
|
|
112
|
+
@llm_client = llm_client
|
|
113
|
+
@project_root = File.expand_path(project_root)
|
|
114
|
+
@task_manager = task_manager
|
|
115
|
+
@mailbox = mailbox
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
|
|
119
|
+
@max_runs = max_runs
|
|
120
|
+
@max_cost = max_cost
|
|
121
|
+
@poll_interval = poll_interval
|
|
122
|
+
@idle_timeout = idle_timeout
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
|
|
126
|
+
@on_state_change = on_state_change
|
|
127
|
+
@on_task_complete = on_task_complete
|
|
128
|
+
@on_task_error = on_task_error
|
|
129
|
+
@state = :spawned
|
|
130
|
+
@runs_completed = 0
|
|
131
|
+
@total_cost = 0.0
|
|
132
|
+
@stop_requested = false
|
|
133
|
+
end
|
|
134
|
+
|
|
124
135
|
def install_signal_handlers!
|
|
125
136
|
%w[INT TERM].each do |sig|
|
|
126
137
|
Signal.trap(sig) { stop! }
|