rubyn-code 0.2.2 → 0.4.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 +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -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 +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -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 +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -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 +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- 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/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +311 -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 +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -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 +50 -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 +75 -247
- 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 +10 -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/search.rb +1 -0
- 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/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- 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/tasks/models.rb +1 -0
- 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 +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- 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 +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- 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 +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- 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 +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- metadata +83 -1
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
# -- tool dispatch with budget + signals
|
|
55
|
+
def process_tool_calls(tool_calls)
|
|
56
|
+
aggregate_chars = 0
|
|
57
|
+
budget = Config::Defaults::MAX_MESSAGE_TOOL_RESULTS_CHARS
|
|
58
|
+
|
|
59
|
+
tool_calls.each do |tool_call|
|
|
60
|
+
result, is_error = run_single_tool(tool_call)
|
|
61
|
+
aggregate_chars += result.to_s.length
|
|
62
|
+
result = truncate_tool_result(result, aggregate_chars, budget)
|
|
63
|
+
notify_tool_result(field(tool_call, :name), result, is_error)
|
|
64
|
+
record_tool_result(tool_call, result, is_error)
|
|
65
|
+
end
|
|
66
|
+
@decision_compactor&.signal_edit_batch_complete!
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_single_tool(tool_call)
|
|
70
|
+
tool_name = field(tool_call, :name)
|
|
71
|
+
tool_input = field(tool_call, :input) || {}
|
|
72
|
+
decision = Permissions::Policy.check(
|
|
73
|
+
tool_name: tool_name, tool_input: tool_input, tier: @permission_tier, deny_list: @deny_list
|
|
74
|
+
)
|
|
75
|
+
@on_tool_call&.call(tool_name, tool_input) rescue nil # rubocop:disable Style/RescueModifier
|
|
76
|
+
execute_with_permission(decision, tool_name, tool_input)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def truncate_tool_result(result, aggregate_chars, budget)
|
|
80
|
+
return result unless aggregate_chars > budget
|
|
81
|
+
|
|
82
|
+
remaining = [budget - (aggregate_chars - result.to_s.length), 500].max
|
|
83
|
+
RubynCode::Debug.token("Tool result budget exceeded: #{aggregate_chars}/#{budget} chars")
|
|
84
|
+
"#{result.to_s[0, remaining]}\n\n[truncated — tool result budget exceeded (#{budget} chars/message)]"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def notify_tool_result(tool_name, result, is_error)
|
|
88
|
+
@on_tool_result&.call(tool_name, result, is_error) rescue nil # rubocop:disable Style/RescueModifier
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def record_tool_result(tool_call, result, is_error)
|
|
92
|
+
tool_name = field(tool_call, :name)
|
|
93
|
+
@stall_detector.record(tool_name, field(tool_call, :input) || {})
|
|
94
|
+
@conversation.add_tool_result(field(tool_call, :id), tool_name, result, is_error: is_error)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def execute_with_permission(decision, tool_name, tool_input)
|
|
98
|
+
case decision
|
|
99
|
+
when :deny then ["Tool '#{tool_name}' is blocked by the deny list.", true]
|
|
100
|
+
when :ask then ask_and_execute(tool_name, tool_input)
|
|
101
|
+
when :allow then execute_tool(tool_name, tool_input)
|
|
102
|
+
else ["Unknown permission decision: #{decision}", true]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ask_and_execute(tool_name, tool_input)
|
|
107
|
+
if prompt_user(tool_name,
|
|
108
|
+
tool_input)
|
|
109
|
+
execute_tool(tool_name,
|
|
110
|
+
tool_input)
|
|
111
|
+
else
|
|
112
|
+
["User denied permission for '#{tool_name}'.", true]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def execute_tool(tool_name, tool_input)
|
|
117
|
+
discover_tool(tool_name)
|
|
118
|
+
@hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
|
|
119
|
+
result = dispatch_tool(tool_name, tool_input)
|
|
120
|
+
@hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
|
|
121
|
+
signal_decision_compactor(tool_name, tool_input, result)
|
|
122
|
+
[result.to_s, false]
|
|
123
|
+
rescue RubynCode::UserDeniedError => e
|
|
124
|
+
# User refused this call via the IDE. Surface as is_error so the model
|
|
125
|
+
# knows the tool did not run, not that it ran and returned text.
|
|
126
|
+
[e.message, true]
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
["Error executing #{tool_name}: #{e.message}", true]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Run the tool through @tool_wrapper if one is configured (IDE mode),
|
|
132
|
+
# otherwise call the executor directly. The wrapper receives the raw
|
|
133
|
+
# tool name/input so it can emit protocol notifications and gate the
|
|
134
|
+
# call; the block below is what actually performs the work.
|
|
135
|
+
def dispatch_tool(tool_name, tool_input)
|
|
136
|
+
if @tool_wrapper
|
|
137
|
+
@tool_wrapper.call(tool_name, tool_input) do
|
|
138
|
+
@tool_executor.execute(tool_name, symbolize_keys(tool_input))
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
@tool_executor.execute(tool_name, symbolize_keys(tool_input))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def signal_decision_compactor(tool_name, tool_input, result) # rubocop:disable Metrics/CyclomaticComplexity -- tool dispatch
|
|
146
|
+
return unless @decision_compactor
|
|
147
|
+
|
|
148
|
+
case tool_name
|
|
149
|
+
when 'edit_file', 'write_file'
|
|
150
|
+
path = tool_input[:path] || tool_input['path']
|
|
151
|
+
@decision_compactor.signal_file_edited!(path) if path
|
|
152
|
+
when 'run_specs'
|
|
153
|
+
@decision_compactor.signal_specs_passed! if result.to_s.include?('0 failures')
|
|
154
|
+
end
|
|
155
|
+
rescue StandardError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def prompt_user(tool_name, tool_input)
|
|
160
|
+
risk = resolve_tool_risk(tool_name)
|
|
161
|
+
if risk == :destructive
|
|
162
|
+
Permissions::Prompter.confirm_destructive(tool_name,
|
|
163
|
+
tool_input)
|
|
164
|
+
else
|
|
165
|
+
Permissions::Prompter.confirm(
|
|
166
|
+
tool_name, tool_input
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def resolve_tool_risk(tool_name)
|
|
172
|
+
Tools::Registry.get(tool_name).risk_level
|
|
173
|
+
rescue ToolNotFoundError
|
|
174
|
+
:unknown
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
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
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'etc'
|
|
7
|
+
require 'socket'
|
|
8
|
+
|
|
9
|
+
module RubynCode
|
|
10
|
+
module Auth
|
|
11
|
+
# Encrypts and decrypts provider API keys at rest using AES-256-GCM.
|
|
12
|
+
#
|
|
13
|
+
# The encryption key is derived via PBKDF2 from machine-specific identifiers
|
|
14
|
+
# (username, hostname, home directory) combined with a random salt stored in
|
|
15
|
+
# ~/.rubyn-code/.encryption_salt. This means keys are only decryptable on the
|
|
16
|
+
# same machine by the same user.
|
|
17
|
+
#
|
|
18
|
+
# Encrypted values are prefixed with "enc:v1:" so plaintext values from older
|
|
19
|
+
# versions are transparently migrated on first read.
|
|
20
|
+
module KeyEncryption
|
|
21
|
+
CIPHER = 'aes-256-gcm'
|
|
22
|
+
PREFIX = 'enc:v1:'
|
|
23
|
+
IV_LENGTH = 12
|
|
24
|
+
TAG_LENGTH = 16
|
|
25
|
+
PBKDF2_ITERATIONS = 100_000
|
|
26
|
+
KEY_LENGTH = 32
|
|
27
|
+
SALT_LENGTH = 32
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def encrypt(plaintext)
|
|
31
|
+
return nil unless plaintext
|
|
32
|
+
|
|
33
|
+
cipher = OpenSSL::Cipher.new(CIPHER).encrypt
|
|
34
|
+
key = derive_key
|
|
35
|
+
cipher.key = key
|
|
36
|
+
iv = cipher.random_iv
|
|
37
|
+
|
|
38
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
39
|
+
tag = cipher.auth_tag(TAG_LENGTH)
|
|
40
|
+
|
|
41
|
+
encoded = Base64.strict_encode64(iv + ciphertext + tag)
|
|
42
|
+
"#{PREFIX}#{encoded}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def decrypt(value)
|
|
46
|
+
return nil unless value
|
|
47
|
+
return value unless encrypted?(value)
|
|
48
|
+
|
|
49
|
+
raw = Base64.strict_decode64(value.delete_prefix(PREFIX))
|
|
50
|
+
decrypt_raw(raw)
|
|
51
|
+
rescue OpenSSL::Cipher::CipherError, ArgumentError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def encrypted?(value)
|
|
56
|
+
value.is_a?(String) && value.start_with?(PREFIX)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def decrypt_raw(raw)
|
|
62
|
+
iv = raw[0, IV_LENGTH]
|
|
63
|
+
tag = raw[-TAG_LENGTH, TAG_LENGTH]
|
|
64
|
+
ciphertext = raw[IV_LENGTH...-TAG_LENGTH]
|
|
65
|
+
|
|
66
|
+
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
|
|
67
|
+
cipher.key = derive_key
|
|
68
|
+
cipher.iv = iv
|
|
69
|
+
cipher.auth_tag = tag
|
|
70
|
+
(cipher.update(ciphertext) + cipher.final).force_encoding('UTF-8')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def derive_key
|
|
74
|
+
OpenSSL::KDF.pbkdf2_hmac(
|
|
75
|
+
machine_identity,
|
|
76
|
+
salt: load_or_create_salt,
|
|
77
|
+
iterations: PBKDF2_ITERATIONS,
|
|
78
|
+
length: KEY_LENGTH,
|
|
79
|
+
hash: 'SHA256'
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def machine_identity
|
|
84
|
+
# Use the real UID's login name rather than Etc.getlogin. Etc.getlogin
|
|
85
|
+
# reads the controlling tty's owner and can return "root" when the tty
|
|
86
|
+
# is root-owned (common after `sudo`, and in some VSCode integrated
|
|
87
|
+
# terminal setups) — even though the process itself is running as the
|
|
88
|
+
# real user. That mismatch derives a different AES key on decrypt vs.
|
|
89
|
+
# encrypt and the AEAD tag check fails, which surfaces as a misleading
|
|
90
|
+
# "No <provider> API key configured" error.
|
|
91
|
+
user = begin
|
|
92
|
+
Etc.getpwuid(Process.uid).name
|
|
93
|
+
rescue StandardError
|
|
94
|
+
ENV['USER'] || Etc.getlogin || 'unknown'
|
|
95
|
+
end
|
|
96
|
+
[user, Socket.gethostname, Dir.home].join(':')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def load_or_create_salt
|
|
100
|
+
path = salt_path
|
|
101
|
+
if File.exist?(path)
|
|
102
|
+
File.binread(path)
|
|
103
|
+
else
|
|
104
|
+
salt = SecureRandom.random_bytes(SALT_LENGTH)
|
|
105
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
106
|
+
File.binwrite(path, salt)
|
|
107
|
+
File.chmod(0o600, path)
|
|
108
|
+
salt
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def salt_path
|
|
113
|
+
File.join(Config::Defaults::HOME_DIR, '.encryption_salt')
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
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)
|