Class: TalkToYourApp::Plugins::Rake::Tools::Run
- Defined in:
- lib/talk_to_your_app/plugins/rake/tools/run.rb
Overview
Runs a single allow-listed rake task and returns its status and output as JSON. The task must be on the plugin’s ‘allowed:` list; anything else is refused without executing. The task runs in a subprocess (`bundle exec rake`), so it cannot inject shell commands and its real exit status is captured.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- MAX_OUTPUT_BYTES =
Cap captured output per stream so a chatty task can’t balloon the web process’s memory; the child keeps running (drained past the cap) so it still finishes normally. Grace window before escalating TERM to KILL.
1_000_000- KILL_GRACE_SECONDS =
3
Class Method Summary collapse
- .app_root ⇒ Object
-
.read_capped(io) ⇒ Object
Reads an IO to EOF but keeps only the first MAX_OUTPUT_BYTES; the rest is drained and discarded so the child never blocks on a full pipe.
-
.run_task(task, rake_args) ⇒ Object
Executes ‘bundle exec rake <task>` in the app root.
-
.terminate(wait_thr) ⇒ Object
Terminates the whole process group (negative pid) so ‘bundle exec rake` and any children it spawned are taken down, escalating from TERM to KILL if the group ignores TERM.
Instance Method Summary collapse
Methods inherited from Tool
argument, arguments, clear_custom_registry!, connection, custom_registry, default_arguments, description, dispatch, inherited, input_schema_hash, invoke, name, normalize_response, to_mcp_definition, to_mcp_tool, tool_name
Class Method Details
.app_root ⇒ Object
109 110 111 |
# File 'lib/talk_to_your_app/plugins/rake/tools/run.rb', line 109 def self.app_root defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.to_s : Dir.pwd end |
.read_capped(io) ⇒ Object
Reads an IO to EOF but keeps only the first MAX_OUTPUT_BYTES; the rest is drained and discarded so the child never blocks on a full pipe.
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/talk_to_your_app/plugins/rake/tools/run.rb', line 76 def self.read_capped(io) buffer = +"" capped = false while (chunk = io.read(16_384)) next if capped buffer << chunk if buffer.bytesize >= MAX_OUTPUT_BYTES buffer = buffer.byteslice(0, MAX_OUTPUT_BYTES) << "\n[output truncated at #{MAX_OUTPUT_BYTES} bytes]" capped = true end end buffer rescue IOError # Pipe closed underneath us (e.g. the reader thread was killed on # timeout). Return whatever was captured so far. buffer end |
.run_task(task, rake_args) ⇒ Object
Executes ‘bundle exec rake <task>` in the app root. Array argv (no shell) so task/args cannot inject shell commands. The subprocess is its own process group and is killed (TERM, then KILL) if it runs longer than the configured timeout, so a hung or runaway task cannot pin the calling web thread. Output is drained on threads — capped in memory, but fully consumed — so a chatty task neither blocks on a full pipe nor balloons the process.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/talk_to_your_app/plugins/rake/tools/run.rb', line 55 def self.run_task(task, rake_args) invocation = rake_args.empty? ? task : "#{task}[#{rake_args.join(",")}]" timeout = TalkToYourApp::Plugins::Rake.timeout Open3.popen3("bundle", "exec", "rake", invocation, chdir: app_root, pgroup: true) do |stdin, stdout, stderr, wait_thr| stdin.close out_reader = Thread.new { read_capped(stdout) } err_reader = Thread.new { read_capped(stderr) } if wait_thr.join(timeout).nil? terminate(wait_thr) [out_reader, err_reader].each(&:kill) raise Timeout::Error, "rake task #{invocation.inspect} exceeded the #{timeout}s timeout and was terminated" end Result.new(stdout: out_reader.value.to_s, stderr: err_reader.value.to_s, exit_code: wait_thr.value.exitstatus || 1) end end |
.terminate(wait_thr) ⇒ Object
Terminates the whole process group (negative pid) so ‘bundle exec rake` and any children it spawned are taken down, escalating from TERM to KILL if the group ignores TERM. Reaps the child either way.
98 99 100 101 102 103 104 105 106 107 |
# File 'lib/talk_to_your_app/plugins/rake/tools/run.rb', line 98 def self.terminate(wait_thr) pid = wait_thr.pid Process.kill("TERM", -pid) return if wait_thr.join(KILL_GRACE_SECONDS) # exited on TERM Process.kill("KILL", -pid) wait_thr.join # reap the killed child rescue Errno::ESRCH # Already exited between checks — nothing left to signal or reap. end |
Instance Method Details
#call(args, _ctx) ⇒ Object
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# File 'lib/talk_to_your_app/plugins/rake/tools/run.rb', line 30 def call(args, _ctx) task = args[:task].to_s unless TalkToYourApp::Plugins::Rake.allowed?(task) return error("Rake task #{task.inspect} is not allowed. Allowed tasks: #{TalkToYourApp::Plugins::Rake.allowed_tasks.inspect}.") end result = self.class.run_task(task, Array(args[:args]).map(&:to_s)) json( task: task, status: result.exit_code.zero? ? "success" : "error", exit_code: result.exit_code, output: result.stdout.strip, error: (result.stderr.strip.empty? ? nil : result.stderr.strip), ) rescue StandardError => e error("Failed to run rake #{args[:task]}: #{e.class}: #{e.}") end |