rubino-agent 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.
Files changed (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. metadata +856 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Memory
5
+ # Scans content destined for the memories table for adversarial patterns.
6
+ #
7
+ # Memory is a long-lived, cross-session channel that gets *spliced into
8
+ # every future system prompt*, so a single tainted write can persistently
9
+ # bias the agent across runs. We inspect every write at the boundary and
10
+ # refuse anything that smells like a known injection / exfiltration
11
+ # vector. We deliberately err on the side of false-positives — the agent
12
+ # can rephrase, but a planted directive in memory has no antidote.
13
+ #
14
+ # `.scan(content)` returns nil when safe, otherwise a short string
15
+ # describing the threat (used as both error_code label and audit log
16
+ # payload).
17
+ class ThreatScanner
18
+ # Prompt-injection markers. These are the cliches that show up in
19
+ # documented jailbreak attempts; any one match is enough to refuse —
20
+ # legitimate user-profile content has no reason to embed them.
21
+ PROMPT_INJECTION_PATTERNS = [
22
+ /ignore (?:all |the )?previous/i,
23
+ /disregard (?:all |the )?(?:above|previous)/i,
24
+ /you are now/i,
25
+ /new instructions:/i,
26
+ /^\s*system\s*:/i,
27
+ /^\s*assistant\s*:/i,
28
+ /<\|im_start\|>/i,
29
+ /<\|im_end\|>/i,
30
+ /\[INST\]/i
31
+ ].freeze
32
+
33
+ # Credentials embedded in a URL — classic data-exfil channel
34
+ # (scheme://user:pass@host).
35
+ URL_CREDENTIAL_PATTERN = %r{\b[a-z][a-z0-9+\-.]*://[^/\s:@]+:[^/\s@]+@}i
36
+
37
+ # Contiguous base64 of 200+ chars. Reasonable prose never has this;
38
+ # encoded payloads (binaries, encrypted blobs) do.
39
+ BASE64_BLOB_PATTERN = %r{[A-Za-z0-9+/]{200,}={0,2}}
40
+
41
+ # curl/wget piped to a shell — remote code execution recipe.
42
+ PIPE_TO_SHELL_PATTERN = /\b(?:curl|wget)\b[^\n]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b/i
43
+
44
+ # Zero-width characters and BIDI override / isolate codepoints. Used
45
+ # to hide instructions or swap visible text direction — see the
46
+ # "Trojan Source" class of attacks (CVE-2021-42574).
47
+ INVISIBLE_UNICODE_PATTERN = /[​‌‍‮⁦-⁩]/
48
+
49
+ class << self
50
+ # Returns nil when the content is safe, otherwise a short string
51
+ # naming the detected threat class (e.g. "prompt_injection").
52
+ def scan(content)
53
+ return nil if content.nil? || content.empty?
54
+
55
+ text = content.to_s
56
+
57
+ return "prompt_injection" if PROMPT_INJECTION_PATTERNS.any? { |p| text.match?(p) }
58
+ return "exfiltration_url_credentials" if text.match?(URL_CREDENTIAL_PATTERN)
59
+ return "exfiltration_pipe_to_shell" if text.match?(PIPE_TO_SHELL_PATTERN)
60
+ return "exfiltration_base64_blob" if text.match?(BASE64_BLOB_PATTERN)
61
+ return "invisible_unicode" if text.match?(INVISIBLE_UNICODE_PATTERN)
62
+
63
+ nil
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ # In-process Prometheus-style metrics registry.
5
+ #
6
+ # Counters and histograms only — no gauges (process state is queried lazily
7
+ # by the /v1/health operation). The registry is a process-wide singleton;
8
+ # tests can call Metrics.reset! between examples to start clean.
9
+ #
10
+ # Metrics.counter(:http_requests_total, method: "GET", status: 200).increment
11
+ # Metrics.histogram(:http_request_duration_seconds, path: "/v1/runs").observe(0.034)
12
+ #
13
+ # Output is the Prometheus text exposition format (see Renderer), served by
14
+ # API::Operations::MetricsOperation.
15
+ module Metrics
16
+ # Default histogram bucket boundaries (seconds). Tuned for sub-second HTTP
17
+ # request latencies — fine granularity below 100ms, coarser past 1s.
18
+ DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze
19
+
20
+ # Monotonic counter keyed by label set. Thread-safe via internal mutex.
21
+ class Counter
22
+ attr_reader :name, :help
23
+
24
+ def initialize(name, help)
25
+ @name = name
26
+ @help = help
27
+ @values = Hash.new(0)
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ # Add `by` to the counter for the given label set (default 1).
32
+ def increment(by: 1, **labels)
33
+ @mutex.synchronize { @values[labels] += by }
34
+ end
35
+
36
+ def each(&)
37
+ @mutex.synchronize { @values.dup }.each(&)
38
+ end
39
+
40
+ def type = :counter
41
+ end
42
+
43
+ # Bucketed distribution keyed by label set. Buckets are CUMULATIVE per
44
+ # Prometheus convention: each observation increments every bucket whose
45
+ # `le >= value`, plus the implicit `+Inf` bucket. Thread-safe.
46
+ class Histogram
47
+ attr_reader :name, :help, :buckets
48
+
49
+ def initialize(name, help, buckets: DEFAULT_BUCKETS)
50
+ @name = name
51
+ @help = help
52
+ @buckets = buckets
53
+ @observations = Hash.new { |h, k| h[k] = { counts: Hash.new(0), sum: 0.0, count: 0 } }
54
+ @mutex = Mutex.new
55
+ end
56
+
57
+ # Record one observation. Increments every bucket with `le >= value`,
58
+ # the `+Inf` bucket, the `_sum`, and the `_count`.
59
+ def observe(value, **labels)
60
+ @mutex.synchronize do
61
+ obs = @observations[labels]
62
+ @buckets.each { |b| obs[:counts][b] += 1 if value <= b }
63
+ obs[:counts]["+Inf"] += 1
64
+ obs[:sum] += value
65
+ obs[:count] += 1
66
+ end
67
+ end
68
+
69
+ def each(&)
70
+ @mutex.synchronize { @observations.dup }.each(&)
71
+ end
72
+
73
+ def type = :histogram
74
+ end
75
+
76
+ class << self
77
+ # Fetch (or lazily create) the named Counter and bind `labels` for use via Proxy.
78
+ def counter(name, **labels)
79
+ registry[name] ||= Counter.new(name, descriptions.fetch(name, name.to_s))
80
+ Proxy.new(registry[name], labels)
81
+ end
82
+
83
+ # Fetch (or lazily create) the named Histogram and bind `labels` for use via Proxy.
84
+ def histogram(name, **labels)
85
+ registry[name] ||= Histogram.new(name, descriptions.fetch(name, name.to_s))
86
+ Proxy.new(registry[name], labels)
87
+ end
88
+
89
+ # Set the HELP text for `name`; applied when the metric is first created.
90
+ def describe(name, help)
91
+ descriptions[name] = help
92
+ end
93
+
94
+ # Yield each registered metric (Counter or Histogram).
95
+ def each(&) = registry.each_value(&)
96
+
97
+ # Drop all registered metrics. Intended for tests.
98
+ def reset!
99
+ @registry = nil
100
+ end
101
+
102
+ # Serialize the full registry to Prometheus text exposition format.
103
+ def render
104
+ Renderer.call(registry.values)
105
+ end
106
+
107
+ private
108
+
109
+ def registry
110
+ @registry ||= {}
111
+ end
112
+
113
+ def descriptions
114
+ @descriptions ||= {}
115
+ end
116
+ end
117
+
118
+ # Thin wrapper binding a metric to a pre-built label set so call sites
119
+ # read cleanly:
120
+ # Metrics.counter(:foo, label: "x").increment
121
+ class Proxy
122
+ def initialize(metric, labels)
123
+ @metric = metric
124
+ @labels = labels
125
+ end
126
+
127
+ def increment(by: 1)
128
+ @metric.increment(by: by, **@labels)
129
+ end
130
+
131
+ def observe(value)
132
+ @metric.observe(value, **@labels)
133
+ end
134
+ end
135
+
136
+ # Serializes metrics to Prometheus text exposition format:
137
+ # # HELP name help text
138
+ # # TYPE name counter|histogram
139
+ # name{label="value",...} value
140
+ # Label values are escaped for `"`, `\`, and newline.
141
+ module Renderer
142
+ def self.call(metrics)
143
+ metrics.flat_map { |m| render_metric(m) }.join("\n") + "\n"
144
+ end
145
+
146
+ def self.render_metric(metric)
147
+ lines = ["# HELP #{metric.name} #{metric.help}", "# TYPE #{metric.name} #{metric.type}"]
148
+ case metric.type
149
+ when :counter
150
+ metric.each { |labels, value| lines << "#{metric.name}#{format_labels(labels)} #{value}" }
151
+ when :histogram
152
+ metric.each do |labels, data|
153
+ data[:counts].each do |bucket, count|
154
+ lines << "#{metric.name}_bucket#{format_labels(labels.merge(le: bucket.to_s))} #{count}"
155
+ end
156
+ lines << "#{metric.name}_sum#{format_labels(labels)} #{data[:sum]}"
157
+ lines << "#{metric.name}_count#{format_labels(labels)} #{data[:count]}"
158
+ end
159
+ end
160
+ lines
161
+ end
162
+
163
+ def self.format_labels(labels)
164
+ return "" if labels.empty?
165
+
166
+ pairs = labels.map { |k, v| %(#{k}="#{escape(v)}") }.join(",")
167
+ "{#{pairs}}"
168
+ end
169
+
170
+ def self.escape(value)
171
+ value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", '\n')
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ # In-process switch that gates two orthogonal concerns from a single name:
5
+ #
6
+ # :default — tutti i tool registrati, approval rules from config
7
+ # :plan — solo tool read-only (read/grep/glob/web/todo/question)
8
+ # :yolo — tutti i tool, ApprovalPolicy bypassata (sempre :allow)
9
+ #
10
+ # Lives at the process level intentionally — alpha rule: no premature
11
+ # persistence. A new `rubino chat` boots in :default; an explicit
12
+ # `/mode yolo` or `Modes.set(:yolo)` from the API caller takes effect
13
+ # for the rest of that process. We can move it onto Session later if
14
+ # users actually want it sticky.
15
+ #
16
+ # Boot pinning (#3): the process forgets the active mode on restart, which
17
+ # surprises callers when an external supervisor re-applies config and bounces
18
+ # the process out from under an already-set mode. Rather than introduce
19
+ # on-disk state, the initial mode is read once from the RUBINO_BOOT_MODE env
20
+ # var: an unattended supervisor can pin it in the process environment so a
21
+ # restart comes back up in the same mode it was configured for. Unset (the
22
+ # normal interactive case) keeps the :default boot. An unknown value is
23
+ # ignored so a typo in the environment can never crash boot.
24
+ module Modes
25
+ DEFAULT = :default
26
+ PLAN = :plan
27
+ YOLO = :yolo
28
+ ALL = [DEFAULT, PLAN, YOLO].freeze
29
+
30
+ # Tool names allowed in plan mode. Pulled by string against Tool#name
31
+ # in the Registry — see Tools::Registry.enabled_tools. Keep this list
32
+ # in sync with the actual tool names registered in
33
+ # Tools::Registry.register_defaults!; the spec pins both sides.
34
+ READ_ONLY_TOOLS = %w[read grep glob webfetch websearch todowrite question shell_output skill].freeze
35
+
36
+ DESCRIPTIONS = {
37
+ DEFAULT => "all tools, approvals from config",
38
+ PLAN => "read-only tools only, no edits/shell/git",
39
+ YOLO => "all tools, approvals skipped"
40
+ }.freeze
41
+
42
+ class << self
43
+ def current
44
+ @current ||= boot_default
45
+ end
46
+
47
+ # Switches the active mode. Returns the new mode symbol. Raises on
48
+ # an unknown name so a typo in a slash command surfaces immediately
49
+ # rather than silently leaving the previous mode in place.
50
+ def set(name)
51
+ sym = name.to_s.downcase.to_sym
52
+ raise ArgumentError, "unknown mode: #{name.inspect} (valid: #{ALL.join(", ")})" unless ALL.include?(sym)
53
+
54
+ @current = sym
55
+ end
56
+
57
+ # Initial mode for a fresh process. Honours RUBINO_BOOT_MODE so an
58
+ # external supervisor can pin the mode across a restart without any
59
+ # on-disk state; an unset or unknown value falls back to DEFAULT.
60
+ def boot_default
61
+ raw = ENV.fetch("RUBINO_BOOT_MODE", nil)
62
+ return DEFAULT if raw.nil? || raw.strip.empty?
63
+
64
+ sym = raw.strip.downcase.to_sym
65
+ ALL.include?(sym) ? sym : DEFAULT
66
+ end
67
+
68
+ def reset!
69
+ @current = DEFAULT
70
+ end
71
+
72
+ def description(name = current)
73
+ DESCRIPTIONS[name.to_s.downcase.to_sym]
74
+ end
75
+
76
+ # Used by Tools::Registry.enabled_tools. Plan is the only mode that
77
+ # filters; default and yolo both pass everything through.
78
+ def allows_tool?(tool_name)
79
+ return true unless current == PLAN
80
+
81
+ READ_ONLY_TOOLS.include?(tool_name.to_s)
82
+ end
83
+
84
+ # Used by Security::ApprovalPolicy#decide. Yolo short-circuits to
85
+ # :allow before any pattern matching; plan never reaches the policy
86
+ # because the tools it would gate are already filtered out of the
87
+ # registry.
88
+ def skip_approvals?
89
+ current == YOLO
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Rubino
8
+ module OAuth
9
+ # Persistence for OAuth connections backed by the +oauth_connections+
10
+ # table. Tokens are encrypted on write and decrypted on read through
11
+ # {TokenEncryptor}, so every hash returned by this repository carries
12
+ # plaintext +:access_token+/+:refresh_token+ alongside parsed +:scopes+
13
+ # (Array) and +:metadata+ (Hash) — callers never deal with ciphertext or
14
+ # raw JSON columns.
15
+ #
16
+ # {#upsert} is keyed on +(provider, account_id)+: re-authenticating the
17
+ # same provider account updates the existing row in place (preserving its
18
+ # +id+ and +created_at+) rather than duplicating.
19
+ class ConnectionRepository
20
+ def initialize(db: nil, encryptor: nil)
21
+ @db = db || Rubino.database.db
22
+ @encryptor = encryptor || TokenEncryptor.from_env
23
+ end
24
+
25
+ # Insert or update a connection identified by +(provider, account_id)+.
26
+ # Tokens are encrypted before they hit the database.
27
+ #
28
+ # @return [Hash] the freshly-decrypted row as returned by {#find}:
29
+ # includes all schema columns plus plaintext +:access_token+ and
30
+ # +:refresh_token+, +:scopes+ (Array<String>) and +:metadata+ (Hash).
31
+ # The +:access_token+/+:refresh_token+ values are sensitive — never
32
+ # log them.
33
+ def upsert(provider:, account_id:, access_token:, account_email: nil, refresh_token: nil, expires_at: nil,
34
+ scopes: [], metadata: {})
35
+ now = Time.now.utc.iso8601
36
+ existing = @db[:oauth_connections].where(provider: provider.to_s, account_id: account_id.to_s).first
37
+ id = existing ? existing[:id] : SecureRandom.uuid
38
+
39
+ attrs = {
40
+ id: id,
41
+ provider: provider.to_s,
42
+ account_id: account_id.to_s,
43
+ account_email: account_email,
44
+ access_token: @encryptor.encrypt(access_token),
45
+ refresh_token: @encryptor.encrypt(refresh_token),
46
+ expires_at: expires_at,
47
+ scopes_json: JSON.generate(scopes),
48
+ metadata_json: JSON.generate(metadata),
49
+ updated_at: now
50
+ }
51
+
52
+ if existing
53
+ @db[:oauth_connections].where(id: id).update(attrs)
54
+ else
55
+ @db[:oauth_connections].insert(attrs.merge(created_at: now))
56
+ end
57
+ find(id)
58
+ end
59
+
60
+ def find(id)
61
+ row = @db[:oauth_connections].where(id: id).first
62
+ decrypt(row)
63
+ end
64
+
65
+ def for_provider(provider)
66
+ @db[:oauth_connections].where(provider: provider.to_s).order(:created_at).map { |r| decrypt(r) }
67
+ end
68
+
69
+ def first_for_provider(provider)
70
+ decrypt(@db[:oauth_connections].where(provider: provider.to_s).order(:created_at).first)
71
+ end
72
+
73
+ def list
74
+ @db[:oauth_connections].order(:provider, :created_at).map { |r| decrypt(r) }
75
+ end
76
+
77
+ def destroy!(id)
78
+ @db[:oauth_connections].where(id: id).delete
79
+ end
80
+
81
+ private
82
+
83
+ def decrypt(row)
84
+ return nil unless row
85
+
86
+ row.merge(
87
+ access_token: @encryptor.decrypt(row[:access_token]),
88
+ refresh_token: @encryptor.decrypt(row[:refresh_token]),
89
+ scopes: JSON.parse(row[:scopes_json] || "[]"),
90
+ metadata: JSON.parse(row[:metadata_json] || "{}")
91
+ )
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module OAuth
8
+ class Provider
9
+ # GitHub OAuth 2.0 provider.
10
+ #
11
+ # Scopes are sent space-separated (GitHub's expected delimiter, inherited
12
+ # from {Provider#scope_separator}). When the authenticated user has set
13
+ # their primary email private, +/user+ returns +email: nil+; in that case
14
+ # we fall back to +/user/emails+ and pick the primary entry.
15
+ class Github < Provider
16
+ def self.id = :github
17
+ def self.display_name = "GitHub"
18
+ def self.site = "https://github.com"
19
+ def self.authorize_path = "/login/oauth/authorize"
20
+ def self.token_path = "/login/oauth/access_token"
21
+ def self.default_scopes = %w[repo user:email]
22
+
23
+ API_BASE = "https://api.github.com"
24
+
25
+ # Revoke an access token by deleting the OAuth grant for our app.
26
+ # https://docs.github.com/en/rest/apps/oauth-applications#delete-an-app-token
27
+ # Authentication is the app's (client_id, client_secret) via Basic, not
28
+ # the user token — the token to revoke goes in the JSON body.
29
+ #
30
+ # @param access_token [String] user token to invalidate
31
+ # @return [Boolean] true on 204 (success), false otherwise.
32
+ def revoke(access_token)
33
+ conn = Faraday.new(url: API_BASE) do |f|
34
+ f.request :authorization, :basic, @client_id, @client_secret
35
+ f.headers["Accept"] = "application/vnd.github+json"
36
+ f.headers["Content-Type"] = "application/json"
37
+ f.headers["User-Agent"] = "rubino"
38
+ end
39
+ response = conn.delete("/applications/#{@client_id}/token", JSON.generate(access_token: access_token))
40
+ response.success?
41
+ end
42
+
43
+ def fetch_account_info(access_token)
44
+ conn = Faraday.new(url: API_BASE) do |f|
45
+ f.headers["Authorization"] = "Bearer #{access_token}"
46
+ f.headers["Accept"] = "application/vnd.github+json"
47
+ f.headers["User-Agent"] = "rubino"
48
+ end
49
+
50
+ user = JSON.parse(conn.get("/user").body)
51
+ email = user["email"] || fetch_primary_email(conn)
52
+
53
+ {
54
+ account_id: user["id"].to_s,
55
+ account_email: email,
56
+ metadata: { login: user["login"], name: user["name"] }
57
+ }
58
+ end
59
+
60
+ private
61
+
62
+ def fetch_primary_email(conn)
63
+ response = conn.get("/user/emails")
64
+ return nil unless response.success?
65
+
66
+ emails = JSON.parse(response.body)
67
+ primary = emails.find { |e| e["primary"] } || emails.first
68
+ primary && primary["email"]
69
+ rescue StandardError
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module OAuth
8
+ class Provider
9
+ # Google OAuth 2.0 / OpenID Connect provider.
10
+ #
11
+ # Account info comes from the OIDC +/v1/userinfo+ endpoint; +sub+ is used
12
+ # as the stable account_id. The authorize request injects
13
+ # +access_type=offline+ and +prompt=consent+ — without both, Google only
14
+ # returns a refresh_token on the user's very first consent and not on
15
+ # subsequent re-auths, which silently breaks token refresh.
16
+ class Google < Provider
17
+ def self.id = :google
18
+ def self.display_name = "Google"
19
+ def self.site = "https://accounts.google.com"
20
+ def self.authorize_path = "/o/oauth2/v2/auth"
21
+ def self.token_path = "https://oauth2.googleapis.com/token"
22
+ def self.default_scopes = %w[openid email profile]
23
+
24
+ USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
25
+ REVOKE_URL = "https://oauth2.googleapis.com/revoke"
26
+
27
+ # Revoke an access or refresh token. Google's revoke endpoint accepts
28
+ # either; revoking a refresh token implicitly invalidates all access
29
+ # tokens derived from it, so callers should pass the refresh token when
30
+ # available.
31
+ # https://developers.google.com/identity/protocols/oauth2/web-server#tokenrevoke
32
+ #
33
+ # @param token [String] access or refresh token
34
+ # @return [Boolean] true on 200, false otherwise.
35
+ def revoke(token)
36
+ response = Faraday.post(REVOKE_URL, { token: token },
37
+ "Content-Type" => "application/x-www-form-urlencoded")
38
+ response.success?
39
+ end
40
+
41
+ def fetch_account_info(access_token)
42
+ response = Faraday.get(USERINFO_URL, nil, "Authorization" => "Bearer #{access_token}")
43
+ user = JSON.parse(response.body)
44
+
45
+ {
46
+ account_id: user["sub"],
47
+ account_email: user["email"],
48
+ metadata: { name: user["name"], picture: user["picture"], hd: user["hd"] }
49
+ }
50
+ end
51
+
52
+ def build_authorize_request(redirect_uri:, scopes: nil, extra: {})
53
+ super(redirect_uri: redirect_uri, scopes: scopes,
54
+ extra: { access_type: "offline", prompt: "consent" }.merge(extra))
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end