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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. 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
- auth_url = build_authorization_url(code_challenge:, state:)
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 = http_client.post(token_url) do |req|
55
- req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
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
- TokenStore.save(
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 = http_client.post(token_url) do |req|
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
- client_id: client_id,
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
- unless response.success?
123
- body = parse_json(response.body)
124
- error_msg = body&.dig('error_description') || body&.dig('error') || response.body
125
- raise TokenExchangeError, "Code exchange failed (#{response.status}): #{error_msg}"
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
- raise TokenExchangeError, 'Invalid response from token endpoint' unless body
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: body['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/ then 'open'
141
- when /linux/ then 'xdg-open'
142
- when /mingw|mswin/ then 'start'
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(a, b)
164
- return false if a.nil? || b.nil?
165
- return false unless a.bytesize == b.bytesize
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
- l = a.unpack('C*')
168
- r = b.unpack('C*')
169
- l.zip(r).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
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
- code = params['code']
60
- state = params['state']
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
- error = params['error'] || 'unknown'
73
- description = params['error_description'] || 'No authorization code received'
63
+ handle_error_callback(params, res)
64
+ end
74
65
 
75
- res.status = 400
76
- res.content_type = 'text/html; charset=utf-8'
77
- res.body = error_html(error, description)
66
+ Thread.new { sleep(0.5) && server.shutdown }
67
+ end
78
68
 
79
- @mutex.synchronize do
80
- @condvar.signal
81
- end
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
- Thread.new do
85
- sleep(0.5)
86
- server.shutdown
87
- end
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)