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.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +172 -5
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +35 -3
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/skills.md +31 -0
  15. data/docs/troubleshooting.md +1 -1
  16. data/exe/rubino +16 -2
  17. data/install.sh +721 -59
  18. data/lib/rubino/active_agent.rb +73 -0
  19. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  20. data/lib/rubino/agent/agent_registry.rb +5 -2
  21. data/lib/rubino/agent/definition.rb +1 -9
  22. data/lib/rubino/agent/fallback_chain.rb +0 -6
  23. data/lib/rubino/agent/iteration_budget.rb +109 -3
  24. data/lib/rubino/agent/loop.rb +476 -20
  25. data/lib/rubino/agent/model_call_runner.rb +81 -3
  26. data/lib/rubino/agent/prompts/build.txt +22 -5
  27. data/lib/rubino/agent/response_validator.rb +8 -0
  28. data/lib/rubino/agent/runner.rb +133 -8
  29. data/lib/rubino/agent/tool_executor.rb +166 -14
  30. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  31. data/lib/rubino/api/server.rb +19 -0
  32. data/lib/rubino/attachments/classify.rb +35 -17
  33. data/lib/rubino/boot/config_guard.rb +71 -0
  34. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  35. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  36. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  37. data/lib/rubino/cli/chat_command.rb +1189 -50
  38. data/lib/rubino/cli/commands.rb +282 -2
  39. data/lib/rubino/cli/config_command.rb +68 -8
  40. data/lib/rubino/cli/doctor_command.rb +204 -12
  41. data/lib/rubino/cli/jobs_command.rb +12 -0
  42. data/lib/rubino/cli/memory_command.rb +53 -20
  43. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  44. data/lib/rubino/cli/session_command.rb +172 -18
  45. data/lib/rubino/cli/setup_command.rb +131 -8
  46. data/lib/rubino/cli/skills_command.rb +183 -9
  47. data/lib/rubino/cli/trust_gate.rb +16 -7
  48. data/lib/rubino/commands/built_ins.rb +2 -0
  49. data/lib/rubino/commands/command.rb +12 -2
  50. data/lib/rubino/commands/executor.rb +149 -12
  51. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  52. data/lib/rubino/commands/handlers/agents.rb +156 -41
  53. data/lib/rubino/commands/handlers/config.rb +4 -1
  54. data/lib/rubino/commands/handlers/help.rb +113 -14
  55. data/lib/rubino/commands/handlers/memory.rb +15 -5
  56. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  57. data/lib/rubino/commands/handlers/status.rb +9 -4
  58. data/lib/rubino/commands/loader.rb +12 -0
  59. data/lib/rubino/config/configuration.rb +86 -24
  60. data/lib/rubino/config/defaults.rb +140 -33
  61. data/lib/rubino/config/loader.rb +62 -12
  62. data/lib/rubino/config/validator.rb +341 -0
  63. data/lib/rubino/config/writer.rb +123 -31
  64. data/lib/rubino/context/compressor.rb +184 -22
  65. data/lib/rubino/context/environment_inspector.rb +2 -2
  66. data/lib/rubino/context/file_discovery.rb +2 -2
  67. data/lib/rubino/context/message_boundary.rb +27 -1
  68. data/lib/rubino/context/project_languages.rb +90 -0
  69. data/lib/rubino/context/prompt_assembler.rb +105 -22
  70. data/lib/rubino/context/summary_builder.rb +45 -4
  71. data/lib/rubino/context/token_budget.rb +36 -11
  72. data/lib/rubino/context/token_estimate.rb +45 -0
  73. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  74. data/lib/rubino/database/connection.rb +154 -3
  75. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  76. data/lib/rubino/database/migrator.rb +98 -5
  77. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  78. data/lib/rubino/documents/converters/csv.rb +4 -3
  79. data/lib/rubino/documents/converters/docx.rb +29 -5
  80. data/lib/rubino/documents/converters/html.rb +5 -1
  81. data/lib/rubino/documents/converters/json.rb +2 -1
  82. data/lib/rubino/documents/converters/pdf.rb +11 -2
  83. data/lib/rubino/documents/converters/plain.rb +2 -1
  84. data/lib/rubino/documents/converters/pptx.rb +11 -2
  85. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  86. data/lib/rubino/documents/converters/xml.rb +2 -1
  87. data/lib/rubino/documents/limits.rb +210 -0
  88. data/lib/rubino/documents.rb +10 -3
  89. data/lib/rubino/errors.rb +36 -5
  90. data/lib/rubino/interaction/cancel_token.rb +19 -3
  91. data/lib/rubino/interaction/events.rb +13 -0
  92. data/lib/rubino/interaction/lifecycle.rb +99 -13
  93. data/lib/rubino/interaction/polishing.rb +176 -0
  94. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  95. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  96. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  97. data/lib/rubino/jobs/queue.rb +63 -8
  98. data/lib/rubino/jobs/runner.rb +24 -6
  99. data/lib/rubino/jobs/worker.rb +0 -4
  100. data/lib/rubino/llm/adapter_response.rb +47 -4
  101. data/lib/rubino/llm/credential_check.rb +15 -16
  102. data/lib/rubino/llm/error_classifier.rb +89 -1
  103. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  104. data/lib/rubino/llm/request.rb +30 -3
  105. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  106. data/lib/rubino/llm/tool_bridge.rb +113 -9
  107. data/lib/rubino/mcp/manager.rb +18 -1
  108. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  109. data/lib/rubino/memory/aux_retry.rb +107 -0
  110. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  111. data/lib/rubino/memory/backends.rb +23 -7
  112. data/lib/rubino/memory/salience_gate.rb +103 -0
  113. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  114. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  115. data/lib/rubino/memory/store.rb +33 -5
  116. data/lib/rubino/memory/threat_scanner.rb +52 -0
  117. data/lib/rubino/output/cost.rb +52 -0
  118. data/lib/rubino/output/headless_block_latch.rb +53 -0
  119. data/lib/rubino/output/result_serializer.rb +222 -0
  120. data/lib/rubino/output/turn_recorder.rb +77 -0
  121. data/lib/rubino/security/approval_policy.rb +227 -32
  122. data/lib/rubino/security/command_allowlist.rb +79 -4
  123. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  124. data/lib/rubino/security/hardline_guard.rb +189 -16
  125. data/lib/rubino/security/pattern_matcher.rb +28 -5
  126. data/lib/rubino/security/prefix_deriver.rb +25 -6
  127. data/lib/rubino/security/readonly_commands.rb +145 -5
  128. data/lib/rubino/security/secret_path.rb +134 -0
  129. data/lib/rubino/security/url_safety.rb +255 -0
  130. data/lib/rubino/session/repository.rb +212 -11
  131. data/lib/rubino/session/store.rb +139 -14
  132. data/lib/rubino/skills/installer.rb +230 -0
  133. data/lib/rubino/skills/prompt_index.rb +2 -2
  134. data/lib/rubino/skills/registry.rb +52 -1
  135. data/lib/rubino/skills/skill.rb +64 -3
  136. data/lib/rubino/skills/skill_tool.rb +16 -5
  137. data/lib/rubino/tools/background_tasks.rb +157 -13
  138. data/lib/rubino/tools/base.rb +204 -3
  139. data/lib/rubino/tools/edit_tool.rb +73 -18
  140. data/lib/rubino/tools/glob_tool.rb +48 -9
  141. data/lib/rubino/tools/grep_tool.rb +103 -9
  142. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  143. data/lib/rubino/tools/patch_tool.rb +5 -0
  144. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  145. data/lib/rubino/tools/read_tool.rb +33 -15
  146. data/lib/rubino/tools/read_tracker.rb +153 -35
  147. data/lib/rubino/tools/registry.rb +113 -12
  148. data/lib/rubino/tools/result.rb +9 -1
  149. data/lib/rubino/tools/ruby_tool.rb +0 -0
  150. data/lib/rubino/tools/shell_registry.rb +70 -0
  151. data/lib/rubino/tools/shell_tool.rb +40 -1
  152. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  153. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  154. data/lib/rubino/tools/task_tool.rb +36 -8
  155. data/lib/rubino/tools/vision_tool.rb +5 -0
  156. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  157. data/lib/rubino/tools/websearch_tool.rb +92 -30
  158. data/lib/rubino/tools/write_tool.rb +23 -4
  159. data/lib/rubino/ui/api.rb +10 -1
  160. data/lib/rubino/ui/base.rb +11 -0
  161. data/lib/rubino/ui/bottom_composer.rb +382 -74
  162. data/lib/rubino/ui/cli.rb +515 -83
  163. data/lib/rubino/ui/completion_menu.rb +11 -7
  164. data/lib/rubino/ui/headless_trace.rb +63 -0
  165. data/lib/rubino/ui/live_region.rb +70 -7
  166. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  167. data/lib/rubino/ui/notifier.rb +0 -2
  168. data/lib/rubino/ui/null.rb +52 -5
  169. data/lib/rubino/ui/paste_store.rb +16 -2
  170. data/lib/rubino/ui/queued_indicators.rb +6 -1
  171. data/lib/rubino/ui/status_bar.rb +61 -7
  172. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  173. data/lib/rubino/ui/subagent_view.rb +29 -4
  174. data/lib/rubino/ui/tool_label.rb +52 -0
  175. data/lib/rubino/update_check.rb +39 -4
  176. data/lib/rubino/util/atomic_file.rb +117 -0
  177. data/lib/rubino/util/ignore_rules.rb +120 -0
  178. data/lib/rubino/util/output.rb +229 -12
  179. data/lib/rubino/util/secrets_mask.rb +70 -7
  180. data/lib/rubino/util/spill_store.rb +153 -0
  181. data/lib/rubino/version.rb +1 -1
  182. data/lib/rubino/workspace.rb +9 -1
  183. data/lib/rubino.rb +191 -7
  184. data/rubino-agent.gemspec +1 -0
  185. data/skills/ruby-expert/SKILL.md +1 -0
  186. metadata +42 -12
  187. data/lib/rubino/agent/router.rb +0 -65
  188. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  189. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  190. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  191. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  192. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  193. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  194. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  195. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  196. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
@@ -38,8 +38,11 @@ module Rubino
38
38
  # #record_tool_started / #record_tool_finished) under the registry mutex
39
39
  # and read by the parent renderer (UI::SubagentCards) and
40
40
  # the /agents drill-in. activity_log is a bounded ring of the last few
41
- # `✓ verb · hint` lines for the live drill-in; nothing is persisted (it
42
- # dies with the process, like the rest of the registry).
41
+ # `✓ verb · hint` lines for the live drill-in; output_tail is the bounded
42
+ # line buffer of the CURRENTLY RUNNING tool's streamed output (fed by
43
+ # #record_tool_output, wiped at #record_tool_finished) that the drill-in's
44
+ # output: block tails (#5). Nothing is persisted (it dies with the
45
+ # process, like the rest of the registry).
43
46
  #
44
47
  # approval_gate / approval_question / approval_command are the
45
48
  # Option-2 approval-surfacing state: when a background child's tool needs
@@ -49,7 +52,7 @@ module Rubino
49
52
  Entry = Struct.new(
50
53
  :id, :subagent, :prompt, :status, :result, :error,
51
54
  :thread, :runner, :started_at, :finished_at,
52
- :last_activity, :tool_count, :activity_log,
55
+ :last_activity, :tool_count, :activity_log, :output_tail,
53
56
  :approval_gate, :approval_id, :approval_question, :approval_command,
54
57
  # Parent->child steer (the `/agents <id> steer "..."` note). Wired into
55
58
  # the child Loop as its Interaction::InputQueue (the SAME turn-boundary
@@ -94,6 +97,26 @@ module Rubino
94
97
  # How many recent activity lines the drill-in shows (the live `recent:` ring).
95
98
  ACTIVITY_LOG_MAX = 6
96
99
 
100
+ # Bounds for the live output tail (#5): how many COMPLETE lines the
101
+ # drill-in's output: block shows (the buffer keeps one extra slot for the
102
+ # in-flight partial line), and the byte cap per buffered line so a
103
+ # newline-free stream can't grow a line unbounded.
104
+ OUTPUT_TAIL_MAX = 6
105
+ OUTPUT_TAIL_LINE_MAX = 200
106
+
107
+ # Prefix #deliver_answer stamps on the steer-queue COPY of an answer it has
108
+ # ALREADY delivered to the child via its ask gate (the dual-path delivery:
109
+ # gate for a blocking ask, steer-queue for a non-blocking one). When the
110
+ # child resumes via the gate and finishes WITHOUT another turn boundary, the
111
+ # still-queued copy is drained by #complete and would surface as an
112
+ # "undelivered steer note" — but the answer WAS delivered via the gate, so
113
+ # reporting it undelivered is a false alarm (the /reply happy-path
114
+ # regression from the H5 fix #457). The completion-notice paths filter notes
115
+ # carrying this prefix OUT of the undelivered report for exactly that
116
+ # reason; a genuine `/agents <id> steer "..."` note never carries it, so the
117
+ # deliver-or-report-undelivered invariant for real steer notes is intact.
118
+ ANSWER_NOTE_PREFIX = "[parent answer] "
119
+
97
120
  class << self
98
121
  def instance
99
122
  @instance ||= new
@@ -190,6 +213,19 @@ module Rubino
190
213
  # see a consistent snapshot. A failure landing on a :stopping entry is a
191
214
  # USER-REQUESTED stop unwinding (Interrupted at the next checkpoint), so
192
215
  # it is recorded as :stopped — distinct from a genuine :failed (#108/#13).
216
+ #
217
+ # H5 — closes the drain↔complete race. The final drain of the child's
218
+ # steer_queue happens HERE, under the SAME registry mutex that flips the
219
+ # status to terminal, and #steer refuses to push onto a terminal entry
220
+ # under that SAME mutex. So a steer/answer arriving concurrently is
221
+ # serialised against this finalize: it is EITHER pushed before the status
222
+ # flips (and drained right here into the returned `undelivered` notes) OR
223
+ # rejected by #steer (which then honestly reports not-delivered). The
224
+ # earlier shape — drain (InputQueue lock) then complete (registry lock),
225
+ # two locks with a gap — let an answer land on a now-dead queue: dropped,
226
+ # omitted from `undelivered`, yet reported delivered. Returns the notes
227
+ # that were still queued at finalize time (never delivered to the child),
228
+ # so the caller can surface them as undelivered.
193
229
  def complete(entry, status:, result: nil, error: nil)
194
230
  @mutex.synchronize do
195
231
  status = :stopped if entry.status == :stopping && status == :failed
@@ -197,6 +233,10 @@ module Rubino
197
233
  entry.result = result
198
234
  entry.error = error
199
235
  entry.finished_at = Time.now
236
+ # Drain UNDER the mutex: anything still here is undelivered (the child
237
+ # has no further turn to fold it in), and once status is terminal no
238
+ # new note can arrive — #steer rejects it.
239
+ entry.steer_queue&.drain || []
200
240
  end
201
241
  end
202
242
 
@@ -219,7 +259,8 @@ module Rubino
219
259
  # Records a child tool FINISHING: appends a terse line to the bounded
220
260
  # activity ring the live drill-in (#71) tails. Keeps the last
221
261
  # ACTIVITY_LOG_MAX entries so the ring never grows unbounded for a
222
- # read-heavy child.
262
+ # read-heavy child. Also wipes the live output tail — it belongs to the
263
+ # tool that just finished, so the drill-in's output: block clears (#5).
223
264
  def record_tool_finished(id, line)
224
265
  @mutex.synchronize do
225
266
  entry = @entries[id]
@@ -228,6 +269,26 @@ module Rubino
228
269
  log = (entry.activity_log ||= [])
229
270
  log << line.to_s
230
271
  log.shift while log.size > ACTIVITY_LOG_MAX
272
+ entry.output_tail = nil
273
+ end
274
+ end
275
+
276
+ # Records a streamed chunk of the CURRENTLY RUNNING tool's output (#5):
277
+ # splits on newlines into a bounded line buffer whose LAST slot carries
278
+ # the in-flight partial line, so the /agents drill-in can tail it live.
279
+ # Called from UI::SubagentView#tool_chunk on the CHILD thread, so it MUST
280
+ # take the mutex like the other record_* writers. No-op for an unknown id.
281
+ def record_tool_output(id, chunk)
282
+ @mutex.synchronize do
283
+ entry = @entries[id]
284
+ return unless entry
285
+
286
+ tail = (entry.output_tail ||= [""])
287
+ chunk.to_s.each_line do |line|
288
+ tail[-1] = "#{tail[-1]}#{line.chomp}"[0, OUTPUT_TAIL_LINE_MAX]
289
+ tail << "" if line.end_with?("\n")
290
+ end
291
+ tail.shift while tail.size > OUTPUT_TAIL_MAX + 1
231
292
  end
232
293
  end
233
294
 
@@ -267,17 +328,28 @@ module Rubino
267
328
  # affordance). Pushes the text onto the child's steering queue, which the
268
329
  # child Loop drains at its next iteration boundary (Loop#inject_steered_input)
269
330
  # — between turns, never between a tool_use and its results. Best-effort:
270
- # returns false (and pushes nothing) when the entry is gone or has no queue
271
- # (e.g. a finished child), true when the note was queued.
331
+ # returns false (and pushes nothing) when the entry is gone, has no queue,
332
+ # or has ALREADY reached a terminal state (the child finished there is no
333
+ # more turn to fold the note into); true when the note was queued.
334
+ #
335
+ # H5 — the push happens UNDER the registry mutex, gated on a non-terminal
336
+ # status, so it is serialised against #complete (which flips the status to
337
+ # terminal AND drains the queue under that SAME mutex). Either this push
338
+ # wins the lock first (the note is queued and will be drained — by the
339
+ # child at its next turn, or by #complete into the undelivered report) or
340
+ # #complete wins first (status is terminal and this returns false). There
341
+ # is no window in which a note is pushed onto a queue nobody will drain yet
342
+ # reported delivered. Pushing inside the mutex is safe: InputQueue#push has
343
+ # its own lock and never calls back into the registry, so no lock cycle.
272
344
  def steer(id, text)
273
- queue = @mutex.synchronize do
345
+ @mutex.synchronize do
274
346
  entry = @entries[id]
275
- entry&.steer_queue
276
- end
277
- return false unless queue
347
+ return false unless entry&.steer_queue
348
+ return false if terminal_status?(entry.status)
278
349
 
279
- queue.push(text)
280
- true
350
+ entry.steer_queue.push(text)
351
+ true
352
+ end
281
353
  end
282
354
 
283
355
  # Records a BILLED live probe against a child (S3): bumps probe_count and
@@ -352,8 +424,22 @@ module Rubino
352
424
  entry = find(id)
353
425
  return false unless entry&.ask_gate
354
426
 
427
+ # H5 — #steer is the SINGLE race-free liveness oracle here: it pushes the
428
+ # answer onto the steer_queue under the registry mutex IFF the child is
429
+ # still non-terminal, returning false the instant the child has finished
430
+ # (atomic against #complete, which flips the status and drains the queue
431
+ # under that same mutex). So we steer FIRST and let its honest result
432
+ # decide everything:
433
+ # false ⇒ the child already finished; neither path can reach it. Do NOT
434
+ # decide the gate (a no-op for a child that will never await
435
+ # it) and do NOT clear the ask — report not-delivered.
436
+ # true ⇒ the child is live and the answer is queued; a BLOCKING ask
437
+ # additionally needs its gate decided so the parked child wakes
438
+ # with the answer as its tool result. Then clear the blocked
439
+ # state and report delivered.
440
+ return false unless steer(entry.id, "#{ANSWER_NOTE_PREFIX}#{answer}")
441
+
355
442
  entry.ask_gate.decide(entry.ask_id, answer)
356
- steer(entry.id, "[parent answer] #{answer}")
357
443
  end_ask(entry.id)
358
444
  true
359
445
  end
@@ -451,6 +537,54 @@ module Rubino
451
537
  descendants_of(id).each { |e| e.ask_gate&.cancel! }
452
538
  end
453
539
 
540
+ # The ONE per-entry stop body, shared by every stop path (the human
541
+ # /agents <id> --stop, the model-callable task_stop, and the
542
+ # parent-teardown #cancel_all below). Marks the stop so the unwind records
543
+ # as :stopped (not ✗ failed) and the list shows ◌ stopping, then wakes the
544
+ # entry no matter HOW it is blocked: a child parked on its OWN approval or
545
+ # ask gate (cancel those → Interrupted → clean unwind), any descendant
546
+ # parked on a blocking ask (the stop-cascade), and the runner's CancelToken
547
+ # for a child between checkpoints. Idempotent and safe on an already-stopped
548
+ # or never-blocked entry (each cancel! is one-shot; request_stop no-ops on a
549
+ # non-live status), so #cancel_all can call it across the whole registry.
550
+ def stop_entry(entry)
551
+ return unless entry
552
+
553
+ request_stop(entry.id)
554
+ entry.approval_gate&.cancel!
555
+ entry.ask_gate&.cancel!
556
+ cancel_descendant_ask_gates(entry.id)
557
+ entry.runner&.cancel!
558
+ end
559
+
560
+ # Structured-concurrency teardown seam: cancel EVERY live subagent so the
561
+ # process never leaves a child parked. The required fix for the parent-death
562
+ # deadlock (#XXX) — when the PARENT dies/interrupts (REPL break, HUP/TERM,
563
+ # clean quit, an aborted turn) a child blocked on ask_parent(blocking:true)
564
+ # otherwise stays parked on its gate for the full ask_parent_timeout (~900s)
565
+ # because nothing cancels its gate; the per-id stop paths only fire on an
566
+ # explicit /agents --stop or task_stop. Calling this from each parent-death
567
+ # edge wakes every blocked child SYNCHRONOUSLY (cancel! pushes its sentinel;
568
+ # the gate's await observes it within one WAKE_TICK) so each unwinds via the
569
+ # existing `rescue Rubino::Interrupted` with the clean "parent question was
570
+ # cancelled" message instead of hanging to the bound. No-op when there are no
571
+ # live children, and idempotent (#stop_entry is), so it is safe to invoke
572
+ # from a teardown `ensure` and from a signal trap. Snapshots #running first
573
+ # (outside the per-entry work) so we don't hold the registry mutex across the
574
+ # gate/runner cancels.
575
+ def cancel_all
576
+ running.each { |entry| stop_entry(entry) }
577
+ # Logical cancel alone (above) only flips cancel tokens and trusts each
578
+ # child THREAD to observe the token and reap its own shell within a wake
579
+ # tick — but on parent-DEATH the process exits before the thread reaches
580
+ # that checkpoint, so any shell a child spawned (its own pgid) reparents
581
+ # to init as an orphan (MED-2). Reap the tracked shell process groups
582
+ # SYNCHRONOUSLY here so the same parent-death edges that call cancel_all
583
+ # (clean quit, HUP/TERM trap, REPL break) leave no surviving shell.
584
+ ShellRegistry.instance.kill_all_groups
585
+ end
586
+ alias shutdown! cancel_all
587
+
454
588
  # True iff `child_id`'s direct owner is `parent_id` (the ownership predicate
455
589
  # later slices' steer/probe/answer_child AUTHORIZATION checks will build on).
456
590
  def owned_by?(parent_id, child_id)
@@ -508,6 +642,16 @@ module Rubino
508
642
  %i[running needs_approval blocked_on_human blocked_on_parent stopping].include?(status)
509
643
  end
510
644
 
645
+ # A child has reached a TERMINAL state once #complete has run: its worker
646
+ # thread is done, its steer_queue has been drained, and it has no further
647
+ # turn to fold a steer note into. #steer rejects pushes onto a terminal
648
+ # entry (H5) so an answer arriving after finalize is reported undelivered
649
+ # rather than dropped-but-reported-delivered. :cancelled is included for
650
+ # the API surface, which records cancellation via #complete too.
651
+ def terminal_status?(status)
652
+ %i[completed failed stopped cancelled].include?(status)
653
+ end
654
+
511
655
  def running_count
512
656
  @entries.values.count { |e| live_status?(e.status) }
513
657
  end
@@ -24,6 +24,14 @@ module Rubino
24
24
  # tool with no streamable output (read, edit, glob) just ignores it.
25
25
  attr_accessor :stream_chunk
26
26
 
27
+ # Optional render hint the ToolExecutor forwards to the UI alongside each
28
+ # streamed chunk (and the end-of-call body). :diff makes the CLI colorize
29
+ # +/-/@@ lines AND show the full hunks instead of collapsing to the 3-line
30
+ # preview — so "show me the diff" surfaces the real diff, not a snippet.
31
+ # Default nil ⇒ :plain. Set it from #call once the command/content kind is
32
+ # known; the streaming lambda reads it live.
33
+ attr_accessor :stream_kind
34
+
27
35
  # Convenience guard so tools don't sprinkle nil-checks at every emit.
28
36
  def emit_chunk(text)
29
37
  return if text.nil? || text.to_s.empty?
@@ -90,6 +98,26 @@ module Rubino
90
98
 
91
99
  protected
92
100
 
101
+ # Resolves a model-supplied path to an absolute one, anchoring a RELATIVE
102
+ # path at the workspace primary root (terminal.cwd || launch cwd) instead
103
+ # of the process cwd.
104
+ #
105
+ # `File.expand_path(rel)` anchors at Dir.pwd, but the agent's "current
106
+ # directory" — the dir the @-picker, shell/test and sandbox all agree on
107
+ # — is Workspace.primary_root, which is terminal.cwd when configured (e.g.
108
+ # bin/dev / the QA harness point it at a workspace subdir while the process
109
+ # launches from the parent). When the two diverge, a relative `shopkit/
110
+ # cart.py` resolved one directory too shallow and 404'd, forcing an
111
+ # ls→glob→re-read detour (r6 F3). Anchoring at primary_root fixes that
112
+ # while an ABSOLUTE path (or a ~ path) passes straight through unchanged,
113
+ # so the workspace guard downstream still sees the real target.
114
+ def expand_workspace_path(path)
115
+ str = path.to_s
116
+ return File.expand_path(str) if str.start_with?(File::SEPARATOR, "~")
117
+
118
+ File.expand_path(str, workspace_root)
119
+ end
120
+
93
121
  # Filesystem sandbox for write/edit/delete operations.
94
122
  #
95
123
  # Defaults to Dir.pwd, overridable via terminal.cwd in config. Mutating
@@ -191,6 +219,152 @@ module Rubino
191
219
  "Set tools.workspace_strict=false in config.yml to disable this check."
192
220
  end
193
221
 
222
+ # Typed "outside workspace" error gate, retained for the AUX-LLM read
223
+ # tools (summarize_file, vision) ONLY. Those route the raw file bytes
224
+ # through a third-party auxiliary model, so an out-of-workspace read would
225
+ # EXFILTRATE a sibling-repo secret / ~/.ssh file — a stronger threat than
226
+ # the in-process read/grep/glob, which were relaxed to broad in #406. A
227
+ # `path` is outside iff within_workspace? is false (strict mode on) and it
228
+ # isn't under the agent home; strict mode off never fires.
229
+ def outside_workspace?(expanded)
230
+ return false unless workspace_strict?
231
+ return false if within_workspace?(expanded)
232
+ # The agent's OWN home dir (~/.rubino) holds pastes, attachments and
233
+ # session files the agent explicitly points the model at — legitimate
234
+ # reads even though they sit outside the project workspace.
235
+ return false if under_agent_home?(expanded)
236
+
237
+ true
238
+ end
239
+
240
+ def outside_workspace_message(path)
241
+ roots = workspace_roots
242
+ roots_list = roots.length == 1 ? roots.first : roots.join(", ")
243
+ { output: "Error: '#{path}' is outside your workspace roots (#{roots_list}) — " \
244
+ "it is NOT missing, you are not allowed to access it here. " \
245
+ "Run `/add-dir #{File.dirname(File.expand_path(path.to_s))}` to include its folder, " \
246
+ "or relaunch in that directory. Do not try to create or overwrite it.",
247
+ error_code: :outside_workspace }
248
+ end
249
+
250
+ # UNIFIED SECRET-PATH PREDICATE (#446). One "is this a secret/credential
251
+ # path?" question used by BOTH the read side (read/grep/glob) and the
252
+ # write side (write/edit/multi_edit/apply_patch). Previously the read
253
+ # denylist (#406) was a NARROW subset (.env*/.envrc + agent-home) and the
254
+ # write denylist (#413) the SUPERSET; the maintainer decision is that
255
+ # reading OR writing a secret both require EXPLICIT user approval, applied
256
+ # to the SAME set. So there is now ONE set — the (wider) write set — and
257
+ # ONE predicate: #secret_path_category. The approval gate lives in
258
+ # Security::ApprovalPolicy#decide (returns :ask for a secret target), which
259
+ # gives us the existing flow for free: interactive → approval dropdown
260
+ # auto-opens; approved → the tool proceeds; denied → refused; headless (no
261
+ # human) → fails CLOSED via ToolExecutor's :noninteractive floor. The tools
262
+ # therefore NO LONGER self-refuse a secret in #call — an approved read of
263
+ # your .env must actually return its bytes, and an approved write must
264
+ # actually write. The predicate is still consulted directly in ONE place:
265
+ # GrepTool post-filters its RESULTS through it so an include-glob
266
+ # (`include: "*.env"`) over a directory can't leak a secret the per-target
267
+ # gate never saw (F2).
268
+ #
269
+ # DELIBERATE DIVERGENCE FROM HERMES: Hermes' file_safety.get_read_block_error
270
+ # FLAT-DENIES reading project .env* (model-facing deny, no human in the
271
+ # loop, defense-in-depth only). rubino instead routes the read through an
272
+ # explicit user APPROVAL gate (ask, not deny) so the agent CAN read/update
273
+ # your .env when you say yes — stricter than Claude Code's default
274
+ # (ungated reads) and aider, more content-aware than Codex's OS-sandbox.
275
+ #
276
+ # Matches (by BASENAME, in any directory):
277
+ # - project credential files: .env, .env.* (.env.local/.production), .envrc
278
+ # - shell/credential dotfiles: .netrc, .pgpass, .npmrc, .pypirc,
279
+ # .git-credentials, .bashrc, .zshrc, .profile, .bash_profile, .zprofile
280
+ # Matches (by absolute PATH / PREFIX):
281
+ # - ~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.docker, ~/.azure,
282
+ # ~/.config/gh, ~/.config/gcloud (the whole tree)
283
+ # - /etc/sudoers, /etc/sudoers.d/*, /etc/passwd, /etc/shadow, /etc/systemd/*
284
+ # - anything UNDER the agent home (~/.rubino) that holds auth/secrets:
285
+ # the home .env, the sqlite DB, any *oauth* file, an mcp-tokens/ dir,
286
+ # and *.key / *.pem material.
287
+ # Returns the matched category string (truthy) or nil when the path is not
288
+ # a secret. (Non-predicate: the truthy return carries the category string
289
+ # the approval question / block message interpolates.)
290
+ #
291
+ # The UNIFIED predicate (delegates to the single source of truth,
292
+ # Security::SecretPath.category). Returns the matched-secret category
293
+ # string (truthy) for a secret/credential path, or nil for a normal file.
294
+ def secret_path_category(expanded)
295
+ Security::SecretPath.category(expanded)
296
+ end
297
+
298
+ # Denial body for a secret hit that the GrepTool post-filter strips out of
299
+ # an include-glob result set (F2): the directory grep wasn't itself a
300
+ # secret target, so the per-call approval gate never saw it — we refuse the
301
+ # leaking RESULTS here instead. error_code stays :secret_denied for parity
302
+ # with the read side.
303
+ def secret_filtered_block_message(path, category)
304
+ { output: "Error: refusing to return secret content from '#{path}' — it is a #{category}. " \
305
+ "The search matched a credential file via an include-glob; secrets are not " \
306
+ "returned without explicit user approval. Ask the user, or read the file " \
307
+ "directly (which prompts for approval).",
308
+ error_code: :secret_denied }
309
+ end
310
+
311
+ # True when +expanded+ resolves under the Rubino home directory. Symlinks
312
+ # are resolved on both sides so a link can't be used to claim home-ness.
313
+ def under_agent_home?(expanded)
314
+ home = Rubino.home_path
315
+ return false if home.nil? || home.to_s.empty?
316
+
317
+ home_real = (File.realpath(home) if File.exist?(home)) || File.expand_path(home)
318
+ target_real = canonical_path(expanded)
319
+ return false unless target_real
320
+
321
+ target_real == home_real || target_real.start_with?("#{home_real}#{File::SEPARATOR}")
322
+ rescue StandardError => e
323
+ # Fail closed (treat as NOT under home) on any resolution error — but log
324
+ # it: this predicate gates a security-relevant decision, so a swallowed
325
+ # error that mis-resolves home-ness must at least leave a trace.
326
+ Rubino.logger&.warn(event: "tools.under_agent_home_failed",
327
+ error: e.message, error_class: e.class.name)
328
+ false
329
+ end
330
+
331
+ # Reads a file and scrubs a stray non-UTF-8 byte (e.g. a Latin-1 `é` in a
332
+ # legacy/EU source) to the replacement char. Shared by EditTool and
333
+ # MultiEditTool so a single bad byte doesn't raise "invalid byte sequence
334
+ # in UTF-8" out of the include?/scan/sub that follow and leave the file
335
+ # uneditable. Lossy on the offending byte, graceful for everything else.
336
+ #
337
+ # IMPORTANT (#326): this is for MODEL CONTEXT only — NEVER feed the
338
+ # scrubbed buffer to a File.write, because `scrub` rewrites every
339
+ # non-UTF-8 byte on UNTOUCHED lines to U+FFFD, so a one-line ASCII edit
340
+ # would lossily corrupt the whole file. Use #read_for_edit for the
341
+ # read-modify-write path.
342
+ def read_scrubbed(path)
343
+ content = File.read(path)
344
+ content.valid_encoding? ? content : content.scrub
345
+ end
346
+
347
+ # Reads a file for the edit/multi_edit READ-MODIFY-WRITE path (#326).
348
+ #
349
+ # Returns the raw bytes as BINARY (ASCII-8BIT) so the literal
350
+ # include?/scan/sub/gsub run byte-wise and every byte OUTSIDE the matched
351
+ # span is preserved exactly — a Latin-1 `André` on an untouched line is
352
+ # written back byte-identical even when the file isn't valid UTF-8. The
353
+ # model-supplied old_string/new_string are likewise compared/spliced as
354
+ # bytes (see #to_match_bytes), so a UTF-8 needle still matches its UTF-8
355
+ # bytes in the file. Valid-UTF-8 files behave exactly as before.
356
+ def read_for_edit(path)
357
+ File.binread(path)
358
+ end
359
+
360
+ # Forces a model-supplied string to the SAME binary encoding the on-disk
361
+ # content carries in #read_for_edit, so include?/scan/sub compare raw
362
+ # bytes (a UTF-8 `é` needle matches its two on-disk bytes). dup so we
363
+ # never mutate the caller's frozen literal.
364
+ def to_match_bytes(str)
365
+ str.to_s.dup.force_encoding(Encoding::BINARY)
366
+ end
367
+
194
368
  # Read-before-edit gate shared by EditTool and MultiEditTool. Refuses the
195
369
  # write when the model never read this file in the current session, or
196
370
  # read it but the file changed on disk since. Returns nil (proceed) or an
@@ -208,15 +382,42 @@ module Rubino
208
382
  error_code: :stale_read }
209
383
  end
210
384
 
385
+ # Fresh? matches on EITHER unchanged mtime OR unchanged content hash, so
386
+ # the agent's own write (refreshed via note_write), a no-op touch, a
387
+ # CRLF normalisation, or a linter rewrite to identical bytes does NOT
388
+ # trip this guard (r5 B2). Only a genuine content change does.
389
+ return nil if @read_tracker.fresh?(expanded)
390
+
211
391
  stashed = @read_tracker.mtime_at_read(expanded)
212
392
  current = File.mtime(expanded)
213
- return nil if stashed.nil? || current <= stashed
214
-
215
393
  { output: "Error: #{display_path} changed on disk since the last read " \
216
- "(read at #{stashed.utc.iso8601}, now #{current.utc.iso8601}). " \
394
+ "(read at #{stashed&.utc&.iso8601}, now #{current.utc.iso8601}). " \
217
395
  "Re-read the file before editing so the #{verb} reflect the current contents.",
218
396
  error_code: :stale_read }
219
397
  end
398
+
399
+ # Read-before-overwrite gate for WriteTool on an EXISTING file (r5 MF-2).
400
+ # Refuses a blind `write` that would clobber a file the model never read
401
+ # this session (or read but is now stale on disk). New files don't reach
402
+ # here. Returns nil (proceed) or an error Hash with error_code:
403
+ # :unread_overwrite. No tracker → no gate.
404
+ def overwrite_guard_error(expanded, display_path)
405
+ return nil unless @read_tracker
406
+
407
+ unless @read_tracker.seen?(expanded)
408
+ return { output: "Error: refusing to overwrite existing file #{display_path} — " \
409
+ "you have not read it this session, so a blind write would clobber its " \
410
+ "current contents. Read it first (then use `edit`/`multi_edit` for a " \
411
+ "targeted change, or `write` the full intended content).",
412
+ error_code: :unread_overwrite }
413
+ end
414
+
415
+ return nil if @read_tracker.fresh?(expanded)
416
+
417
+ { output: "Error: #{display_path} changed on disk since you last read it — " \
418
+ "re-read it before overwriting so you don't clobber newer content.",
419
+ error_code: :unread_overwrite }
420
+ end
220
421
  end
221
422
  end
222
423
  end
@@ -46,12 +46,21 @@ module Rubino
46
46
  end
47
47
 
48
48
  def call(arguments)
49
- file_path = arguments["file_path"] || arguments[:file_path]
50
- old_string = arguments["old_string"] || arguments[:old_string]
51
- new_string = arguments["new_string"] || arguments[:new_string]
52
- replace_all = arguments["replace_all"] || arguments[:replace_all] || false
49
+ file_path, old_string, new_string, replace_all = parse_args(arguments)
50
+
51
+ # Input guards (#329a/b): reject an empty needle (a literal sub/gsub on
52
+ # "" matches at every char boundary and would corrupt the file under
53
+ # replace_all) and a no-op old==new (reporting "1 replacement" misleads
54
+ # the model — multi_edit already rejects it, so match that).
55
+ if (guard = guard_args(old_string, new_string))
56
+ return guard
57
+ end
53
58
 
54
- expanded = File.expand_path(file_path)
59
+ expanded = expand_workspace_path(file_path)
60
+ # SECRET/credential edits (#446) are no longer HARD-refused here — they
61
+ # are gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask): an
62
+ # APPROVED edit of your .env actually applies, a denied/headless one
63
+ # never reaches #call. The workspace sandbox below is unchanged.
55
64
  return workspace_violation_message(file_path) unless within_workspace?(expanded)
56
65
 
57
66
  return "Error: File not found: #{file_path}" unless File.exist?(expanded)
@@ -60,30 +69,40 @@ module Rubino
60
69
  return gate
61
70
  end
62
71
 
63
- content = File.read(expanded)
64
-
65
- unless content.include?(old_string)
72
+ # Read the RAW bytes (binary) for the read-modify-write so non-UTF-8
73
+ # bytes on untouched lines are preserved verbatim on write (#326); the
74
+ # model-supplied needle/replacement are matched/spliced as bytes too.
75
+ content = read_for_edit(expanded)
76
+ old_bytes = to_match_bytes(old_string)
77
+ new_bytes = to_match_bytes(new_string)
78
+
79
+ unless content.include?(old_bytes)
80
+ # The model's mental model of the file was wrong (hallucinated text).
81
+ # Flag a recovery so its next read of this path bypasses dedup and
82
+ # returns FRESH bytes instead of a stale "[DUPLICATE READ]" nudge
83
+ # (r5 B3).
84
+ @read_tracker&.note_edit_failure(expanded)
66
85
  return "Error: old_string not found in file content. " \
67
86
  "Make sure the text matches exactly including whitespace."
68
87
  end
69
88
 
70
89
  # Count occurrences
71
- count = content.scan(old_string).size
90
+ count = content.scan(old_bytes).size
72
91
  if count > 1 && !replace_all
73
92
  return "Error: Found #{count} matches for old_string. " \
74
93
  "Provide more surrounding context to make it unique, " \
75
94
  "or set replace_all: true to replace all occurrences."
76
95
  end
77
96
 
78
- # Perform replacement use block form so new_string is treated as a
79
- # literal string, not a pattern (avoids \0, \1, \& interpolation bugs).
80
- new_content = if replace_all
81
- content.gsub(old_string) { new_string }
82
- else
83
- content.sub(old_string) { new_string }
84
- end
85
-
86
- File.write(expanded, new_content)
97
+ new_content = replace_literal(content, old_bytes, new_bytes, replace_all)
98
+ # Crash-safe write: temp-in-same-dir + fsync + atomic rename, so a
99
+ # SIGINT/crash mid-flush can't destroy the user's existing file content
100
+ # (this is a read-modify-write of an existing file — HIGH-1).
101
+ Util::AtomicFile.write_atomic(expanded, new_content)
102
+ # Refresh-on-own-write: the bytes we just wrote are now authoritative,
103
+ # so the very next edit to this file passes the read-gate instead of
104
+ # "changed on disk since last read" (r5 B2).
105
+ @read_tracker&.note_write(expanded, new_content)
87
106
 
88
107
  replaced_count = replace_all ? count : 1
89
108
  added = new_string.to_s.lines.size
@@ -93,10 +112,46 @@ module Rubino
93
112
  "+#{added * replaced_count} −#{removed * replaced_count}",
94
113
  body: build_diff_preview(old_string, new_string, replaced_count),
95
114
  body_kind: :diff }
115
+ rescue StandardError => e
116
+ # Mirror WriteTool: a read-only/permission-denied target (Errno::EACCES)
117
+ # or any other filesystem error returns a clean, uniform message rather
118
+ # than leaking a raw exception/backtrace to the model.
119
+ "Error editing #{file_path}: #{e.message}"
96
120
  end
97
121
 
98
122
  private
99
123
 
124
+ # Returns an error string when old/new_string are unusable (#329a/b), or
125
+ # nil when they're fine. Kept out of #call so it stays under the length gate.
126
+ def guard_args(old_string, new_string)
127
+ if old_string.nil? || old_string.empty?
128
+ return "Error: old_string is empty. Provide the exact existing text to replace " \
129
+ "(use the write tool to create or fully replace a file)."
130
+ end
131
+ return unless old_string == new_string
132
+
133
+ "Error: old_string and new_string are identical — nothing to change."
134
+ end
135
+
136
+ # Pull the four inputs (string- or symbol-keyed) in one place so #call
137
+ # stays under the complexity gate.
138
+ def parse_args(arguments)
139
+ [arguments["file_path"] || arguments[:file_path],
140
+ arguments["old_string"] || arguments[:old_string],
141
+ arguments["new_string"] || arguments[:new_string],
142
+ arguments["replace_all"] || arguments[:replace_all] || false]
143
+ end
144
+
145
+ # Block form so new_string is treated as a literal replacement, not a
146
+ # pattern — avoids \0, \1, \& interpolation bugs in the new text.
147
+ def replace_literal(content, old_string, new_string, replace_all)
148
+ if replace_all
149
+ content.gsub(old_string) { new_string }
150
+ else
151
+ content.sub(old_string) { new_string }
152
+ end
153
+ end
154
+
100
155
  # Inline diff shown between the `tool · edit` and `done · edit` headers.
101
156
  # Not a real unified diff — just `- old` then `+ new` so the user can
102
157
  # see at a glance what the model is changing without scrolling back to