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
|
@@ -34,6 +34,21 @@ module Rubino
|
|
|
34
34
|
# can emit it as plain text — never lost)
|
|
35
35
|
class StreamingMarkdown
|
|
36
36
|
FENCE_RE = /\A\s*```/
|
|
37
|
+
# An OPENING fence captures its run of backticks (≥3) and any info string
|
|
38
|
+
# (the language tag) that follows. The CommonMark rule we lean on: a fenced
|
|
39
|
+
# block is closed only by a bare fence — no info string — of AT LEAST as
|
|
40
|
+
# many backticks. That keeps a nested ```ruby inside an outer ```markdown
|
|
41
|
+
# from being mistaken for the close (it carries an info string), so the
|
|
42
|
+
# whole wrapped block stays one unit instead of mis-toggling (#264).
|
|
43
|
+
FENCE_OPEN_RE = /\A\s*(`{3,})\s*(\S.*)?\z/
|
|
44
|
+
FENCE_CLOSE_RE = /\A\s*(`{3,})\s*\z/
|
|
45
|
+
# Info strings that mean "this fence WRAPS markdown" — the model boxed a
|
|
46
|
+
# whole answer (which itself contains nested ```lang fences) in an outer
|
|
47
|
+
# ```markdown / ```md. For those, and ONLY those, we track fence-nesting
|
|
48
|
+
# DEPTH: a nested fence's bare close must not be mistaken for the wrapper's
|
|
49
|
+
# close (T1), so the whole wrapped answer stays ONE block. The renderer
|
|
50
|
+
# re-renders such a body AS markdown (MarkdownRenderer::MARKDOWN_FENCE_LANGS).
|
|
51
|
+
MARKDOWN_FENCE_LANGS = %w[markdown md].freeze
|
|
37
52
|
# An ordered ("1. ", "2) ") or unordered ("- ", "* ", "+ ") list item.
|
|
38
53
|
# Used so a loose list (blank lines BETWEEN items) is kept as ONE block
|
|
39
54
|
# instead of being split per-item: each split item was re-rendered on its
|
|
@@ -45,6 +60,13 @@ module Rubino
|
|
|
45
60
|
@pending = +"" # un-newlined remainder (the live tail-in-progress line)
|
|
46
61
|
@block = [] # completed lines accumulated for the current block
|
|
47
62
|
@in_fence = false
|
|
63
|
+
@fence_len = 0 # backtick count of the OPEN fence (close needs ≥ this many)
|
|
64
|
+
# When the OPEN fence is a ```markdown / ```md wrapper, we track nesting
|
|
65
|
+
# DEPTH (starts at 1 on open): nested opening fences ++ it, bare closes of
|
|
66
|
+
# ≥ @fence_len -- it, and the block completes only when it returns to 0.
|
|
67
|
+
# nil = the open fence is a PLAIN code fence (no nesting; closes on the
|
|
68
|
+
# first bare fence of ≥ @fence_len), the CommonMark default (T1).
|
|
69
|
+
@fence_depth = nil
|
|
48
70
|
@in_list = false # current block is a markdown list (keep loose items together)
|
|
49
71
|
@blanks = 0 # blank lines buffered inside a list, re-emitted iff it continues
|
|
50
72
|
end
|
|
@@ -109,6 +131,8 @@ module Rubino
|
|
|
109
131
|
text = @block.join("\n")
|
|
110
132
|
@block = []
|
|
111
133
|
@in_fence = false
|
|
134
|
+
@fence_len = 0
|
|
135
|
+
@fence_depth = nil
|
|
112
136
|
@in_list = false
|
|
113
137
|
@blanks = 0
|
|
114
138
|
text
|
|
@@ -121,15 +145,22 @@ module Rubino
|
|
|
121
145
|
def consume_line(line)
|
|
122
146
|
if @in_fence
|
|
123
147
|
@block << line
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
148
|
+
return nil unless fence_line_closes?(line)
|
|
149
|
+
|
|
150
|
+
@in_fence = false
|
|
151
|
+
@fence_len = 0
|
|
152
|
+
@fence_depth = nil
|
|
153
|
+
return take_block
|
|
129
154
|
end
|
|
130
155
|
|
|
131
|
-
if line.match
|
|
156
|
+
if (m = line.match(FENCE_OPEN_RE)) # opening fence starts a code block
|
|
132
157
|
@in_fence = true
|
|
158
|
+
@fence_len = m[1].length
|
|
159
|
+
# A ```markdown / ```md wrapper arms nesting-DEPTH tracking (starts at
|
|
160
|
+
# 1); its body's nested ```lang fences must not close it early (T1).
|
|
161
|
+
# Any other language (or none) is a plain code fence: depth stays nil,
|
|
162
|
+
# so it closes on the first bare fence of ≥ @fence_len (CommonMark).
|
|
163
|
+
@fence_depth = MARKDOWN_FENCE_LANGS.include?(m[2].to_s.strip.downcase) ? 1 : nil
|
|
133
164
|
flush_blanks
|
|
134
165
|
@block << line
|
|
135
166
|
return nil
|
|
@@ -167,6 +198,28 @@ module Rubino
|
|
|
167
198
|
nil
|
|
168
199
|
end
|
|
169
200
|
|
|
201
|
+
# Does this line (already appended to the open fence's block) CLOSE that
|
|
202
|
+
# fence? For a PLAIN code fence (@fence_depth nil) the first bare fence of
|
|
203
|
+
# ≥ the opening run closes it — the CommonMark default. For a ```markdown /
|
|
204
|
+
# ```md WRAPPER we track nesting depth: a nested OPENING fence (carries an
|
|
205
|
+
# info string, e.g. ```ruby) deepens it, a bare close of ≥ @fence_len
|
|
206
|
+
# un-nests one level, and only the close that returns depth to 0 ends the
|
|
207
|
+
# block — so the wrapper's nested fences never close it early (T1).
|
|
208
|
+
def fence_line_closes?(line)
|
|
209
|
+
if @fence_depth.nil?
|
|
210
|
+
m = line.match(FENCE_CLOSE_RE)
|
|
211
|
+
return !!(m && m[1].length >= @fence_len)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
if (m = line.match(FENCE_CLOSE_RE)) && m[1].length >= @fence_len
|
|
215
|
+
@fence_depth -= 1
|
|
216
|
+
return @fence_depth <= 0
|
|
217
|
+
end
|
|
218
|
+
# A nested opening fence (```lang, with an info string) deepens the wrapper.
|
|
219
|
+
@fence_depth += 1 if line.match?(FENCE_OPEN_RE)
|
|
220
|
+
false
|
|
221
|
+
end
|
|
222
|
+
|
|
170
223
|
# Re-emit blank lines buffered inside a continuing list so loose-list
|
|
171
224
|
# spacing is preserved in the committed block text.
|
|
172
225
|
def flush_blanks
|
|
@@ -28,7 +28,8 @@ module Rubino
|
|
|
28
28
|
# single in-place line per subagent (`▸ sa_… · explore · running · N tools ·
|
|
29
29
|
# Ns · <last_activity>`) that updates without scrolling — see UI::CLI
|
|
30
30
|
# #set_subagent_cards / UI::SubagentCards. The /agents <id> drill-in tails the
|
|
31
|
-
# same registry ring for the live recent: list (#71)
|
|
31
|
+
# same registry ring for the live recent: list (#71) and the entry's
|
|
32
|
+
# output_tail — fed by #tool_chunk — for the live output: block (#5).
|
|
32
33
|
#
|
|
33
34
|
# The view is wired with the entry id at construction (TaskTool builds it per
|
|
34
35
|
# background run). With no id (legacy/foreground synchronous path, tests) it
|
|
@@ -124,10 +125,20 @@ module Rubino
|
|
|
124
125
|
end
|
|
125
126
|
end
|
|
126
127
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
128
|
+
# Card mode: append the streamed chunk to the entry's bounded output tail —
|
|
129
|
+
# the live output: block the /agents <id> watch tails while THIS tool runs
|
|
130
|
+
# (#5). Registry-only, NO card repaint: a chatty shell streams a chunk per
|
|
131
|
+
# line and repainting the cards per chunk would flood the parent terminal
|
|
132
|
+
# (the watch drill-in re-reads the tail on its own tick). Legacy mode
|
|
133
|
+
# stays quiet (the start/finish rows already say what ran).
|
|
134
|
+
def tool_chunk(_name, chunk, kind: :plain)
|
|
135
|
+
Tools::BackgroundTasks.instance.record_tool_output(@entry_id, chunk) if card_mode?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# tool_body: the END-OF-CALL preview of a NON-streaming tool (the executor
|
|
139
|
+
# skips it when the tool streamed via #tool_chunk). Useless for the live
|
|
140
|
+
# tail — tool_finished wipes the buffer right after — so it stays quiet.
|
|
129
141
|
def tool_body(_text, kind: :plain); end
|
|
130
|
-
def tool_chunk(_name, _chunk); end
|
|
131
142
|
|
|
132
143
|
# --- Suppressed: the child's prose / token stream ---------------------
|
|
133
144
|
|
|
@@ -185,6 +196,20 @@ module Rubino
|
|
|
185
196
|
false
|
|
186
197
|
end
|
|
187
198
|
|
|
199
|
+
# Whether this nested view can actually put an approval in front of a
|
|
200
|
+
# human and block for an answer. ONLY the card-mode background path with a
|
|
201
|
+
# wired @approve handler can (it parks the child on a per-entry gate and a
|
|
202
|
+
# /agents <id> decision resolves it). The foreground/legacy path (no
|
|
203
|
+
# @approve) cannot — there is no one to ask. Returning false there makes
|
|
204
|
+
# the child's ToolExecutor FAIL CLOSED with the honest "needs approval but
|
|
205
|
+
# no interactive session — use --yolo / allowlist it" block (#260) instead
|
|
206
|
+
# of routing through #confirm's auto-deny, which the model otherwise reads
|
|
207
|
+
# as "the user denied it" though no human ever decided (#419). With a
|
|
208
|
+
# handler we stay interactive so the gate path is reached as before.
|
|
209
|
+
def interactive?
|
|
210
|
+
!@approve.nil?
|
|
211
|
+
end
|
|
212
|
+
|
|
188
213
|
# No interactive clarification mid-delegation either.
|
|
189
214
|
def ask(_prompt)
|
|
190
215
|
nil
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module UI
|
|
5
|
+
# Shared vocabulary for the concise `name hint` tool label used across
|
|
6
|
+
# adapters — the interactive `● edit foo.rb` card open-row (UI::CLI) AND the
|
|
7
|
+
# non-interactive one-shot stderr trace (UI::HeadlessTrace) draw their hint
|
|
8
|
+
# from here so the two never drift. The hint is the single most-identifying
|
|
9
|
+
# argument (pattern / file_path / path / command), secrets-masked and
|
|
10
|
+
# terminal-sanitized so an untrusted filename/command can never drive the
|
|
11
|
+
# terminal from a label row (R3C-1, CWE-150).
|
|
12
|
+
module ToolLabel
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Picks the most-identifying [key, value] pair from a tool's arguments,
|
|
16
|
+
# in priority order. Returns nil when none of the known keys carry a value.
|
|
17
|
+
def pick_hint(arguments)
|
|
18
|
+
return nil unless arguments.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
%i[pattern file_path path command].each do |k|
|
|
21
|
+
v = arguments[k] || arguments[k.to_s]
|
|
22
|
+
return [k, v] if v && !v.to_s.empty?
|
|
23
|
+
end
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The concise, sanitized hint string for a tool's arguments — the first
|
|
28
|
+
# line, secrets-masked, escape-stripped, truncated to `max` chars. Returns
|
|
29
|
+
# nil when there's no identifying argument. `verbose:` raises the cap so a
|
|
30
|
+
# `--verbose` one-shot trace can show fuller args.
|
|
31
|
+
def hint(arguments, max: 60, verbose: false)
|
|
32
|
+
picked = pick_hint(arguments)
|
|
33
|
+
return nil unless picked
|
|
34
|
+
|
|
35
|
+
raw_key, raw_value = picked
|
|
36
|
+
masked = Util::SecretsMask.mask_value(raw_value, key: raw_key).to_s
|
|
37
|
+
clean = Util::Output.sanitize_terminal(masked)
|
|
38
|
+
first = clean.lines.first.to_s.strip
|
|
39
|
+
cap = verbose ? max * 4 : max
|
|
40
|
+
first.length > cap ? "#{first[0, cap - 3]}..." : first
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The full `name hint` label (e.g. `edit foo.rb`, `bash npm test`,
|
|
44
|
+
# `read README.md`). Falls back to the bare tool name when the tool has no
|
|
45
|
+
# identifying argument.
|
|
46
|
+
def label(name, arguments, max: 60, verbose: false)
|
|
47
|
+
h = hint(arguments, max: max, verbose: verbose)
|
|
48
|
+
h && !h.empty? ? "#{name} #{h}" : name.to_s
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/rubino/update_check.rb
CHANGED
|
@@ -42,6 +42,12 @@ module Rubino
|
|
|
42
42
|
# notice must not leak through either (#66).
|
|
43
43
|
def notice_from_cache
|
|
44
44
|
return nil if opted_out?
|
|
45
|
+
# Never render off a poisoned cache: a future-dated checked_at (the
|
|
46
|
+
# "v99.0.0" phantom came from {"checked_at":"2099-…","latest":"99.0.0"})
|
|
47
|
+
# is suppressed here, and stale? treats it as stale so a refresh
|
|
48
|
+
# repopulates it for next boot. A legitimately >24h-old cache is still
|
|
49
|
+
# trusted for the notice — only forward-dated / corrupt entries are poison.
|
|
50
|
+
return nil if poisoned_cache?
|
|
45
51
|
|
|
46
52
|
latest = cached_latest
|
|
47
53
|
return nil unless newer?(latest)
|
|
@@ -59,6 +65,7 @@ module Rubino
|
|
|
59
65
|
return nil unless stale?
|
|
60
66
|
|
|
61
67
|
Thread.new do
|
|
68
|
+
Thread.current[:rubino_update_refresh] = true
|
|
62
69
|
latest = fetch_latest
|
|
63
70
|
write_cache(latest) if latest
|
|
64
71
|
rescue StandardError
|
|
@@ -138,14 +145,42 @@ module Rubino
|
|
|
138
145
|
ENV["CI"].to_s.strip.empty?
|
|
139
146
|
end
|
|
140
147
|
|
|
141
|
-
# True when the cache is missing
|
|
148
|
+
# True when the cache is missing, unreadable, its checked_at is older than
|
|
149
|
+
# 24h — AND, crucially, also true when checked_at is in the FUTURE.
|
|
150
|
+
#
|
|
151
|
+
# A future checked_at (clock skew, a hand-edited or corrupt cache, a bogus
|
|
152
|
+
# fixture) yields a NEGATIVE age, which `>= 24h` is never true for — so the
|
|
153
|
+
# old code latched the poisoned cache forever and re-rendered its bogus
|
|
154
|
+
# `latest` on every boot (the live "rubino v99.0.0 available" phantom, from
|
|
155
|
+
# a cache pinned at {"checked_at":"2099-…","latest":"99.0.0"}). Treating a
|
|
156
|
+
# negative age as stale forces a re-check, letting the notifier self-heal.
|
|
142
157
|
def stale?
|
|
143
|
-
|
|
158
|
+
age = cache_age
|
|
159
|
+
return true if age.nil?
|
|
160
|
+
|
|
161
|
+
age.negative? || age >= CHECK_INTERVAL
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# A cache whose checked_at is in the future is poison: it cannot reflect a
|
|
165
|
+
# real check and must never drive the boot notice. Missing/unreadable caches
|
|
166
|
+
# are NOT "poisoned" (they simply yield no notice); only a forward-dated or
|
|
167
|
+
# unparseable timestamp is.
|
|
168
|
+
def poisoned_cache?
|
|
169
|
+
return false unless File.exist?(cache_path)
|
|
170
|
+
|
|
171
|
+
age = cache_age
|
|
172
|
+
age.nil? || age.negative?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Seconds since the cached checked_at (negative if it is in the future), or
|
|
176
|
+
# nil when the cache is missing, unreadable, or has an unparseable timestamp.
|
|
177
|
+
def cache_age
|
|
178
|
+
return nil unless File.exist?(cache_path)
|
|
144
179
|
|
|
145
180
|
checked_at = JSON.parse(File.read(cache_path))["checked_at"]
|
|
146
|
-
Time.now.utc - Time.parse(checked_at)
|
|
181
|
+
Time.now.utc - Time.parse(checked_at)
|
|
147
182
|
rescue StandardError
|
|
148
|
-
|
|
183
|
+
nil
|
|
149
184
|
end
|
|
150
185
|
|
|
151
186
|
# ---- update mechanics -------------------------------------------------
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Util
|
|
7
|
+
# Crash- and concurrency-safe writes to a shared state file.
|
|
8
|
+
#
|
|
9
|
+
# Several files in rubino are read-modify-written by commands the user can
|
|
10
|
+
# legitimately run in parallel: the skills provenance ledger
|
|
11
|
+
# (`.sources.json`), the YAML config (`config.yml`). A plain
|
|
12
|
+
# read → mutate → `File.write` has two defects under concurrency:
|
|
13
|
+
#
|
|
14
|
+
# * lost update — two writers read the same base, each writes its own
|
|
15
|
+
# mutation, the second clobbers the first (e.g. 4 parallel
|
|
16
|
+
# `skills install` → only the last 2 ledger entries survive); and
|
|
17
|
+
# * torn file — a writer interrupted mid-`write` (or interleaved with a
|
|
18
|
+
# reader) leaves a half-written, unparseable file that bricks every
|
|
19
|
+
# later command (e.g. corrupt `config.yml`).
|
|
20
|
+
#
|
|
21
|
+
# `update` fixes both with the standard POSIX recipe:
|
|
22
|
+
#
|
|
23
|
+
# 1. `flock(LOCK_EX)` on a dedicated `<target>.lock` sibling — a separate
|
|
24
|
+
# file so the lock outlives any rename/replace of the data file and a
|
|
25
|
+
# reader's `LOCK_SH` never races the writer's rename of the data file
|
|
26
|
+
# itself. The whole read-modify-write runs under the lock, so writers
|
|
27
|
+
# serialize and none reads a base another is about to overwrite.
|
|
28
|
+
# 2. write the new contents to a temp file IN THE SAME DIRECTORY (so the
|
|
29
|
+
# final rename is same-filesystem, hence atomic), then `fsync` it.
|
|
30
|
+
# 3. `File.rename(tmp, target)` — atomic on POSIX: a concurrent reader sees
|
|
31
|
+
# either the whole old file or the whole new one, never a torn mix.
|
|
32
|
+
# 4. `fsync` the directory so the rename survives a crash.
|
|
33
|
+
#
|
|
34
|
+
# Readers that want a consistent snapshot can take `LOCK_SH` over the same
|
|
35
|
+
# lock via `.read_shared`; a plain `File.read` is also safe against tearing
|
|
36
|
+
# because the rename is atomic (it just may observe a slightly stale file).
|
|
37
|
+
module AtomicFile
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# Serialized read-modify-write of +path+. Yields the current file contents
|
|
41
|
+
# (a String, or nil when the file doesn't exist yet) while holding an
|
|
42
|
+
# exclusive lock, and atomically writes back whatever the block returns.
|
|
43
|
+
# If the block returns nil the file is left untouched (no-op write).
|
|
44
|
+
# Returns the block's value.
|
|
45
|
+
def update(path)
|
|
46
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
47
|
+
with_lock(path, File::LOCK_EX) do
|
|
48
|
+
current = File.file?(path) ? File.read(path) : nil
|
|
49
|
+
new_contents = yield(current)
|
|
50
|
+
write_atomic(path, new_contents) unless new_contents.nil?
|
|
51
|
+
new_contents
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reads +path+ under a shared lock (so it can't observe a concurrent
|
|
56
|
+
# writer's intermediate state). Returns the contents, or nil when absent.
|
|
57
|
+
def read_shared(path)
|
|
58
|
+
return nil unless File.file?(path)
|
|
59
|
+
|
|
60
|
+
with_lock(path, File::LOCK_SH) do
|
|
61
|
+
File.file?(path) ? File.read(path) : nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Write +contents+ to +path+ via temp-file + atomic rename, fsyncing the
|
|
66
|
+
# temp file and the directory. Standalone (no lock) for callers that
|
|
67
|
+
# already hold one, or that only need crash-safety, not serialization.
|
|
68
|
+
def write_atomic(path, contents)
|
|
69
|
+
dir = File.dirname(path)
|
|
70
|
+
FileUtils.mkdir_p(dir)
|
|
71
|
+
# Preserve plain-write semantics on an EXISTING read-only file: a temp +
|
|
72
|
+
# rename would otherwise sidestep the file's own 0444 (the writable dir
|
|
73
|
+
# lets us swap it in), silently clobbering a file the user marked
|
|
74
|
+
# read-only. Refuse with the same EACCES a File.write would have raised.
|
|
75
|
+
existing_mode = File.exist?(path) ? File.stat(path).mode : nil
|
|
76
|
+
raise Errno::EACCES, path if existing_mode && !File.writable?(path)
|
|
77
|
+
|
|
78
|
+
tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.#{rand(1 << 32)}.tmp")
|
|
79
|
+
begin
|
|
80
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
|
|
81
|
+
f.write(contents)
|
|
82
|
+
f.flush
|
|
83
|
+
f.fsync
|
|
84
|
+
end
|
|
85
|
+
# Carry the original file's permission bits across the replace so an
|
|
86
|
+
# in-place edit doesn't silently re-chmod the user's file to 0600.
|
|
87
|
+
File.chmod(existing_mode & 0o777, tmp) if existing_mode
|
|
88
|
+
File.rename(tmp, path)
|
|
89
|
+
fsync_dir(dir)
|
|
90
|
+
ensure
|
|
91
|
+
FileUtils.rm_f(tmp)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def with_lock(path, mode)
|
|
96
|
+
lock_path = "#{path}.lock"
|
|
97
|
+
FileUtils.mkdir_p(File.dirname(lock_path))
|
|
98
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
99
|
+
lock.flock(mode)
|
|
100
|
+
yield
|
|
101
|
+
ensure
|
|
102
|
+
lock.flock(File::LOCK_UN)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# fsync the directory so the rename is durable. Best-effort: some
|
|
107
|
+
# platforms/filesystems refuse to open a dir for fsync (e.g. Windows),
|
|
108
|
+
# in which case durability of the rename degrades but correctness of the
|
|
109
|
+
# atomic swap is unaffected.
|
|
110
|
+
def fsync_dir(dir)
|
|
111
|
+
File.open(dir, &:fsync)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Util
|
|
7
|
+
# Consistent .gitignore-aware file filtering shared by the grep Ruby
|
|
8
|
+
# fallback and the glob tool (#375b/#375c).
|
|
9
|
+
#
|
|
10
|
+
# The PROBLEM: grep's ripgrep path honors .gitignore, but the Ruby fallback
|
|
11
|
+
# (`Dir.glob("**/*")`) did not — so `grep` returned a DIFFERENT, larger set
|
|
12
|
+
# (including build artifacts, node_modules, secrets in ignored files)
|
|
13
|
+
# depending on whether rg happened to be installed. Non-deterministic, and a
|
|
14
|
+
# leak of ignored content. The glob tool ignored .gitignore entirely too.
|
|
15
|
+
#
|
|
16
|
+
# The FIX: a single ignore oracle both non-rg paths consult, matching rg's
|
|
17
|
+
# default semantics as closely as a non-rg implementation can:
|
|
18
|
+
#
|
|
19
|
+
# * In a git repo, the canonical answer is `git ls-files --cached --others
|
|
20
|
+
# --exclude-standard` (tracked + untracked-but-not-ignored) — EXACTLY
|
|
21
|
+
# the set rg searches by default. We build the allowed-path set from it.
|
|
22
|
+
# * Outside a repo (or if git fails), fall back to a small built-in
|
|
23
|
+
# denylist of always-noise dirs (.git, node_modules, …) so behaviour is
|
|
24
|
+
# still deterministic and never leaks the VCS internals.
|
|
25
|
+
#
|
|
26
|
+
# Per-root results are cached for the life of the instance, so a single
|
|
27
|
+
# grep/glob call pays the git cost once.
|
|
28
|
+
class IgnoreRules
|
|
29
|
+
# Always-skipped dirs for the non-git fallback. Mirrors the set
|
|
30
|
+
# UI::CompletionSource uses so discovery is consistent across the tool.
|
|
31
|
+
FALLBACK_IGNORE_DIRS = %w[.git node_modules vendor tmp log .bundle .svn .hg __pycache__].freeze
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@allowed_cache = {}
|
|
35
|
+
@git_root_cache = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# True when +abs_path+ (an absolute file path) should be EXCLUDED from
|
|
39
|
+
# results given +root+ (the search base). Consistent across grep-fallback
|
|
40
|
+
# and glob: a path git ignores (or the fallback denylist matches) is
|
|
41
|
+
# ignored regardless of whether rg is installed.
|
|
42
|
+
def ignored?(abs_path, root)
|
|
43
|
+
allowed = allowed_set(root)
|
|
44
|
+
return fallback_ignored?(abs_path, root) if allowed.nil?
|
|
45
|
+
|
|
46
|
+
rel = relative(abs_path, root)
|
|
47
|
+
return true if rel.nil?
|
|
48
|
+
|
|
49
|
+
!allowed.include?(rel)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# The set of NON-ignored relative paths under +root+ per git, or nil when
|
|
55
|
+
# this isn't a git repo / git failed (caller falls back to the denylist).
|
|
56
|
+
# git ls-files is run from the repo root and paths are re-based onto +root+
|
|
57
|
+
# so a search rooted in a subdir still matches.
|
|
58
|
+
def allowed_set(root)
|
|
59
|
+
@allowed_cache.fetch(root) do
|
|
60
|
+
@allowed_cache[root] = build_allowed_set(root)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_allowed_set(root)
|
|
65
|
+
repo_root = git_root(root)
|
|
66
|
+
return nil if repo_root.nil?
|
|
67
|
+
|
|
68
|
+
out, status = Open3.capture2(
|
|
69
|
+
"git", "ls-files", "--cached", "--others", "--exclude-standard", "-z",
|
|
70
|
+
chdir: repo_root, err: File::NULL
|
|
71
|
+
)
|
|
72
|
+
return nil unless status.success?
|
|
73
|
+
|
|
74
|
+
set = Set.new
|
|
75
|
+
out.split("\0").each do |rel_to_repo|
|
|
76
|
+
next if rel_to_repo.empty?
|
|
77
|
+
|
|
78
|
+
abs = File.expand_path(rel_to_repo, repo_root)
|
|
79
|
+
rel = relative(abs, root)
|
|
80
|
+
set << rel if rel
|
|
81
|
+
end
|
|
82
|
+
set
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The git toplevel for +root+, or nil if not a repo. Cached per root.
|
|
88
|
+
def git_root(root)
|
|
89
|
+
@git_root_cache.fetch(root) do
|
|
90
|
+
out, status = Open3.capture2(
|
|
91
|
+
"git", "rev-parse", "--show-toplevel",
|
|
92
|
+
chdir: root, err: File::NULL
|
|
93
|
+
)
|
|
94
|
+
@git_root_cache[root] = status.success? ? out.strip : nil
|
|
95
|
+
end
|
|
96
|
+
rescue StandardError
|
|
97
|
+
@git_root_cache[root] = nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Non-git fallback: ignore by built-in noise-dir denylist on any path
|
|
101
|
+
# component, so behaviour stays deterministic without a repo.
|
|
102
|
+
def fallback_ignored?(abs_path, root)
|
|
103
|
+
rel = relative(abs_path, root) || File.basename(abs_path)
|
|
104
|
+
rel.split(File::SEPARATOR).any? { |part| FALLBACK_IGNORE_DIRS.include?(part) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Path of +abs_path+ relative to +root+, or nil when it's outside +root+.
|
|
108
|
+
def relative(abs_path, root)
|
|
109
|
+
abs = File.expand_path(abs_path)
|
|
110
|
+
base = File.expand_path(root)
|
|
111
|
+
return File.basename(abs) if abs == base
|
|
112
|
+
|
|
113
|
+
prefix = "#{base}#{File::SEPARATOR}"
|
|
114
|
+
return nil unless abs.start_with?(prefix)
|
|
115
|
+
|
|
116
|
+
abs[prefix.length..]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|