rubino-agent 0.3.0 → 0.5.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 +4 -4
- data/.rubocop_todo.yml +11 -2
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +172 -5
- data/CONTRIBUTING.md +10 -1
- data/README.md +14 -5
- data/Rakefile +31 -0
- data/docs/agents.md +42 -23
- data/docs/architecture.md +2 -2
- data/docs/commands.md +35 -3
- data/docs/configuration.md +20 -23
- data/docs/getting-started.md +5 -3
- data/docs/security.md +16 -5
- data/docs/skills.md +31 -0
- data/docs/troubleshooting.md +1 -1
- data/exe/rubino +16 -2
- data/install.sh +721 -59
- data/lib/rubino/active_agent.rb +73 -0
- data/lib/rubino/agent/action_claim_guard.rb +881 -0
- data/lib/rubino/agent/agent_registry.rb +5 -2
- data/lib/rubino/agent/definition.rb +1 -9
- data/lib/rubino/agent/fallback_chain.rb +0 -6
- data/lib/rubino/agent/iteration_budget.rb +109 -3
- data/lib/rubino/agent/loop.rb +476 -20
- data/lib/rubino/agent/model_call_runner.rb +81 -3
- data/lib/rubino/agent/prompts/build.txt +22 -5
- data/lib/rubino/agent/response_validator.rb +8 -0
- data/lib/rubino/agent/runner.rb +133 -8
- data/lib/rubino/agent/tool_executor.rb +166 -14
- data/lib/rubino/agent/truncation_continuation.rb +4 -1
- data/lib/rubino/api/server.rb +19 -0
- data/lib/rubino/attachments/classify.rb +35 -17
- data/lib/rubino/boot/config_guard.rb +71 -0
- data/lib/rubino/cli/chat/completion_builder.rb +42 -6
- data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
- data/lib/rubino/cli/chat/session_resolver.rb +87 -21
- data/lib/rubino/cli/chat_command.rb +1189 -50
- data/lib/rubino/cli/commands.rb +282 -2
- data/lib/rubino/cli/config_command.rb +68 -8
- data/lib/rubino/cli/doctor_command.rb +204 -12
- data/lib/rubino/cli/jobs_command.rb +12 -0
- data/lib/rubino/cli/memory_command.rb +53 -20
- data/lib/rubino/cli/onboarding_wizard.rb +79 -6
- data/lib/rubino/cli/session_command.rb +172 -18
- data/lib/rubino/cli/setup_command.rb +131 -8
- data/lib/rubino/cli/skills_command.rb +183 -9
- data/lib/rubino/cli/trust_gate.rb +16 -7
- data/lib/rubino/commands/built_ins.rb +2 -0
- data/lib/rubino/commands/command.rb +12 -2
- data/lib/rubino/commands/executor.rb +149 -12
- data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
- data/lib/rubino/commands/handlers/agents.rb +156 -41
- data/lib/rubino/commands/handlers/config.rb +4 -1
- data/lib/rubino/commands/handlers/help.rb +113 -14
- data/lib/rubino/commands/handlers/memory.rb +15 -5
- data/lib/rubino/commands/handlers/sessions.rb +26 -3
- data/lib/rubino/commands/handlers/status.rb +9 -4
- data/lib/rubino/commands/loader.rb +12 -0
- data/lib/rubino/config/configuration.rb +86 -24
- data/lib/rubino/config/defaults.rb +140 -33
- data/lib/rubino/config/loader.rb +62 -12
- data/lib/rubino/config/validator.rb +341 -0
- data/lib/rubino/config/writer.rb +123 -31
- data/lib/rubino/context/compressor.rb +184 -22
- data/lib/rubino/context/environment_inspector.rb +2 -2
- data/lib/rubino/context/file_discovery.rb +2 -2
- data/lib/rubino/context/message_boundary.rb +27 -1
- data/lib/rubino/context/project_languages.rb +90 -0
- data/lib/rubino/context/prompt_assembler.rb +105 -22
- data/lib/rubino/context/summary_builder.rb +45 -4
- data/lib/rubino/context/token_budget.rb +36 -11
- data/lib/rubino/context/token_estimate.rb +45 -0
- data/lib/rubino/context/tool_result_pruner.rb +81 -0
- data/lib/rubino/database/connection.rb +154 -3
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
- data/lib/rubino/database/migrator.rb +98 -5
- data/lib/rubino/documents/cap_exceeded.rb +13 -0
- data/lib/rubino/documents/converters/csv.rb +4 -3
- data/lib/rubino/documents/converters/docx.rb +29 -5
- data/lib/rubino/documents/converters/html.rb +5 -1
- data/lib/rubino/documents/converters/json.rb +2 -1
- data/lib/rubino/documents/converters/pdf.rb +11 -2
- data/lib/rubino/documents/converters/plain.rb +2 -1
- data/lib/rubino/documents/converters/pptx.rb +11 -2
- data/lib/rubino/documents/converters/xlsx.rb +35 -4
- data/lib/rubino/documents/converters/xml.rb +2 -1
- data/lib/rubino/documents/limits.rb +210 -0
- data/lib/rubino/documents.rb +10 -3
- data/lib/rubino/errors.rb +36 -5
- data/lib/rubino/interaction/cancel_token.rb +19 -3
- data/lib/rubino/interaction/events.rb +13 -0
- data/lib/rubino/interaction/lifecycle.rb +99 -13
- data/lib/rubino/interaction/polishing.rb +176 -0
- data/lib/rubino/jobs/cron_job_repository.rb +5 -8
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
- data/lib/rubino/jobs/queue.rb +63 -8
- data/lib/rubino/jobs/runner.rb +24 -6
- data/lib/rubino/jobs/worker.rb +0 -4
- data/lib/rubino/llm/adapter_response.rb +47 -4
- data/lib/rubino/llm/credential_check.rb +15 -16
- data/lib/rubino/llm/error_classifier.rb +89 -1
- data/lib/rubino/llm/inline_think_filter.rb +69 -12
- data/lib/rubino/llm/request.rb +30 -3
- data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
- data/lib/rubino/llm/tool_bridge.rb +113 -9
- data/lib/rubino/mcp/manager.rb +18 -1
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
- data/lib/rubino/memory/aux_retry.rb +107 -0
- data/lib/rubino/memory/backends/sqlite.rb +73 -44
- data/lib/rubino/memory/backends.rb +23 -7
- data/lib/rubino/memory/salience_gate.rb +103 -0
- data/lib/rubino/memory/sqlite_extraction.rb +70 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
- data/lib/rubino/memory/store.rb +33 -5
- data/lib/rubino/memory/threat_scanner.rb +52 -0
- data/lib/rubino/output/cost.rb +52 -0
- data/lib/rubino/output/headless_block_latch.rb +53 -0
- data/lib/rubino/output/result_serializer.rb +222 -0
- data/lib/rubino/output/turn_recorder.rb +77 -0
- data/lib/rubino/security/approval_policy.rb +227 -32
- data/lib/rubino/security/command_allowlist.rb +79 -4
- data/lib/rubino/security/doom_loop_detector.rb +21 -2
- data/lib/rubino/security/hardline_guard.rb +189 -16
- data/lib/rubino/security/pattern_matcher.rb +28 -5
- data/lib/rubino/security/prefix_deriver.rb +25 -6
- data/lib/rubino/security/readonly_commands.rb +145 -5
- data/lib/rubino/security/secret_path.rb +134 -0
- data/lib/rubino/security/url_safety.rb +255 -0
- data/lib/rubino/session/repository.rb +212 -11
- data/lib/rubino/session/store.rb +139 -14
- data/lib/rubino/skills/installer.rb +230 -0
- data/lib/rubino/skills/prompt_index.rb +2 -2
- data/lib/rubino/skills/registry.rb +52 -1
- data/lib/rubino/skills/skill.rb +64 -3
- data/lib/rubino/skills/skill_tool.rb +16 -5
- data/lib/rubino/tools/background_tasks.rb +157 -13
- data/lib/rubino/tools/base.rb +204 -3
- data/lib/rubino/tools/edit_tool.rb +73 -18
- data/lib/rubino/tools/glob_tool.rb +48 -9
- data/lib/rubino/tools/grep_tool.rb +103 -9
- data/lib/rubino/tools/multi_edit_tool.rb +64 -9
- data/lib/rubino/tools/patch_tool.rb +5 -0
- data/lib/rubino/tools/read_attachment_tool.rb +3 -1
- data/lib/rubino/tools/read_tool.rb +33 -15
- data/lib/rubino/tools/read_tracker.rb +153 -35
- data/lib/rubino/tools/registry.rb +113 -12
- data/lib/rubino/tools/result.rb +9 -1
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/shell_registry.rb +70 -0
- data/lib/rubino/tools/shell_tool.rb +40 -1
- data/lib/rubino/tools/summarize_file_tool.rb +6 -0
- data/lib/rubino/tools/task_stop_tool.rb +10 -16
- data/lib/rubino/tools/task_tool.rb +36 -8
- data/lib/rubino/tools/vision_tool.rb +5 -0
- data/lib/rubino/tools/webfetch_tool.rb +39 -7
- data/lib/rubino/tools/websearch_tool.rb +92 -30
- data/lib/rubino/tools/write_tool.rb +23 -4
- data/lib/rubino/ui/api.rb +10 -1
- data/lib/rubino/ui/base.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +382 -74
- data/lib/rubino/ui/cli.rb +515 -83
- data/lib/rubino/ui/completion_menu.rb +11 -7
- data/lib/rubino/ui/headless_trace.rb +63 -0
- data/lib/rubino/ui/live_region.rb +70 -7
- data/lib/rubino/ui/markdown_renderer.rb +142 -7
- data/lib/rubino/ui/notifier.rb +0 -2
- data/lib/rubino/ui/null.rb +52 -5
- data/lib/rubino/ui/paste_store.rb +16 -2
- data/lib/rubino/ui/queued_indicators.rb +6 -1
- data/lib/rubino/ui/status_bar.rb +61 -7
- data/lib/rubino/ui/streaming_markdown.rb +59 -6
- data/lib/rubino/ui/subagent_view.rb +29 -4
- data/lib/rubino/ui/tool_label.rb +52 -0
- data/lib/rubino/update_check.rb +39 -4
- data/lib/rubino/util/atomic_file.rb +117 -0
- data/lib/rubino/util/ignore_rules.rb +120 -0
- data/lib/rubino/util/output.rb +229 -12
- data/lib/rubino/util/secrets_mask.rb +70 -7
- data/lib/rubino/util/spill_store.rb +153 -0
- data/lib/rubino/version.rb +1 -1
- data/lib/rubino/workspace.rb +9 -1
- data/lib/rubino.rb +191 -7
- data/rubino-agent.gemspec +1 -0
- data/skills/ruby-expert/SKILL.md +1 -0
- metadata +42 -12
- data/lib/rubino/agent/router.rb +0 -65
- data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
- data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
|
@@ -127,7 +127,14 @@ module Rubino
|
|
|
127
127
|
"Valid subagents: #{available_subagent_names.join(", ")}."
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
# Force FOREGROUND in headless one-shot (#380): a `rubino prompt`/-q run
|
|
131
|
+
# has no IdleCardHost to fold a background child's result back in, and the
|
|
132
|
+
# process exits the instant the parent's answer is ready — so a background
|
|
133
|
+
# subagent's result would be silently dropped (its notice sink is nil and
|
|
134
|
+
# its thread is killed on exit). Running synchronously returns the child's
|
|
135
|
+
# final text as THIS tool's result, so it lands in the parent transcript
|
|
136
|
+
# and is factored into the one-shot answer, making `task` reliable headless.
|
|
137
|
+
if background && !Rubino.headless?
|
|
131
138
|
run_background(definition, prompt)
|
|
132
139
|
else
|
|
133
140
|
run_subagent(definition, prompt)
|
|
@@ -261,12 +268,28 @@ module Rubino
|
|
|
261
268
|
# Records the terminal :completed state and notifies the parent.
|
|
262
269
|
# Deliver-or-report for /agents steer (#140): a parked note the child
|
|
263
270
|
# never got another turn to fold in would otherwise vanish silently —
|
|
264
|
-
# the user believes the child was steered when it wasn't.
|
|
265
|
-
#
|
|
266
|
-
#
|
|
271
|
+
# the user believes the child was steered when it wasn't. Say so, on the
|
|
272
|
+
# parent UI and in the completion notice.
|
|
273
|
+
#
|
|
274
|
+
# H5 — the final drain now happens INSIDE #complete, under the SAME
|
|
275
|
+
# registry mutex that flips the status to terminal (and that #steer checks
|
|
276
|
+
# before pushing). The previous shape drained the queue HERE (InputQueue
|
|
277
|
+
# lock) and THEN called #complete (registry lock): a steer/answer arriving
|
|
278
|
+
# in that gap landed on an already-drained queue — dropped, missing from
|
|
279
|
+
# `undelivered`, yet reported delivered. Taking the drained notes from
|
|
280
|
+
# #complete's return closes that gap: a note is either drained here (and
|
|
281
|
+
# reported undelivered) or rejected by #steer (and reported not-delivered
|
|
282
|
+
# to its caller) — never silently lost.
|
|
267
283
|
def record_completion(entry, text, sink, parent_ui)
|
|
268
|
-
|
|
269
|
-
|
|
284
|
+
drained = BackgroundTasks.instance.complete(entry, status: :completed, result: text)
|
|
285
|
+
# Drop the gate-delivered answer COPIES (#457 regression): a /reply
|
|
286
|
+
# answer is delivered to the child via its ask gate AND mirrored onto the
|
|
287
|
+
# steer queue; when the child resumes via the gate and finishes without
|
|
288
|
+
# another turn, that mirror is drained here. It was NOT undelivered — the
|
|
289
|
+
# gate delivered it — so reporting it would surface a false "steer note
|
|
290
|
+
# not delivered" alarm on the happy path. GENUINE steer notes (no
|
|
291
|
+
# ANSWER_NOTE_PREFIX) still report undelivered, preserving #457's invariant.
|
|
292
|
+
undelivered = drained.reject { |n| n.to_s.start_with?(BackgroundTasks::ANSWER_NOTE_PREFIX) }
|
|
270
293
|
notify(sink, completion_notice(entry, text, undelivered: undelivered))
|
|
271
294
|
unless undelivered.empty?
|
|
272
295
|
surface_completion(parent_ui,
|
|
@@ -428,7 +451,10 @@ module Rubino
|
|
|
428
451
|
max_turns: definition.max_turns,
|
|
429
452
|
ui: child_ui,
|
|
430
453
|
agent_definition: definition,
|
|
431
|
-
event_bus: Interaction::EventBus.new
|
|
454
|
+
event_bus: Interaction::EventBus.new,
|
|
455
|
+
# Tag the child's fresh session as subagent machinery so it's hidden
|
|
456
|
+
# from the user-facing /sessions picker + `sessions list` (item 2).
|
|
457
|
+
session_source: "subagent"
|
|
432
458
|
)
|
|
433
459
|
end
|
|
434
460
|
end
|
|
@@ -572,7 +598,9 @@ module Rubino
|
|
|
572
598
|
model_override: definition.resolved_model,
|
|
573
599
|
max_turns: definition.max_turns,
|
|
574
600
|
ui: nested_ui(definition),
|
|
575
|
-
agent_definition: definition
|
|
601
|
+
agent_definition: definition,
|
|
602
|
+
# Hidden from the user-facing /sessions list/picker (item 2).
|
|
603
|
+
session_source: "subagent"
|
|
576
604
|
)
|
|
577
605
|
end
|
|
578
606
|
end
|
|
@@ -55,6 +55,11 @@ module Rubino
|
|
|
55
55
|
return "Error: file_path is required" if path.empty?
|
|
56
56
|
|
|
57
57
|
expanded = File.expand_path(path)
|
|
58
|
+
# Like summarize_file, vision sends the raw bytes off to the auxiliary
|
|
59
|
+
# LLM, so an out-of-workspace image must be DENIED rather than read and
|
|
60
|
+
# exfiltrated. Checked before existence so a file outside the sandbox
|
|
61
|
+
# isn't even probed for presence (r5 MF-1 / r5c NEW-2).
|
|
62
|
+
return outside_workspace_message(path) if outside_workspace?(expanded)
|
|
58
63
|
return "Error: file not found: #{path}" unless File.exist?(expanded)
|
|
59
64
|
return "Error: not a regular file: #{path}" unless File.file?(expanded)
|
|
60
65
|
|
|
@@ -58,15 +58,16 @@ module Rubino
|
|
|
58
58
|
def fetch_url(url, format:, redirects: 5)
|
|
59
59
|
return "Error: Too many redirects" if redirects <= 0
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
# Default a bare host to https:// (previous behaviour) before
|
|
62
|
+
# validating, so the SSRF guard sees a complete URL with a scheme.
|
|
63
|
+
url = "https://#{url}" unless URI.parse(url).scheme
|
|
64
|
+
safe = Rubino::Security::UrlSafety.validate!(url)
|
|
65
|
+
uri = safe[:uri]
|
|
63
66
|
|
|
64
|
-
http =
|
|
65
|
-
http.use_ssl = (uri.scheme == "https")
|
|
66
|
-
http.open_timeout = TIMEOUT
|
|
67
|
-
http.read_timeout = TIMEOUT
|
|
67
|
+
http = build_http(uri, safe[:addresses].first)
|
|
68
68
|
|
|
69
69
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
70
|
+
request["Host"] = uri.host
|
|
70
71
|
request["User-Agent"] = "Rubino/#{Rubino::VERSION}"
|
|
71
72
|
request["Accept"] = "text/html,text/plain,application/json"
|
|
72
73
|
|
|
@@ -74,7 +75,10 @@ module Rubino
|
|
|
74
75
|
|
|
75
76
|
case response
|
|
76
77
|
when Net::HTTPRedirection
|
|
77
|
-
|
|
78
|
+
# Re-validate the redirect target from scratch (resolve + IP check);
|
|
79
|
+
# never trust the Location header to point somewhere safe (SSRF).
|
|
80
|
+
next_url = absolute_redirect(uri, response["location"])
|
|
81
|
+
fetch_url(next_url, format: format, redirects: redirects - 1)
|
|
78
82
|
when Net::HTTPSuccess
|
|
79
83
|
content_type = response["content-type"].to_s
|
|
80
84
|
return binary_refusal(url, content_type) if binary_content_type?(content_type)
|
|
@@ -97,10 +101,38 @@ module Rubino
|
|
|
97
101
|
else
|
|
98
102
|
"Error: HTTP #{response.code} - #{response.message}"
|
|
99
103
|
end
|
|
104
|
+
rescue Rubino::Security::UrlSafety::BlockedURLError => e
|
|
105
|
+
"Refused for safety: #{e.message}"
|
|
100
106
|
rescue StandardError => e
|
|
101
107
|
"Error fetching URL: #{e.message}"
|
|
102
108
|
end
|
|
103
109
|
|
|
110
|
+
# Build a Net::HTTP pinned to a validated IP so a DNS-rebinding server
|
|
111
|
+
# can't swap in a private address between our check and connect(). The
|
|
112
|
+
# Host header (set by the caller) and TLS SNI/verification still use the
|
|
113
|
+
# original hostname.
|
|
114
|
+
def build_http(uri, connect_ip)
|
|
115
|
+
http = Net::HTTP.new(connect_ip, uri.port)
|
|
116
|
+
http.use_ssl = (uri.scheme == "https")
|
|
117
|
+
if http.use_ssl?
|
|
118
|
+
http.ipaddr = connect_ip
|
|
119
|
+
# Net::HTTP derives SNI and certificate verification from #address;
|
|
120
|
+
# restore it to the hostname so TLS validates against the cert.
|
|
121
|
+
http.instance_variable_set(:@address, uri.host)
|
|
122
|
+
end
|
|
123
|
+
http.open_timeout = TIMEOUT
|
|
124
|
+
http.read_timeout = TIMEOUT
|
|
125
|
+
http
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Resolve a (possibly relative) Location header against the current URL,
|
|
129
|
+
# so the per-hop SSRF re-validation always runs on an absolute URL.
|
|
130
|
+
def absolute_redirect(current_uri, location)
|
|
131
|
+
URI.join(current_uri.to_s, location.to_s).to_s
|
|
132
|
+
rescue StandardError
|
|
133
|
+
location.to_s
|
|
134
|
+
end
|
|
135
|
+
|
|
104
136
|
BINARY_TYPE_PATTERNS = [
|
|
105
137
|
%r{\Aapplication/(pdf|zip|x-tar|x-gzip|x-bzip2|x-7z-compressed|x-rar|octet-stream|x-msdownload|vnd\.openxmlformats|vnd\.ms-)},
|
|
106
138
|
%r{\Aimage/}, %r{\Aaudio/}, %r{\Avideo/},
|
|
@@ -7,7 +7,22 @@ require "json"
|
|
|
7
7
|
module Rubino
|
|
8
8
|
module Tools
|
|
9
9
|
# Tool for performing web searches via external search APIs.
|
|
10
|
-
#
|
|
10
|
+
#
|
|
11
|
+
# Backends, in priority order:
|
|
12
|
+
# 1. Tavily (TAVILY_API_KEY) — high-quality, preferred
|
|
13
|
+
# 2. SearXNG (SEARXNG_URL) — self-hosted, full web index
|
|
14
|
+
# 3. DuckDuckGo Instant Answer JSON — keyless DEFAULT (no key needed)
|
|
15
|
+
#
|
|
16
|
+
# Why not scrape html/lite.duckduckgo.com keyless? DuckDuckGo now serves
|
|
17
|
+
# an anomaly/bot-challenge page (zero results) to datacenter egress IPs,
|
|
18
|
+
# so the old single-regex HTML scrape returned "No results" 100% of the
|
|
19
|
+
# time — a silent failure that looked like success. The Instant Answer
|
|
20
|
+
# JSON API (api.duckduckgo.com) is keyless, returns structured JSON, and
|
|
21
|
+
# is NOT bot-walled, so it is the robust keyless default. Its coverage is
|
|
22
|
+
# narrower (topic/entity answers, not a full web index): when it yields
|
|
23
|
+
# nothing we degrade to an EXPLICIT "search unavailable" message that
|
|
24
|
+
# points the user at TAVILY_API_KEY / SEARXNG_URL — never a silent
|
|
25
|
+
# zero-results-that-looks-like-a-real-answer.
|
|
11
26
|
class WebSearchTool < Base
|
|
12
27
|
def name
|
|
13
28
|
"websearch"
|
|
@@ -106,51 +121,96 @@ module Rubino
|
|
|
106
121
|
results.empty? ? "No results found for: #{query}" : results.join("\n\n")
|
|
107
122
|
end
|
|
108
123
|
|
|
109
|
-
# DuckDuckGo
|
|
124
|
+
# Keyless default: DuckDuckGo Instant Answer JSON API.
|
|
125
|
+
# No API key, no bot-challenge for datacenter IPs. Defensive parse over
|
|
126
|
+
# Abstract / Results / RelatedTopics; explicit "unavailable" on no data.
|
|
110
127
|
def search_ddg(query, max_results)
|
|
111
|
-
uri = URI("https://
|
|
112
|
-
|
|
128
|
+
uri = URI("https://api.duckduckgo.com/")
|
|
129
|
+
uri.query = URI.encode_www_form(
|
|
130
|
+
q: query,
|
|
131
|
+
format: "json",
|
|
132
|
+
no_html: 1,
|
|
133
|
+
no_redirect: 1,
|
|
134
|
+
skip_disambig: 0,
|
|
135
|
+
t: "rubino"
|
|
136
|
+
)
|
|
113
137
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
request["User-Agent"] = "Rubino/#{Rubino::VERSION}"
|
|
118
|
-
request.body = body
|
|
138
|
+
body = get_json(uri)
|
|
139
|
+
data = parse_json(body)
|
|
140
|
+
return ddg_unavailable(query, "could not parse search response") if data.nil?
|
|
119
141
|
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
results = ddg_results(data, max_results)
|
|
143
|
+
return results.join("\n\n") unless results.empty?
|
|
144
|
+
|
|
145
|
+
ddg_unavailable(query, "no instant-answer results")
|
|
122
146
|
end
|
|
123
147
|
|
|
124
|
-
|
|
148
|
+
# Build a result list from the Instant Answer payload, most-specific
|
|
149
|
+
# signal first: the Abstract (a direct answer), then Results (official
|
|
150
|
+
# site links), then RelatedTopics (related entities). Topic groups
|
|
151
|
+
# (which nest a "Topics" array) are flattened.
|
|
152
|
+
def ddg_results(data, max_results)
|
|
125
153
|
results = []
|
|
126
154
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if clean_url.include?("uddg=")
|
|
135
|
-
clean_url = begin
|
|
136
|
-
URI.decode_www_form_component(clean_url.match(/uddg=([^&]+)/)[1])
|
|
137
|
-
rescue StandardError
|
|
138
|
-
clean_url
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
results << format_result(clean_title, clean_url, clean_snippet)
|
|
143
|
-
break if results.size >= max_results
|
|
155
|
+
abstract = data["AbstractText"].to_s.strip
|
|
156
|
+
abstract = data["Abstract"].to_s.strip if abstract.empty?
|
|
157
|
+
if !abstract.empty? && data["AbstractURL"].to_s.strip != ""
|
|
158
|
+
results << format_result(
|
|
159
|
+
data["Heading"].to_s.strip.empty? ? "Answer" : data["Heading"].to_s.strip,
|
|
160
|
+
data["AbstractURL"], abstract
|
|
161
|
+
)
|
|
144
162
|
end
|
|
145
163
|
|
|
146
|
-
|
|
164
|
+
ddg_topics(data["Results"]).each { |t| results << ddg_topic_result(t) }
|
|
165
|
+
ddg_topics(data["RelatedTopics"]).each { |t| results << ddg_topic_result(t) }
|
|
166
|
+
|
|
167
|
+
results.compact.first(max_results)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Flatten DDG's RelatedTopics: some entries are leaf topics (have
|
|
171
|
+
# FirstURL), others are category groups carrying a nested "Topics" array.
|
|
172
|
+
def ddg_topics(raw)
|
|
173
|
+
Array(raw).flat_map do |entry|
|
|
174
|
+
next [] unless entry.is_a?(Hash)
|
|
175
|
+
|
|
176
|
+
entry.key?("Topics") ? Array(entry["Topics"]) : [entry]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def ddg_topic_result(topic)
|
|
181
|
+
return nil unless topic.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
url = topic["FirstURL"].to_s.strip
|
|
184
|
+
text = topic["Text"].to_s.strip
|
|
185
|
+
return nil if url.empty? || text.empty?
|
|
186
|
+
|
|
187
|
+
# The leading sentence of Text doubles as the title; keep the whole
|
|
188
|
+
# thing as the snippet so no information is lost.
|
|
189
|
+
title = text.split(" - ", 2).first.to_s.strip
|
|
190
|
+
title = text if title.empty?
|
|
191
|
+
format_result(title, url, text)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def ddg_unavailable(query, reason)
|
|
195
|
+
"Web search unavailable for \"#{query}\" (#{reason}).\n\n" \
|
|
196
|
+
"The keyless DuckDuckGo Instant Answer backend only covers topic/" \
|
|
197
|
+
"entity queries and returned nothing for this one. For full web " \
|
|
198
|
+
"search, set TAVILY_API_KEY (https://tavily.com) or SEARXNG_URL to " \
|
|
199
|
+
"a SearXNG instance, then retry."
|
|
147
200
|
end
|
|
148
201
|
|
|
149
202
|
def format_result(title, url, snippet)
|
|
150
203
|
"**#{title}**\n#{url}\n#{snippet}"
|
|
151
204
|
end
|
|
152
205
|
|
|
206
|
+
def parse_json(body)
|
|
207
|
+
JSON.parse(body)
|
|
208
|
+
rescue JSON::ParserError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
153
212
|
def post_json(uri, body)
|
|
213
|
+
Rubino::Security::UrlSafety.validate!(uri.to_s)
|
|
154
214
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
155
215
|
http.use_ssl = (uri.scheme == "https")
|
|
156
216
|
http.open_timeout = 10
|
|
@@ -164,6 +224,7 @@ module Rubino
|
|
|
164
224
|
end
|
|
165
225
|
|
|
166
226
|
def get_json(uri)
|
|
227
|
+
Rubino::Security::UrlSafety.validate!(uri.to_s)
|
|
167
228
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
168
229
|
http.use_ssl = (uri.scheme == "https")
|
|
169
230
|
http.open_timeout = 10
|
|
@@ -171,6 +232,7 @@ module Rubino
|
|
|
171
232
|
|
|
172
233
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
173
234
|
request["Accept"] = "application/json"
|
|
235
|
+
request["User-Agent"] = "Rubino/#{Rubino::VERSION}"
|
|
174
236
|
|
|
175
237
|
http.request(request).body
|
|
176
238
|
end
|
|
@@ -40,13 +40,32 @@ module Rubino
|
|
|
40
40
|
|
|
41
41
|
return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty?
|
|
42
42
|
|
|
43
|
-
expanded =
|
|
43
|
+
expanded = expand_workspace_path(file_path)
|
|
44
|
+
# SECRET/credential writes (#446) are no longer HARD-refused here — they
|
|
45
|
+
# are gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask): an
|
|
46
|
+
# APPROVED write to your .env actually writes, a denied/headless one
|
|
47
|
+
# never reaches #call. The workspace sandbox below is unchanged.
|
|
44
48
|
return workspace_violation_message(file_path) unless within_workspace?(expanded)
|
|
45
49
|
|
|
46
|
-
FileUtils.mkdir_p(File.dirname(expanded))
|
|
47
|
-
|
|
48
50
|
existed = File.exist?(expanded)
|
|
49
|
-
|
|
51
|
+
# Read-before-overwrite guard (r5 MF-2, Claude Code's rule): writing
|
|
52
|
+
# over an EXISTING file requires that the model read it this session, so
|
|
53
|
+
# a blind `write` can't silently clobber content the model never saw
|
|
54
|
+
# (the near-data-loss path). NEW files skip the guard. No tracker
|
|
55
|
+
# injected → no guard (single-tool unit tests / one-shot MCP).
|
|
56
|
+
if existed && (guard = overwrite_guard_error(expanded, file_path))
|
|
57
|
+
return guard
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(expanded))
|
|
61
|
+
# Crash-safe write: temp-in-same-dir + fsync + atomic rename, so a
|
|
62
|
+
# SIGINT/SIGTERM/OOM-kill mid-write leaves the ORIGINAL file intact
|
|
63
|
+
# rather than a torn/truncated one (HIGH-1). The bare File.write here
|
|
64
|
+
# could be cut mid-flush, destroying the user's existing content.
|
|
65
|
+
Util::AtomicFile.write_atomic(expanded, content)
|
|
66
|
+
# Refresh-on-own-write so a later edit of this just-written file passes
|
|
67
|
+
# the read-gate (r5 B2) and a re-read sees it as authoritative.
|
|
68
|
+
@read_tracker&.note_write(expanded, content)
|
|
50
69
|
|
|
51
70
|
verb = existed ? "overwrote" : "created"
|
|
52
71
|
bytes = content.to_s.bytesize
|
data/lib/rubino/ui/api.rb
CHANGED
|
@@ -52,6 +52,15 @@ module Rubino
|
|
|
52
52
|
!@gate.nil? && !@recorder.nil?
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# ToolExecutor reads this to decide whether a tool needing approval can be
|
|
56
|
+
# put in front of a human (#260). The API adapter CAN — via the HTTP
|
|
57
|
+
# ApprovalGate — but only when a gate + recorder are wired; a gate-less
|
|
58
|
+
# embed/test run has no one to answer, so it fails closed too instead of
|
|
59
|
+
# silently auto-approving a write/shell command.
|
|
60
|
+
def interactive?
|
|
61
|
+
blocking_human_input?
|
|
62
|
+
end
|
|
63
|
+
|
|
55
64
|
def info(message) = emit_event(:info, message: message)
|
|
56
65
|
|
|
57
66
|
def success(message) = emit_event(:success, message: message)
|
|
@@ -81,7 +90,7 @@ module Rubino
|
|
|
81
90
|
end
|
|
82
91
|
|
|
83
92
|
def tool_body(text, kind: :plain) = emit_event(:tool_body, text: text, kind: kind)
|
|
84
|
-
def tool_chunk(name, chunk) = emit_event(:tool_chunk, name: name, chunk: chunk)
|
|
93
|
+
def tool_chunk(name, chunk, kind: :plain) = emit_event(:tool_chunk, name: name, chunk: chunk, kind: kind)
|
|
85
94
|
def tool_finished(name, result: nil) = emit_event(:tool_finished, name: name)
|
|
86
95
|
def compression_started(at: nil) = emit_event(:compression_started, at: at)
|
|
87
96
|
|
data/lib/rubino/ui/base.rb
CHANGED
|
@@ -139,6 +139,17 @@ module Rubino
|
|
|
139
139
|
raise NotImplementedError, "#{self.class}#confirm not implemented"
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
+
# True when this adapter can actually put an approval prompt in front of a
|
|
143
|
+
# human and block for an answer — a real interactive terminal (UI::CLI) or
|
|
144
|
+
# the HTTP approval gate (UI::API). The headless one-shot adapter
|
|
145
|
+
# (UI::Null) returns false, which is the signal ToolExecutor uses to FAIL
|
|
146
|
+
# CLOSED: a tool that needs approval is DENIED rather than auto-run, since
|
|
147
|
+
# there is no one to ask (the security floor behind #260). Default true so
|
|
148
|
+
# any custom adapter that hosts a human keeps the prompt path.
|
|
149
|
+
def interactive?
|
|
150
|
+
true
|
|
151
|
+
end
|
|
152
|
+
|
|
142
153
|
# A destructive yes/No confirm, default No — distinct from the tool-approval
|
|
143
154
|
# #confirm above (#218). Used for the in-chat/CLI destructive verbs (session
|
|
144
155
|
# delete, memory forget), so only the CLI and Null adapters implement it;
|