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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Tasks
7
+ # Wire shapes for background-subagent (`task`) entries.
8
+ #
9
+ # #summary powers the list endpoint — id/subagent/prompt/status/timing
10
+ # plus a short result preview, never the full body. #detail adds the
11
+ # complete result (success) or error (failure). The Entry struct carries
12
+ # Time objects and a live Thread/Runner; only the serializable fields are
13
+ # surfaced.
14
+ module Serializer
15
+ module_function
16
+
17
+ RESULT_PREVIEW = 200
18
+
19
+ def summary(entry)
20
+ {
21
+ id: entry.id,
22
+ subagent: entry.subagent,
23
+ prompt: entry.prompt,
24
+ status: entry.status.to_s,
25
+ started_at: iso(entry.started_at),
26
+ elapsed_seconds: elapsed(entry),
27
+ result_summary: preview(entry.result)
28
+ }
29
+ end
30
+
31
+ def detail(entry)
32
+ summary(entry).merge(
33
+ finished_at: iso(entry.finished_at),
34
+ result: entry.result,
35
+ error: entry.error
36
+ )
37
+ end
38
+
39
+ def iso(time)
40
+ time&.utc&.iso8601
41
+ end
42
+
43
+ # Wall-clock seconds: to finish if done, else to now for a live task.
44
+ def elapsed(entry)
45
+ return nil unless entry.started_at
46
+
47
+ ((entry.finished_at || Time.now) - entry.started_at).round(3)
48
+ end
49
+
50
+ def preview(result)
51
+ return nil if result.nil?
52
+
53
+ str = result.to_s
54
+ str.length > RESULT_PREVIEW ? "#{str[0, RESULT_PREVIEW]}…" : str
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Tasks
7
+ # GET /v1/tasks/:id
8
+ # Full detail for one background subagent, including its complete result
9
+ # (on success) or error (on failure).
10
+ #
11
+ # @raise [Rubino::NotFoundError] when no task has the id.
12
+ class ShowOperation
13
+ def self.call(request)
14
+ new.call(request)
15
+ end
16
+
17
+ # Accepts an alternate registry for tests.
18
+ def initialize(registry: nil)
19
+ @registry = registry || ::Rubino::Tools::BackgroundTasks.instance
20
+ end
21
+
22
+ def call(request)
23
+ id = request.params.fetch("id")
24
+ entry = @registry.find(id)
25
+ raise NotFoundError.new("task", id) unless entry
26
+
27
+ [200, Serializer.detail(entry)]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Tasks
7
+ # POST /v1/tasks/:id/stop
8
+ # Cancels a running background subagent — the HTTP twin of the `task_stop`
9
+ # tool. Flips the child Runner's CancelToken (the same mechanism the
10
+ # top-level run stop-watcher uses), which unwinds the child loop
11
+ # cooperatively at its next cancel checkpoint.
12
+ #
13
+ # Cancellation is asynchronous: this returns the entry's CURRENT snapshot,
14
+ # so `status` may still read "running" until the worker thread reaches a
15
+ # checkpoint and records its terminal (cancelled/failed) state. Poll
16
+ # GET /v1/tasks/:id to observe the transition.
17
+ #
18
+ # @raise [Rubino::NotFoundError] when no task has the id.
19
+ # @raise [Rubino::ConflictError] when the task is already finished.
20
+ class StopOperation
21
+ def self.call(request)
22
+ new.call(request)
23
+ end
24
+
25
+ # Accepts an alternate registry for tests.
26
+ def initialize(registry: nil)
27
+ @registry = registry || ::Rubino::Tools::BackgroundTasks.instance
28
+ end
29
+
30
+ def call(request)
31
+ id = request.params.fetch("id")
32
+ entry = @registry.find(id)
33
+ raise NotFoundError.new("task", id) unless entry
34
+
35
+ raise ConflictError, "task #{id} already #{entry.status} — nothing to stop" unless entry.status == :running
36
+
37
+ entry.runner&.cancel!
38
+ # Stop-cascade (S5a): wake any descendant parked on a blocking
39
+ # ask_parent so the whole subtree unwinds at once.
40
+ @registry.cancel_descendant_ask_gates(id)
41
+ [202, Serializer.detail(entry)]
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ # Operation-facing view over the Rack env: URL captures, parsed JSON body,
6
+ # query string, headers, and a dry-schema validation helper.
7
+ #
8
+ # Body comes from env["rubino.json"] (set by JsonParser middleware),
9
+ # so operations never touch rack.input directly.
10
+ #
11
+ # request.params # URL captures (e.g. { "id" => "abc" })
12
+ # request.body # parsed JSON body (Hash)
13
+ # request.validate!(schema) # runs dry-schema, raises ValidationError on fail
14
+ # request.header("X-Foo") # case-insensitive header lookup
15
+ class Request
16
+ # @param env [Hash] Rack env
17
+ # @param params [Hash{String=>String}] captures from the matched route
18
+ def initialize(env, params)
19
+ @env = env
20
+ @params = params
21
+ end
22
+
23
+ attr_reader :env, :params
24
+
25
+ # @return [Hash] parsed JSON body, or {} when none
26
+ def body
27
+ @env.fetch("rubino.json", {})
28
+ end
29
+
30
+ # Case-insensitive header lookup; "X-Foo" becomes HTTP_X_FOO.
31
+ def header(name)
32
+ key = "HTTP_#{name.upcase.tr("-", "_")}"
33
+ @env[key]
34
+ end
35
+
36
+ def query
37
+ @query ||= Rack::Utils.parse_nested_query(@env["QUERY_STRING"].to_s)
38
+ end
39
+
40
+ # Runs the body through a dry-schema and returns the coerced hash.
41
+ # dry-schema is used only at the HTTP boundary; internals trust their types.
42
+ #
43
+ # @param schema [Dry::Schema::Processor]
44
+ # @return [Hash] coerced, validated payload
45
+ # @raise [ValidationError] when the schema rejects the body (mapped to 422 by ErrorHandler)
46
+ def validate!(schema)
47
+ result = schema.call(body)
48
+ raise ValidationError.new("invalid request body", details: { errors: result.errors.to_h }) if result.failure?
49
+
50
+ result.to_h
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module API
7
+ # Coerces Operation return values into Rack response triples.
8
+ # Lets operations return whatever shape is most convenient (a plain Hash for
9
+ # 200, a [status, body] pair for other codes, or a full Rack triple for
10
+ # streaming/binary), while the router always hands Rack a valid triple.
11
+ module Responses
12
+ module_function
13
+
14
+ # Builds a JSON response triple with content-type set.
15
+ #
16
+ # @return [Array(Integer, Hash, Array<String>)]
17
+ def json(status, payload)
18
+ [status, { "content-type" => "application/json" }, [JSON.generate(payload)]]
19
+ end
20
+
21
+ # @return [Array(Integer, Hash, Array)] empty 204 response triple
22
+ def no_content
23
+ [204, {}, []]
24
+ end
25
+
26
+ # Normalize an operation result. See Router class comment for the contract.
27
+ #
28
+ # @param value [Hash, Array, #to_rack]
29
+ # @return [Array(Integer, Hash, Array<String>)] Rack triple
30
+ # @raise [ArgumentError] when value doesn't match any supported shape
31
+ def coerce(value)
32
+ case value
33
+ when Array
34
+ coerce_array(value)
35
+ when Hash
36
+ json(200, value)
37
+ else
38
+ return value.to_rack if value.respond_to?(:to_rack)
39
+
40
+ raise ArgumentError, "operation returned unsupported value: #{value.class}"
41
+ end
42
+ end
43
+
44
+ # Disambiguates [status, body] (length 2, JSON-encoded) from a raw
45
+ # [status, headers, body] Rack triple (length 3, passed through).
46
+ def coerce_array(value)
47
+ case value.length
48
+ when 2
49
+ status, body = value
50
+ # RFC 7231 §6.3.5: a 204 response MUST NOT have a message body. We
51
+ # force the body to "" regardless of what the operation returned so
52
+ # we never emit `null\n` (a 4-byte JSON literal) for a No Content.
53
+ return [status, {}, [""]] if status == 204
54
+
55
+ json(status, body)
56
+ when 3
57
+ value
58
+ else
59
+ raise ArgumentError, "operation returned array of length #{value.length}; expected 2 or 3"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ # Minimal pattern-matching router mapping HTTP verb + path to an Operation class.
6
+ #
7
+ # Path patterns support `:name` captures (e.g. "/v1/sessions/:id"), compiled to
8
+ # a `[^/]+` regex group. On a match the captures become Request#params, the
9
+ # original pattern is stashed on env["rubino.route"] (low-cardinality label
10
+ # for Observability), and the operation's return value is coerced via Responses.
11
+ #
12
+ # Operation contract: `.call(request)` returning one of:
13
+ # - Hash → 200 JSON
14
+ # - [status, body_hash] → status + JSON body
15
+ # - [status, headers, body_iterable] → raw Rack triple
16
+ # - object responding to #to_rack → delegated
17
+ #
18
+ # router = Router.new
19
+ # router.get "/v1/health", to: HealthOperation
20
+ # router.post "/v1/sessions", to: Sessions::CreateOperation
21
+ # router.get "/v1/sessions/:id", to: Sessions::ShowOperation
22
+ class Router
23
+ Route = Struct.new(:method, :pattern, :keys, :operation, :original_path)
24
+
25
+ HTTP_METHODS = %i[get post put patch delete].freeze
26
+
27
+ def initialize
28
+ @routes = []
29
+ end
30
+
31
+ HTTP_METHODS.each do |verb|
32
+ define_method(verb) do |path, to:|
33
+ add(verb.to_s.upcase, path, to)
34
+ end
35
+ end
36
+
37
+ # Rack entry point. Matches in registration order; first match wins.
38
+ # Returns a 404 JSON response when nothing matches.
39
+ #
40
+ # @return [Array(Integer, Hash, Array<String>)] Rack response triple
41
+ def call(env)
42
+ rack_method = env["REQUEST_METHOD"]
43
+ path = env["PATH_INFO"]
44
+
45
+ @routes.each do |route|
46
+ next unless route.method == rack_method
47
+
48
+ match = route.pattern.match(path)
49
+ next unless match
50
+
51
+ params = route.keys.zip(match.captures).to_h
52
+ env["rubino.route"] = route.original_path
53
+ request = Request.new(env, params)
54
+ return Responses.coerce(route.operation.call(request))
55
+ end
56
+
57
+ Responses.json(404, error: { code: "not_found", message: "route not found: #{rack_method} #{path}" })
58
+ end
59
+
60
+ private
61
+
62
+ def add(method, path, operation)
63
+ keys = []
64
+ pattern = path.gsub(/:([a-z_]+)/) do
65
+ keys << ::Regexp.last_match(1)
66
+ "([^/]+)"
67
+ end
68
+ @routes << Route.new(method, /\A#{pattern}\z/, keys, operation, path)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-schema"
4
+
5
+ Dry::Schema.load_extensions(:json_schema)
6
+
7
+ module Rubino
8
+ module API
9
+ # dry-schema definitions for HTTP request bodies. Validation runs only at
10
+ # the HTTP boundary (via Request#validate!); domain code downstream assumes
11
+ # types are already coerced. Each constant maps to a single endpoint.
12
+ module Schemas
13
+ # POST /v1/sessions
14
+ CreateSession = Dry::Schema.JSON do
15
+ optional(:title).maybe(:string)
16
+ optional(:parent_id).maybe(:string)
17
+ end
18
+
19
+ # POST /v1/sessions/:id/runs
20
+ # `input` is optional at the schema level so an image-only run (a file
21
+ # with no accompanying text) is accepted: the executor substitutes a
22
+ # default prompt when the text is blank but an image is attached. The
23
+ # "input present OR attachments present" rule is enforced in
24
+ # Operations::Runs::CreateOperation (dry-schema has no cross-field rule
25
+ # and we don't pull in dry-validation just for this).
26
+ CreateRun = Dry::Schema.JSON do
27
+ optional(:input).maybe(:string)
28
+ optional(:attachments).array(:string)
29
+ optional(:skills).array(:string)
30
+ optional(:model).maybe(:string)
31
+ optional(:provider).maybe(:string)
32
+ end
33
+
34
+ # POST /v1/runs/:run_id/approvals/:approval_id
35
+ # Keep in sync with UI::API::APPROVE_DECISIONS — the approve values plus
36
+ # the explicit "deny" (one-off) and "deny_always" (persists a
37
+ # permissions:deny rule) forms the closed set of decisions the gate
38
+ # understands. `always` is a BACK-COMPAT ALIAS for `always_command`
39
+ # (existing web clients post `always`); `always_prefix`/`always_command`
40
+ # are the explicit forms. New values are additive — old clients keep working.
41
+ DecideApproval = Dry::Schema.JSON do
42
+ required(:decision).filled(
43
+ :string,
44
+ included_in?: %w[once session always always_prefix always_command deny deny_always]
45
+ )
46
+ end
47
+
48
+ # POST /v1/runs/:run_id/clarifications/:clarify_id
49
+ DecideClarification = Dry::Schema.JSON do
50
+ required(:response).filled(:string)
51
+ end
52
+
53
+ # PUT /v1/skills/:name
54
+ ToggleSkill = Dry::Schema.JSON do
55
+ required(:enabled).filled(:bool)
56
+ end
57
+
58
+ # PUT /v1/mode — string instead of symbol because JSON has no symbol
59
+ # type; the operation normalises via Modes.set.
60
+ UpdateMode = Dry::Schema.JSON do
61
+ required(:mode).filled(:string, included_in?: Rubino::Modes::ALL.map(&:to_s))
62
+ end
63
+
64
+ # POST /v1/jobs
65
+ CreateCronJob = Dry::Schema.JSON do
66
+ required(:name).filled(:string)
67
+ required(:schedule).filled(:string)
68
+ required(:prompt).filled(:string)
69
+ optional(:skills).array(:string)
70
+ optional(:model).maybe(:string)
71
+ optional(:provider).maybe(:string)
72
+ optional(:deliver).filled(:string, included_in?: %w[local webhook])
73
+ end
74
+
75
+ # PATCH /v1/jobs/:id
76
+ UpdateCronJob = Dry::Schema.JSON do
77
+ optional(:name).filled(:string)
78
+ optional(:schedule).filled(:string)
79
+ optional(:prompt).filled(:string)
80
+ optional(:skills).array(:string)
81
+ optional(:model).maybe(:string)
82
+ optional(:provider).maybe(:string)
83
+ optional(:deliver).filled(:string, included_in?: %w[local webhook])
84
+ optional(:enabled).filled(:bool)
85
+ end
86
+
87
+ # POST /v1/oauth/providers/:id/connect
88
+ ConnectProvider = Dry::Schema.JSON do
89
+ required(:redirect_uri).filled(:string)
90
+ optional(:scopes).array(:string)
91
+ end
92
+
93
+ # POST /v1/oauth/providers/:id/callback
94
+ CallbackProvider = Dry::Schema.JSON do
95
+ required(:code).filled(:string)
96
+ required(:state).filled(:string)
97
+ required(:expected_state).filled(:string)
98
+ required(:code_verifier).filled(:string)
99
+ required(:redirect_uri).filled(:string)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "uri"
5
+ require "puma"
6
+ require "puma/configuration"
7
+ require "puma/launcher"
8
+ require "puma/events"
9
+
10
+ module Rubino
11
+ module API
12
+ # Rack app entry point. Wires the middleware stack + router and runs it under Puma.
13
+ #
14
+ # Reads RUBINO_API_KEY from the environment when no key is passed explicitly;
15
+ # start! refuses to boot without one so the bearer-auth middleware is never bypassed.
16
+ # The pure Rack app (no Puma) is exposed via .build_app for tests and embedding.
17
+ #
18
+ # server = Rubino::API::Server.new(port: 4820)
19
+ # server.start!
20
+ class Server
21
+ DEFAULT_PORT = 4820
22
+ # Loopback by default (#69): the server speaks to a shell tool, so a
23
+ # routable bind is opt-in (--host 0.0.0.0 / RUBINO_API_HOST).
24
+ DEFAULT_HOST = "127.0.0.1"
25
+
26
+ # @param port [Integer] TCP port (default 4820, or pass via constructor)
27
+ # @param host [String] bind address (default 127.0.0.1)
28
+ # @param api_key [String, nil] bearer token; falls back to ENV["RUBINO_API_KEY"]
29
+ # @param tls_cert [String, nil] path to a TLS cert PEM; when set (with
30
+ # tls_key) the listener serves HTTPS via ssl_bind instead of plain TCP
31
+ # @param tls_key [String, nil] path to the matching private-key PEM
32
+ def initialize(port: DEFAULT_PORT, host: DEFAULT_HOST, api_key: nil, router: nil, logger: nil,
33
+ tls_cert: nil, tls_key: nil)
34
+ @port = port
35
+ @host = host
36
+ @api_key = api_key || ENV.fetch("RUBINO_API_KEY", nil)
37
+ @router = router || Router.new
38
+ @logger = logger || Rubino.logger
39
+ @tls_cert = tls_cert
40
+ @tls_key = tls_key
41
+ end
42
+
43
+ # @return [Boolean] whether this server will serve over TLS
44
+ def tls?
45
+ !@tls_cert.nil? && !@tls_key.nil?
46
+ end
47
+
48
+ # Boots Puma and blocks. Fails fast if no API key is configured.
49
+ #
50
+ # @raise [ConfigurationError] if RUBINO_API_KEY is missing/empty
51
+ def start!
52
+ if @api_key.nil? || @api_key.empty?
53
+ raise ConfigurationError,
54
+ "RUBINO_API_KEY must be set to start the API server"
55
+ end
56
+
57
+ app = self.class.build_app(router: @router, api_key: @api_key, logger: @logger)
58
+ @logger.info(event: "api.server.starting", host: @host, port: @port, tls: tls?)
59
+
60
+ bind_url = self.class.bind_url(host: @host, port: @port, tls_cert: @tls_cert, tls_key: @tls_key)
61
+ config = Puma::Configuration.new do |c|
62
+ c.bind(bind_url)
63
+ c.app(app)
64
+ c.quiet
65
+ end
66
+ Puma::Launcher.new(config).run
67
+ end
68
+
69
+ # Composes the Rack middleware stack around the router. Order matters:
70
+ # Observability is outermost (sees every status, including 500s from
71
+ # ErrorHandler), then ErrorHandler, then RateLimit (so /v1/health and
72
+ # /v1/metrics also get a per-IP ceiling before Auth waves them through),
73
+ # then JsonParser, then Auth closest to the router so unauthorized
74
+ # requests never reach operations.
75
+ #
76
+ # @return [#call] a Rack-compatible app
77
+ # Builds the Puma bind URL. When a TLS cert+key are configured it returns
78
+ # an ssl:// bind so Puma terminates TLS with the self-signed cert; the web
79
+ # client pins that cert (see Rubino::API::TLS).
80
+ # Otherwise it returns a plain tcp:// bind (local dev / fake stay HTTP).
81
+ #
82
+ # @return [String] a Puma bind URL ("tcp://..." or "ssl://...")
83
+ def self.bind_url(host:, port:, tls_cert: nil, tls_key: nil)
84
+ return "tcp://#{host}:#{port}" if tls_cert.nil? || tls_key.nil?
85
+
86
+ query = URI.encode_www_form(cert: tls_cert, key: tls_key)
87
+ "ssl://#{host}:#{port}?#{query}"
88
+ end
89
+
90
+ def self.build_app(router:, api_key:, logger: Rubino.logger)
91
+ Rack::Builder.new do
92
+ use Middleware::Observability, logger: logger
93
+ use Middleware::ErrorHandler, logger: logger
94
+ use Middleware::RateLimit
95
+ use Middleware::JsonParser
96
+ use Middleware::Auth, api_key: api_key
97
+ run router
98
+ end.to_app
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "fileutils"
5
+ require "ipaddr"
6
+
7
+ module Rubino
8
+ module API
9
+ # Self-signed TLS for the app→app hop (web client → agent API).
10
+ #
11
+ # The hop is server→server (Ruby Net::HTTP, not a browser), so there is no
12
+ # DNS / Let's Encrypt: the agent generates a long-lived self-signed cert on
13
+ # first boot and the web client PINS it. The operator provisions the PEM out
14
+ # of band over an already-trusted channel, so there is no trust-on-first-use
15
+ # gap on the untrusted HTTP hop.
16
+ #
17
+ # Cert + key live under RUBINO_HOME/tls and are reused across boots.
18
+ module TLS
19
+ DIR_NAME = "tls"
20
+ CERT_NAME = "cert.pem"
21
+ KEY_NAME = "key.pem"
22
+
23
+ # ~10 years — this is a pinned, app→app cert, not a browser-facing one, so
24
+ # a long lifetime avoids needless re-provisioning churn.
25
+ VALIDITY_SECONDS = 10 * 365 * 24 * 60 * 60
26
+
27
+ module_function
28
+
29
+ # TLS is enabled when explicitly toggled (RUBINO_TLS=1) or when a cert
30
+ # already exists under the home dir. Local dev (bin/dev / fake) leaves the
31
+ # toggle unset and ships no cert, so it stays plain HTTP.
32
+ def enabled?(home: Rubino.home_path)
33
+ return true if ENV["RUBINO_TLS"].to_s.strip == "1"
34
+
35
+ File.exist?(cert_path(home: home))
36
+ end
37
+
38
+ def dir(home: Rubino.home_path)
39
+ File.join(home, DIR_NAME)
40
+ end
41
+
42
+ def cert_path(home: Rubino.home_path)
43
+ File.join(dir(home: home), CERT_NAME)
44
+ end
45
+
46
+ def key_path(home: Rubino.home_path)
47
+ File.join(dir(home: home), KEY_NAME)
48
+ end
49
+
50
+ # Returns the cert PEM string, generating the cert+key on first call and
51
+ # reusing them on every subsequent call (idempotent across boots). The
52
+ # cert's CN/SAN is set to +host+ so a pinning client that also checks the
53
+ # subject is satisfied; for IP binds the SAN carries the IP.
54
+ #
55
+ # @param host [String] the host/IP the agent is reachable at
56
+ # @return [String] the certificate PEM
57
+ def ensure_cert!(host: nil, home: Rubino.home_path)
58
+ cert = cert_path(home: home)
59
+ key = key_path(home: home)
60
+ return File.read(cert) if File.exist?(cert) && File.exist?(key)
61
+
62
+ FileUtils.mkdir_p(dir(home: home))
63
+ pem_cert, pem_key = generate(host: host)
64
+ # 0600 the key; the cert PEM is public (it gets shipped to the client).
65
+ File.write(key, pem_key)
66
+ File.chmod(0o600, key)
67
+ File.write(cert, pem_cert)
68
+ File.chmod(0o644, cert)
69
+ pem_cert
70
+ end
71
+
72
+ # Generates a fresh self-signed RSA-2048 cert+key for +host+. Returns
73
+ # [cert_pem, key_pem]. Not persisted — callers persist via ensure_cert!.
74
+ def generate(host: nil)
75
+ cn = host.nil? || host.empty? || host == "0.0.0.0" ? "rubino" : host
76
+ key = OpenSSL::PKey::RSA.new(2048)
77
+
78
+ cert = OpenSSL::X509::Certificate.new
79
+ cert.version = 2
80
+ cert.serial = OpenSSL::BN.rand(159)
81
+ cert.subject = OpenSSL::X509::Name.new([["CN", cn]])
82
+ cert.issuer = cert.subject
83
+ cert.public_key = key.public_key
84
+ cert.not_before = Time.now - 60
85
+ cert.not_after = Time.now + VALIDITY_SECONDS
86
+
87
+ ef = OpenSSL::X509::ExtensionFactory.new
88
+ ef.subject_certificate = cert
89
+ ef.issuer_certificate = cert
90
+ cert.add_extension(ef.create_extension("basicConstraints", "CA:TRUE", true))
91
+ cert.add_extension(ef.create_extension("subjectAltName", san_for(cn), false))
92
+ cert.sign(key, OpenSSL::Digest.new("SHA256"))
93
+
94
+ [cert.to_pem, key.to_pem]
95
+ end
96
+
97
+ # Builds a SAN string. An IP literal goes in as IP:, a hostname as DNS:.
98
+ def san_for(name)
99
+ ip = begin
100
+ IPAddr.new(name)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ ip ? "IP:#{name}" : "DNS:#{name}"
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Attachments
5
+ # Result of Attachments::Classify.call. Pure data; no behaviour.
6
+ # path: frozen realpath captured once (TOCTOU), or original if unsafe
7
+ # kind: :image | :text | :document | :archive | :binary
8
+ # mime: Marcel content-sniffed type
9
+ # safe: false => safety pipeline rejected it; caller skips + warns
10
+ # reason: human-readable why-unsafe / how-classified
11
+ Classification = Struct.new(
12
+ :path, :kind, :mime, :size_bytes, :safe, :reason,
13
+ keyword_init: true
14
+ )
15
+ end
16
+ end