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,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
|
data/lib/rubino/modes.rb
ADDED
|
@@ -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
|