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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- 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
|