From 4f71959394ecd432767303a58d0d0765a495b28d Mon Sep 17 00:00:00 2001 From: dradis-bot <286253174+dradis-bot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:17:45 +0100 Subject: [PATCH 01/10] Echo: stop ReplyJob shadowing ActiveJob#serialize so it can enqueue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private serialize(session, cutoff_id) overrode ActiveJob::Core#serialize, which every queue adapter calls when enqueuing. perform_later — and so Session#request_reply! — raised NoMethodError at runtime. Rename the private method to finalize and cover the enqueue path. --- .../dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb | 6 +++--- .../spec/jobs/dradis/plugins/echo/reply_job_spec.rb | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb b/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb index dc514fac7..a0fc45e24 100644 --- a/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb +++ b/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb @@ -9,7 +9,7 @@ class ReplyJob < ApplicationJob # Generates one assistant reply for a session: it streams the provider # response into a `streaming` message, persists the final text as - # `complete` with model/provider metadata, then serializes — re-enqueueing + # `complete` with model/provider metadata, then finalizes — re-enqueueing # itself if the user spoke again mid-generation, or flipping the session # back to `idle`. Session#request_reply! owns the idle->generating gate. def perform(session) @@ -23,7 +23,7 @@ def perform(session) text, duration_ms = stream_reply(agent, session, message, context) complete(agent, message, text, duration_ms) - serialize(session, cutoff_id) + finalize(session, cutoff_id) rescue Provider::HttpStreaming::Error => e # A genuine provider/transport failure: surface it to the user as a failed # message. Anything else (a disabled agent, a bug in our code) is left to @@ -89,7 +89,7 @@ def strip_thinking(text) # Under a lock so it can't race the controller flipping idle<->generating: # if a user message landed after the reply started (id past the cutoff), # answer it too by re-enqueueing; otherwise release the session to idle. - def serialize(session, cutoff_id) + def finalize(session, cutoff_id) session.with_lock do if session.messages.where(role: :user).where('id > ?', cutoff_id).exists? self.class.perform_later(session) diff --git a/engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb b/engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb index 7a2cdbf5e..86cc457df 100644 --- a/engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb +++ b/engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb @@ -26,6 +26,13 @@ def perform allow_any_instance_of(Dradis::Plugins::Echo::Session).to receive(:broadcast_composer_state) end + # Guards against a private method shadowing ActiveJob::Core#serialize, which + # every queue adapter calls when enqueuing — a collision there breaks + # perform_later (and so Session#request_reply!) at runtime. + it 'can be serialized for enqueuing' do + expect { described_class.new(session).serialize }.not_to raise_error + end + describe 'a successful reply' do before { stub_stream('Hello ', 'world') } From 002e235ec90ea801d5c17aae0702bf499f59926e Mon Sep 17 00:00:00 2001 From: dradis-bot <286253174+dradis-bot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:17:59 +0100 Subject: [PATCH 02/10] Echo: add session controllers, routes and re-authorizing sockets channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Purely additive Slice 4. Adds project-scoped Sessions/Messages controllers (Prompt read only at the controller boundary, no FK; Prompt::SCOPES whitelist honoured via set_type), their routes and index/show views, and SessionsChannel — a custom Turbo Streams channel that re-checks :use on the session's project at subscribe time and rejects revoked users, closing the non-expiring signed-stream-name hole. InteractionsController is untouched. --- .../dradis/plugins/echo/sessions_channel.rb | 44 +++++++++ .../dradis/plugins/echo/record_scoping.rb | 17 ++++ .../projects/sessions/messages_controller.rb | 29 ++++++ .../echo/projects/sessions_controller.rb | 76 +++++++++++++++ .../echo/projects/sessions/index.html.erb | 47 +++++++++ .../echo/projects/sessions/show.html.erb | 34 +++++++ engines/dradis-echo/config/routes.rb | 4 + .../plugins/echo/sessions_channel_spec.rb | 47 +++++++++ .../echo/projects/sessions/messages_spec.rb | 53 ++++++++++ .../plugins/echo/projects/sessions_spec.rb | 96 +++++++++++++++++++ 10 files changed, 447 insertions(+) create mode 100644 engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb create mode 100644 engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb create mode 100644 engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb create mode 100644 engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb create mode 100644 engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb create mode 100644 engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb create mode 100644 engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb create mode 100644 engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb create mode 100644 engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb diff --git a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb new file mode 100644 index 000000000..9ce9e26de --- /dev/null +++ b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb @@ -0,0 +1,44 @@ +module Dradis::Plugins::Echo + # Custom Turbo Streams channel for Echo sessions. Turbo's default channel + # trusts any client holding a validly-signed stream name for the lifetime of + # that name — which never expires — so a user who loses project access keeps + # receiving the transcript. We re-check authorization at subscribe time + # instead: resolve the session behind the signed name and only stream if the + # subscriber may still :use its project, otherwise reject. + # + # Follows the documented turbo-rails custom-channel pattern + # (Turbo::StreamsChannel). + class SessionsChannel < ApplicationCable::Channel + extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName + include Turbo::Streams::StreamName::ClassMethods + + def subscribed + session = session_from_stream_name + + if session && authorized?(session) + stream_from verified_stream_name_from_params + else + reject + end + end + + private + + def authorized?(session) + Ability.new(current_user).can?(:use, session.record.project) + end + + # The signed name encodes `[session, :messages]`, i.e. + # ":messages". A tampered name fails verification and + # yields nil, which we treat as unauthorized. + def session_from_stream_name + name = verified_stream_name_from_params + return unless name + + record = GlobalID::Locator.locate(name.split(':').first) + record if record.is_a?(Session) + rescue StandardError + nil + end + end +end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb new file mode 100644 index 000000000..3791c5370 --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb @@ -0,0 +1,17 @@ +module Dradis::Plugins::Echo + # Shared record scoping for the session controllers. A session is always + # anchored to a record (an Issue or a Note) that must live inside the current + # project; resolving it through the project's collections gives us + # cross-project/record isolation for free — an out-of-scope id raises + # ActiveRecord::RecordNotFound, mirroring Projects::GrammarController. + module RecordScoping + extend ActiveSupport::Concern + + private + + def scoped_record(record) + collection = record.is_a?(Issue) ? current_project.issues : current_project.notes + collection.find(record.id) + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb new file mode 100644 index 000000000..99fdfb492 --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb @@ -0,0 +1,29 @@ +module Dradis::Plugins::Echo + class Projects::Sessions::MessagesController < AuthenticatedController + include ProjectScoped + include RecordScoping + layout false + + before_action :set_session + + # Appends a user turn and re-opens the reply gate. The new message + # broadcasts itself into the transcript, so there's nothing to render back. + def create + message = @session.messages.build(content: params[:content], role: :user, user: current_user) + + if message.save + @session.request_reply! + head :ok + else + head :unprocessable_entity + end + end + + private + + def set_session + @session = Session.find(params[:session_id]) + scoped_record(@session.record) + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb new file mode 100644 index 000000000..9f62c3d6d --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb @@ -0,0 +1,76 @@ +module Dradis::Plugins::Echo + class Projects::SessionsController < AuthenticatedController + include EventPublisher + include ProjectScoped + include RecordScoping + layout false + + before_action :set_type + before_action :set_record, only: [:index, :create] + before_action :check_turbo_config, only: [:show, :create] + before_action :set_session, only: [:show] + + def index + @sessions = Session.for_record(@record).order(created_at: :desc) + + Prompt.seed_default_prompts(current_user) if current_user.prompts.empty? + @prompts = current_user.prompts.for(@type) + end + + def show; end + + # Starts a conversation from a saved prompt. The Prompt is read only here, at + # the controller boundary — we copy its title and never store a FK, so a + # later edit or deletion of the template can't rewrite history. Scoping the + # lookup through `for(@type)` honours the Prompt::SCOPES whitelist. The first + # user Message carries the (Liquid-rendered, possibly edited) prompt text. + def create + prompt = current_user.prompts.for(@type).find(params[:prompt_id]) + + @session = Session.new( + agent: Agents::Roslin.instance, + record: @record, + title: prompt.title, + user: current_user + ) + @session.messages.build(content: params[:prompt], role: :user, user: current_user) + @session.save! + + @session.request_reply! + publish_event('echo_session.created', session: { id: @session.id }) + + render :show + end + + private + + def check_turbo_config + @turbo_status = begin + ActionCable.server.pubsub.redis_connection_for_subscriptions.ping + true + rescue + false + end + end + + def record_params + params.permit(:id, :prompt, :prompt_id, :project_id, :record, :type) + end + + def set_record + raise ActiveRecord::RecordNotFound if @type.blank? + + @record = current_project.send(@type.to_s.pluralize).find(record_params[:record]) + end + + def set_session + @session = Session.find(record_params[:id]) + @record = scoped_record(@session.record) + end + + def set_type + allowed = Prompt::SCOPES.map(&:to_s) + @type = allowed.include?(record_params[:type]) ? record_params[:type].to_sym : nil + end + end +end diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb new file mode 100644 index 000000000..144cda73f --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb @@ -0,0 +1,47 @@ +<% if Dradis::Plugins::Echo::Agents::Roslin.enabled? %> + <% if @prompts.any? %> + <% if @sessions.any? %> +
+ <% @sessions.each do |session| %> + <%= link_to session.title.presence || "Session ##{session.id}", + echo.project_session_path(current_project, session, type: @type, record: @record.id), + id: dom_id(session), + class: 'list-group-item list-group-item-action', + data: { turbo_frame: dom_id(@record, :echo) } %> + <% end %> +
+ <% end %> + +

+ Interact with Roslin by selecting a + <%= link_to 'saved prompt', echo.prompts_path, data: { turbo_frame: '_top' } %> to start a session for this Issue. +

+

+ You can edit the prompt before sending it, any changes are one-off and + won't affect the saved prompt template. +

+ <% else %> + <%= render 'shared/empty_state', + actions_partial: 'dradis/plugins/echo/prompts/empty_state_actions', + docs_link: 'https://dradis.com/support/guides/echo/prompts.html', + name: 'prompt', + text: 'Create reusable prompts to standardize how your team analyzes findings, writes reports, summarizes vulnerabilities, and more. Save time by building a library of prompts that anyone on your team can use for consistent results.' + %> + <% end %> +<% else %> +
+ <% if !current_user.respond_to?(:role?) || current_user.role?(:admin) %> + The Roslin agent is not enabled. + <%= link_to 'Enable it', echo.agents_path, data: { turbo_frame: '_top' } %> + to allow LLM interactions for AI-assisted writing. + <% else %> + Roslin is not enabled. Ask an + admin to enable it to allow LLM interactions. + <% end %> +
+

+ Think of Roslin as an editor or writing companion. It allows you to interact + with different LLM providers to help enhance your findings' readability + or adapt them to your audience. +

+<% end %> diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb new file mode 100644 index 000000000..364c02f56 --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb @@ -0,0 +1,34 @@ + + <% unless @turbo_status %> +
+ There was an error contacting Redis: please make sure the Redis server is running. +
+ <% end %> + + <%# Live transcript + composer state. Slice 5 adds `channel: SessionsChannel` %> + <%# so the socket re-authorizes on subscribe. %> + <%= turbo_stream_from [@session, :messages] %> + <%= turbo_stream_from [@session, :composer_state] %> + +
+ <%= render partial: 'dradis/plugins/echo/projects/sessions/messages/message', + collection: @session.messages.order(:created_at, :id), as: :message %> +
+ + <%= render 'dradis/plugins/echo/projects/sessions/composer_state', session: @session %> + + <%= form_with url: echo.project_session_messages_path(current_project, @session), method: :post do |f| %> +
+ <%= + f.text_area :content, + class: 'form-control field-sizing-content', + placeholder: 'Message Roslin…', + rows: 3 + %> +
+ +
+ <%= f.submit 'Send', class: 'btn btn-primary mt-2' %> +
+ <% end %> +
diff --git a/engines/dradis-echo/config/routes.rb b/engines/dradis-echo/config/routes.rb index 7d72c30fc..a74dcfb3b 100644 --- a/engines/dradis-echo/config/routes.rb +++ b/engines/dradis-echo/config/routes.rb @@ -10,6 +10,10 @@ resources :grammar_corrections, only: [:create], controller: 'projects/grammar_corrections' resources :grammar_suggestions, only: [:create], controller: 'projects/grammar_suggestions' + + resources :sessions, only: [:index, :show, :create], controller: 'projects/sessions' do + resources :messages, only: [:create], controller: 'projects/sessions/messages' + end end resources :prompts, except: [:show] diff --git a/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb b/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb new file mode 100644 index 000000000..3c26051b3 --- /dev/null +++ b/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe Dradis::Plugins::Echo::SessionsChannel, type: :channel do + let(:user) { create(:user) } + let(:agent) { create(:agent) } + let(:session) { create(:echo_session, agent: agent) } + + let(:signed_name) { Turbo::StreamsChannel.signed_stream_name([session, :messages]) } + let(:stream_name) { Turbo::StreamsChannel.verified_stream_name(signed_name) } + + before { stub_connection(current_user: user) } + + it 'accepts the subscription and streams the transcript for an authorized user' do + subscribe(signed_stream_name: signed_name) + + expect(subscription).to be_confirmed + expect(subscription).to have_stream_from(stream_name) + end + + it 'rejects a user whose :use on the record project is denied' do + allow_any_instance_of(Ability).to receive(:can?).and_return(false) + + subscribe(signed_stream_name: signed_name) + + expect(subscription).to be_rejected + end + + # Re-authorizes at subscribe time: the same signed name is accepted while the + # user may :use the project and rejected once that permission is revoked. + it 'flips between accept and reject as authorization changes' do + subscribe(signed_stream_name: signed_name) + expect(subscription).to be_confirmed + + allow_any_instance_of(Ability).to receive(:can?).and_return(false) + + subscribe(signed_stream_name: signed_name) + expect(subscription).to be_rejected + end + + it 'rejects a tampered stream name that fails verification' do + subscribe(signed_stream_name: 'not-a-valid-signed-stream-name') + + expect(subscription).to be_rejected + end +end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb new file mode 100644 index 000000000..d12c41614 --- /dev/null +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe 'Echo session messages' do + include ActiveJob::TestHelper + + let(:user) { create(:user) } + + before do + login_as_user(user) + @project = Project.new + end + + let(:agent) { create(:agent, enabled: true) } + let(:issue) { create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") } + let(:session) { create(:echo_session, agent: agent, record: issue) } + + describe 'POST /addons/echo/projects/:project_id/sessions/:session_id/messages' do + it 'appends a user message and triggers a reply' do + expect { + post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/messages", + params: { content: 'What is the impact?' } + }.to change { session.messages.count }.by(1) + .and have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) + + expect(response).to have_http_status(:ok) + + message = session.messages.order(:id).last + expect(message.role).to eq('user') + expect(message.user).to eq(user) + expect(message.content).to eq('What is the impact?') + end + + it 'rejects a blank message without enqueuing a reply' do + expect { + post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/messages", + params: { content: '' } + }.not_to change(Dradis::Plugins::Echo::Message, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'denies a session whose record is outside the current project scope' do + other_session = create(:echo_session, agent: agent, record: create(:issue, node: create(:node))) + + expect { + post "/addons/echo/projects/#{@project.id}/sessions/#{other_session.id}/messages", + params: { content: 'Hello' } + }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb new file mode 100644 index 000000000..e820cfda5 --- /dev/null +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe 'Echo sessions' do + include ActiveJob::TestHelper + + let(:user) { create(:user) } + + before do + login_as_user(user) + @project = Project.new + end + + let!(:roslin) do + Dradis::Plugins::Echo::Agents::Roslin.provision!.tap { |agent| agent.update!(enabled: true) } + end + + let(:prompt) do + user.prompts.create!( + title: 'Summarise the finding', + prompt: 'Summarise {{ issue.title }}', + scope: 'issue', + visibility: :user + ) + end + + let(:issue) do + create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") + end + + describe 'GET /addons/echo/projects/:project_id/sessions' do + it 'lists the sessions for the record' do + session = create(:echo_session, agent: roslin, record: issue) + + get "/addons/echo/projects/#{@project.id}/sessions", params: { type: 'issue', record: issue.id } + + expect(response).to have_http_status(:ok) + expect(response.body).to include(session.title.to_s) if session.title.present? + end + end + + describe 'POST /addons/echo/projects/:project_id/sessions' do + let(:params) do + { type: 'issue', record: issue.id, prompt_id: prompt.id, prompt: 'Summarise the SQLi finding' } + end + + it 'creates a session with the first user message and triggers a reply' do + expect { + post "/addons/echo/projects/#{@project.id}/sessions", params: params + }.to change(Dradis::Plugins::Echo::Session, :count).by(1) + .and change(Dradis::Plugins::Echo::Message, :count).by(1) + .and have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) + + session = Dradis::Plugins::Echo::Session.last + expect(session.title).to eq(prompt.title) + expect(session.user).to eq(user) + expect(session.record).to eq(issue) + + message = session.messages.first + expect(message.role).to eq('user') + expect(message.content).to eq('Summarise the SQLi finding') + end + + it 'responds with the session turbo frame' do + post "/addons/echo/projects/#{@project.id}/sessions", params: params + + expect(response).to have_http_status(:ok) + expect(response.body).to include("id=\"#{ActionView::RecordIdentifier.dom_id(issue, :echo)}\"") + end + + it 'copies the title without storing a prompt FK' do + post "/addons/echo/projects/#{@project.id}/sessions", params: params + + session = Dradis::Plugins::Echo::Session.last + expect(session.attributes).not_to have_key('prompt_id') + expect(session.title).to eq(prompt.title) + end + + it 'honours the Prompt::SCOPES whitelist' do + expect { + post "/addons/echo/projects/#{@project.id}/sessions", + params: params.merge(type: 'node') + }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'denies a record outside the current project scope' do + other_issue = create(:issue, node: create(:node)) + + expect { + post "/addons/echo/projects/#{@project.id}/sessions", + params: params.merge(record: other_issue.id) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end From 1f5e42f1ed9cfbb4843369a55445d951d6ce7150 Mon Sep 17 00:00:00 2001 From: dradis-bot <286253174+dradis-bot@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:26:04 +0100 Subject: [PATCH 03/10] Echo: defer initial reply generation until the client subscribes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On initial session creation SessionsController#create called request_reply! before render :show, so ReplyJob's streaming-container broadcast raced ahead of the browser's SessionsChannel subscription; the container append was missed and the later chunk/replace broadcasts hit a non-existent node, so the reply only appeared on reload (SEC-506 Bug 4). Restore the invariant "the streaming container is broadcast only to a listening socket": create no longer starts generation. It renders show in the new Session#reply_pending? state and a dedicated RepliesController#create (POST sessions/:id/reply) starts generation, to be driven from the session Stimulus controller once it has connected. reply_pending? (idle + newest message is a user turn) guards the trigger so reconnects and extra viewers can't spawn an unsolicited reply. Follow-up messages are unchanged — the client is already subscribed there. Refs SEC-506. --- .../projects/sessions/replies_controller.rb | 28 +++++++++++ .../echo/projects/sessions_controller.rb | 8 ++- .../app/models/dradis/plugins/echo/session.rb | 11 +++++ engines/dradis-echo/config/routes.rb | 1 + .../dradis/plugins/echo/session_spec.rb | 25 ++++++++++ .../echo/projects/sessions/replies_spec.rb | 49 +++++++++++++++++++ .../plugins/echo/projects/sessions_spec.rb | 9 +++- 7 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb create mode 100644 engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb new file mode 100644 index 000000000..220ef6fb0 --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb @@ -0,0 +1,28 @@ +module Dradis::Plugins::Echo + class Projects::Sessions::RepliesController < AuthenticatedController + include ProjectScoped + include RecordScoping + layout false + + before_action :set_session + + # Starts generation for a freshly-created session. The session Stimulus + # controller POSTs here from `connect`, i.e. after the browser has subscribed + # to the SessionsChannel, so the streaming container ReplyJob broadcasts lands + # on a listening socket (SEC-506 Bug 4). Guarded by reply_pending? so a + # reconnect, a second viewer, or a stray POST on an already-answered session + # can never spawn an unsolicited reply — request_reply! only fires while a + # reply is genuinely owed. + def create + @session.request_reply! if @session.reply_pending? + head :ok + end + + private + + def set_session + @session = Session.find(params[:session_id]) + scoped_record(@session.record) + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb index 9f62c3d6d..d9f50fdec 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb @@ -24,6 +24,13 @@ def show; end # later edit or deletion of the template can't rewrite history. Scoping the # lookup through `for(@type)` honours the Prompt::SCOPES whitelist. The first # user Message carries the (Liquid-rendered, possibly edited) prompt text. + # + # We deliberately do NOT call request_reply! here: on initial creation the + # browser hasn't subscribed to the SessionsChannel yet, so an immediate + # generation would broadcast the streaming container before the socket is + # listening and the reply would never render live (SEC-506 Bug 4). Instead we + # render `show` in the reply_pending? state and let the session Stimulus + # controller POST to RepliesController once it has connected. def create prompt = current_user.prompts.for(@type).find(params[:prompt_id]) @@ -36,7 +43,6 @@ def create @session.messages.build(content: params[:prompt], role: :user, user: current_user) @session.save! - @session.request_reply! publish_event('echo_session.created', session: { id: @session.id }) render :show diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb index 1e7e95c14..37a6a886b 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb @@ -66,6 +66,17 @@ def request_reply! ReplyJob.perform_later(self) if enqueue end + # True when a reply is owed but generation hasn't started yet: the session is + # idle and the newest message is a user turn. `create` renders `show` in this + # state and lets the freshly-subscribed client trigger request_reply!, so the + # streaming container is only broadcast once the socket is listening (SEC-506 + # Bug 4). It flips back to false the moment ReplyJob flips the session to + # `generating` or an assistant reply lands, which makes the client trigger + # idempotent across reconnects and multiple viewers. + def reply_pending? + idle? && messages.order(:created_at, :id).last&.user? + end + # Only completed turns are safe to replay to a provider: a streaming row has # no content yet, and a failed one carries a nil/partial body. Sending either # would poison the next request with a `content: nil` turn. diff --git a/engines/dradis-echo/config/routes.rb b/engines/dradis-echo/config/routes.rb index a74dcfb3b..ae64f62bd 100644 --- a/engines/dradis-echo/config/routes.rb +++ b/engines/dradis-echo/config/routes.rb @@ -12,6 +12,7 @@ resources :grammar_suggestions, only: [:create], controller: 'projects/grammar_suggestions' resources :sessions, only: [:index, :show, :create], controller: 'projects/sessions' do + resource :reply, only: [:create], controller: 'projects/sessions/replies' resources :messages, only: [:create], controller: 'projects/sessions/messages' end end diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb index b223a339f..bd22b25fb 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb @@ -171,6 +171,31 @@ end end + describe '#reply_pending?' do + let(:session) { create(:echo_session) } + + it 'is true when idle and the newest message is a user turn' do + create(:echo_message, session: session, role: :user, content: 'Hello') + expect(session).to be_reply_pending + end + + it 'is false once an assistant reply is the newest message' do + create(:echo_message, session: session, role: :user, content: 'Hello') + create(:assistant_message, session: session, content: 'Hi there') + expect(session).not_to be_reply_pending + end + + it 'is false while the session is already generating' do + create(:echo_message, session: session, role: :user, content: 'Hello') + session.update!(status: :generating) + expect(session).not_to be_reply_pending + end + + it 'is false with no messages yet' do + expect(session).not_to be_reply_pending + end + end + describe 'destroying the record' do it 'destroys sessions attached to a destroyed Note' do note = create(:note) diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb new file mode 100644 index 000000000..99e1da2a1 --- /dev/null +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe 'Echo session replies' do + include ActiveJob::TestHelper + + let(:user) { create(:user) } + + before do + login_as_user(user) + @project = Project.new + end + + let(:agent) { create(:agent, enabled: true) } + let(:issue) { create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") } + let(:session) { create(:echo_session, agent: agent, record: issue) } + + describe 'POST /addons/echo/projects/:project_id/sessions/:session_id/reply' do + it 'enqueues a reply when one is pending (the newest message is a user turn)' do + create(:echo_message, session: session, role: :user, content: 'What is the impact?', user: user) + + expect { + post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/reply" + }.to have_enqueued_job(Dradis::Plugins::Echo::ReplyJob).with(session) + + expect(response).to have_http_status(:ok) + end + + it 'does not enqueue a reply once the session has already been answered' do + create(:echo_message, session: session, role: :user, content: 'What is the impact?', user: user) + create(:assistant_message, session: session, content: 'It is high.') + + expect { + post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/reply" + }.not_to have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) + + expect(response).to have_http_status(:ok) + end + + it 'denies a session whose record is outside the current project scope' do + other_session = create(:echo_session, agent: agent, record: create(:issue, node: create(:node))) + + expect { + post "/addons/echo/projects/#{@project.id}/sessions/#{other_session.id}/reply" + }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb index e820cfda5..2d119cd78 100644 --- a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb @@ -45,17 +45,22 @@ { type: 'issue', record: issue.id, prompt_id: prompt.id, prompt: 'Summarise the SQLi finding' } end - it 'creates a session with the first user message and triggers a reply' do + it 'creates a session with the first user message but defers the reply to the subscribed client' do expect { post "/addons/echo/projects/#{@project.id}/sessions", params: params }.to change(Dradis::Plugins::Echo::Session, :count).by(1) .and change(Dradis::Plugins::Echo::Message, :count).by(1) - .and have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) + + # The reply is triggered by the session Stimulus controller once it has + # subscribed, not by create — otherwise the streaming container broadcasts + # before the socket is listening and never renders live (SEC-506 Bug 4). + expect(Dradis::Plugins::Echo::ReplyJob).not_to have_been_enqueued session = Dradis::Plugins::Echo::Session.last expect(session.title).to eq(prompt.title) expect(session.user).to eq(user) expect(session.record).to eq(issue) + expect(session).to be_reply_pending message = session.messages.first expect(message.role).to eq('user') From ecfd8c4b8237ed2b554e404179cc6c5e546ac06e Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Fri, 17 Jul 2026 11:28:25 +0100 Subject: [PATCH 04/10] =?UTF-8?q?Echo:=20harden=20session=20controller=20?= =?UTF-8?q?=E2=80=94=20blank=20prompt,=20dead=20index,=20cable=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies PR #1655 review findings on the session controllers slice: - Blank-prompt 500 (#7): sessions#create now saves without a bang and returns 422 on an invalid (blank) prompt, mirroring the messages endpoint, instead of raising RecordInvalid. - Dead index (#9): drop the unused sessions#index action and route; nothing links to it (the back-link and lazy frame use the interactions path). The dead index view is removed in the views slice. - check_turbo_config (#10): extract a shared TurboConfigCheck concern that pings only the Redis adapter and memoizes, so async/test adapters no-op silently — no spurious 'can't contact Redis' alert, no per-request round-trip. - SessionsChannel (#11): tighten the class comment to say re-auth guards new subscriptions only (comment-only). --- CHANGELOG | 1 + .../dradis/plugins/echo/sessions_channel.rb | 10 +++--- .../dradis/plugins/echo/turbo_config_check.rb | 31 +++++++++++++++++ .../echo/projects/sessions_controller.rb | 29 +++++----------- engines/dradis-echo/config/routes.rb | 2 +- .../plugins/echo/turbo_config_check_spec.rb | 34 +++++++++++++++++++ .../plugins/echo/projects/sessions_spec.rb | 20 +++++------ 7 files changed, 90 insertions(+), 37 deletions(-) create mode 100644 engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb create mode 100644 engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 843410918..c47454b76 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,7 @@ v5.2.0 (July 2026) - Remove a stray leading blank line from streamed responses - Send only completed conversation turns to the AI provider, so a failed or in-progress reply can no longer break the next one - Show a generic message when a reply fails instead of surfacing the provider's raw error + - Return a graceful error instead of a server error when a chat session is started with a blank prompt v5.1.0 (May 2026) - DataTables: add sticky table toolbar that tracks below the navigation bar when scrolling diff --git a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb index 9ce9e26de..b88d60010 100644 --- a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb +++ b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb @@ -1,10 +1,12 @@ module Dradis::Plugins::Echo # Custom Turbo Streams channel for Echo sessions. Turbo's default channel # trusts any client holding a validly-signed stream name for the lifetime of - # that name — which never expires — so a user who loses project access keeps - # receiving the transcript. We re-check authorization at subscribe time - # instead: resolve the session behind the signed name and only stream if the - # subscriber may still :use its project, otherwise reject. + # that name — which never expires — so a user who loses project access could + # subscribe and keep receiving the transcript. We re-check authorization when + # a subscription is established: resolve the session behind the signed name + # and only stream if the subscriber may still :use its project, otherwise + # reject. Note this guards *new* subscriptions only; a connection opened while + # still authorized keeps streaming until it is torn down. # # Follows the documented turbo-rails custom-channel pattern # (Turbo::StreamsChannel). diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb new file mode 100644 index 000000000..0cc2a2edf --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb @@ -0,0 +1,31 @@ +module Dradis::Plugins::Echo + # Shared `check_turbo_config` before_action for the streaming controllers. + # Turbo Streams only work if Action Cable can reach its backend, so the view + # can warn the user when it can't. + # + # Only the Redis adapter needs a reachable external server, so we ping just + # that one and memoize the result. Every other adapter (async in development, + # test in specs) is always treated as healthy — no spurious "can't contact + # Redis" alert and no per-request Redis round-trip. + module TurboConfigCheck + extend ActiveSupport::Concern + + private + + def check_turbo_config + return @turbo_status if defined?(@turbo_status) + + @turbo_status = turbo_backend_reachable? + end + + def turbo_backend_reachable? + adapter = ActionCable.server.pubsub + return true unless adapter.is_a?(ActionCable::SubscriptionAdapter::Redis) + + adapter.redis_connection_for_subscriptions.ping + true + rescue StandardError + false + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb index d9f50fdec..9874bf1aa 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb @@ -3,20 +3,14 @@ class Projects::SessionsController < AuthenticatedController include EventPublisher include ProjectScoped include RecordScoping + include TurboConfigCheck layout false before_action :set_type - before_action :set_record, only: [:index, :create] + before_action :set_record, only: [:create] before_action :check_turbo_config, only: [:show, :create] before_action :set_session, only: [:show] - def index - @sessions = Session.for_record(@record).order(created_at: :desc) - - Prompt.seed_default_prompts(current_user) if current_user.prompts.empty? - @prompts = current_user.prompts.for(@type) - end - def show; end # Starts a conversation from a saved prompt. The Prompt is read only here, at @@ -41,24 +35,17 @@ def create user: current_user ) @session.messages.build(content: params[:prompt], role: :user, user: current_user) - @session.save! - - publish_event('echo_session.created', session: { id: @session.id }) - render :show + if @session.save + publish_event('echo_session.created', session: { id: @session.id }) + render :show + else + head :unprocessable_entity + end end private - def check_turbo_config - @turbo_status = begin - ActionCable.server.pubsub.redis_connection_for_subscriptions.ping - true - rescue - false - end - end - def record_params params.permit(:id, :prompt, :prompt_id, :project_id, :record, :type) end diff --git a/engines/dradis-echo/config/routes.rb b/engines/dradis-echo/config/routes.rb index ae64f62bd..5071f3bab 100644 --- a/engines/dradis-echo/config/routes.rb +++ b/engines/dradis-echo/config/routes.rb @@ -11,7 +11,7 @@ resources :grammar_corrections, only: [:create], controller: 'projects/grammar_corrections' resources :grammar_suggestions, only: [:create], controller: 'projects/grammar_suggestions' - resources :sessions, only: [:index, :show, :create], controller: 'projects/sessions' do + resources :sessions, only: [:show, :create], controller: 'projects/sessions' do resource :reply, only: [:create], controller: 'projects/sessions/replies' resources :messages, only: [:create], controller: 'projects/sessions/messages' end diff --git a/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb b/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb new file mode 100644 index 000000000..119856bca --- /dev/null +++ b/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +describe Dradis::Plugins::Echo::TurboConfigCheck do + subject(:controller) do + Class.new { include Dradis::Plugins::Echo::TurboConfigCheck }.new + end + + describe '#check_turbo_config' do + it 'reports healthy without pinging when the adapter is not Redis' do + adapter = double('non-redis adapter') + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + expect(adapter).not_to receive(:redis_connection_for_subscriptions) + + expect(controller.send(:check_turbo_config)).to be(true) + end + + it 'pings and reports healthy for a reachable Redis adapter' do + redis = double('redis', ping: 'PONG') + adapter = ActionCable::SubscriptionAdapter::Redis.allocate + allow(adapter).to receive(:redis_connection_for_subscriptions).and_return(redis) + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + + expect(controller.send(:check_turbo_config)).to be(true) + end + + it 'reports unhealthy when the Redis ping fails' do + adapter = ActionCable::SubscriptionAdapter::Redis.allocate + allow(adapter).to receive(:redis_connection_for_subscriptions).and_raise(StandardError) + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + + expect(controller.send(:check_turbo_config)).to be(false) + end + end +end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb index 2d119cd78..50559eeb9 100644 --- a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb @@ -29,17 +29,6 @@ create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") end - describe 'GET /addons/echo/projects/:project_id/sessions' do - it 'lists the sessions for the record' do - session = create(:echo_session, agent: roslin, record: issue) - - get "/addons/echo/projects/#{@project.id}/sessions", params: { type: 'issue', record: issue.id } - - expect(response).to have_http_status(:ok) - expect(response.body).to include(session.title.to_s) if session.title.present? - end - end - describe 'POST /addons/echo/projects/:project_id/sessions' do let(:params) do { type: 'issue', record: issue.id, prompt_id: prompt.id, prompt: 'Summarise the SQLi finding' } @@ -82,6 +71,15 @@ expect(session.title).to eq(prompt.title) end + it 'returns 422 for a blank prompt instead of raising a 500' do + expect { + post "/addons/echo/projects/#{@project.id}/sessions", + params: params.merge(prompt: '') + }.not_to change(Dradis::Plugins::Echo::Session, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + it 'honours the Prompt::SCOPES whitelist' do expect { post "/addons/echo/projects/#{@project.id}/sessions", From 86d6d25c7a202484cc2c76c6dfbb3124794e410b Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Wed, 22 Jul 2026 15:51:53 +0100 Subject: [PATCH 05/10] Echo: convert request-spec brace blocks to do...end for Style/BlockDelimiters --- .../echo/projects/sessions/messages_spec.rb | 12 ++++++------ .../echo/projects/sessions/replies_spec.rb | 12 ++++++------ .../plugins/echo/projects/sessions_spec.rb | 16 ++++++++-------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb index d12c41614..e9bf492ad 100644 --- a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb @@ -18,10 +18,10 @@ describe 'POST /addons/echo/projects/:project_id/sessions/:session_id/messages' do it 'appends a user message and triggers a reply' do - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/messages", params: { content: 'What is the impact?' } - }.to change { session.messages.count }.by(1) + end.to change { session.messages.count }.by(1) .and have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) expect(response).to have_http_status(:ok) @@ -33,10 +33,10 @@ end it 'rejects a blank message without enqueuing a reply' do - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/messages", params: { content: '' } - }.not_to change(Dradis::Plugins::Echo::Message, :count) + end.not_to change(Dradis::Plugins::Echo::Message, :count) expect(response).to have_http_status(:unprocessable_entity) end @@ -44,10 +44,10 @@ it 'denies a session whose record is outside the current project scope' do other_session = create(:echo_session, agent: agent, record: create(:issue, node: create(:node))) - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions/#{other_session.id}/messages", params: { content: 'Hello' } - }.to raise_error(ActiveRecord::RecordNotFound) + end.to raise_error(ActiveRecord::RecordNotFound) end end end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb index 99e1da2a1..f8ed626eb 100644 --- a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb @@ -20,9 +20,9 @@ it 'enqueues a reply when one is pending (the newest message is a user turn)' do create(:echo_message, session: session, role: :user, content: 'What is the impact?', user: user) - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/reply" - }.to have_enqueued_job(Dradis::Plugins::Echo::ReplyJob).with(session) + end.to have_enqueued_job(Dradis::Plugins::Echo::ReplyJob).with(session) expect(response).to have_http_status(:ok) end @@ -31,9 +31,9 @@ create(:echo_message, session: session, role: :user, content: 'What is the impact?', user: user) create(:assistant_message, session: session, content: 'It is high.') - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/reply" - }.not_to have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) + end.not_to have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) expect(response).to have_http_status(:ok) end @@ -41,9 +41,9 @@ it 'denies a session whose record is outside the current project scope' do other_session = create(:echo_session, agent: agent, record: create(:issue, node: create(:node))) - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions/#{other_session.id}/reply" - }.to raise_error(ActiveRecord::RecordNotFound) + end.to raise_error(ActiveRecord::RecordNotFound) end end end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb index 50559eeb9..148ca6c14 100644 --- a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb @@ -35,9 +35,9 @@ end it 'creates a session with the first user message but defers the reply to the subscribed client' do - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions", params: params - }.to change(Dradis::Plugins::Echo::Session, :count).by(1) + end.to change(Dradis::Plugins::Echo::Session, :count).by(1) .and change(Dradis::Plugins::Echo::Message, :count).by(1) # The reply is triggered by the session Stimulus controller once it has @@ -72,28 +72,28 @@ end it 'returns 422 for a blank prompt instead of raising a 500' do - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions", params: params.merge(prompt: '') - }.not_to change(Dradis::Plugins::Echo::Session, :count) + end.not_to change(Dradis::Plugins::Echo::Session, :count) expect(response).to have_http_status(:unprocessable_entity) end it 'honours the Prompt::SCOPES whitelist' do - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions", params: params.merge(type: 'node') - }.to raise_error(ActiveRecord::RecordNotFound) + end.to raise_error(ActiveRecord::RecordNotFound) end it 'denies a record outside the current project scope' do other_issue = create(:issue, node: create(:node)) - expect { + expect do post "/addons/echo/projects/#{@project.id}/sessions", params: params.merge(record: other_issue.id) - }.to raise_error(ActiveRecord::RecordNotFound) + end.to raise_error(ActiveRecord::RecordNotFound) end end end From c6b658351b60be290d9ce6ea8a34802f7209e44f Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Fri, 24 Jul 2026 17:21:31 +0100 Subject: [PATCH 06/10] Echo: apply Slice-4 review resolutions (channel rescue, record scoping, index) --- .../dradis/plugins/echo/sessions_channel.rb | 7 ++-- .../dradis/plugins/echo/record_scoping.rb | 12 +++++-- .../projects/sessions/messages_controller.rb | 2 +- .../projects/sessions/replies_controller.rb | 2 +- .../echo/projects/sessions_controller.rb | 24 +++++++++---- engines/dradis-echo/config/routes.rb | 2 +- .../plugins/echo/projects/sessions_spec.rb | 34 +++++++++++++++++++ 7 files changed, 68 insertions(+), 15 deletions(-) diff --git a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb index b88d60010..aaa34a8b1 100644 --- a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb +++ b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb @@ -32,14 +32,17 @@ def authorized?(session) # The signed name encodes `[session, :messages]`, i.e. # ":messages". A tampered name fails verification and - # yields nil, which we treat as unauthorized. + # yields nil (never reaching locate), so the only raise path left is a + # session deleted between signing and subscribe — RecordNotFound, which we + # treat as unauthorized. Narrower than a blanket rescue so genuine bugs + # (NoMethodError etc.) still surface. def session_from_stream_name name = verified_stream_name_from_params return unless name record = GlobalID::Locator.locate(name.split(':').first) record if record.is_a?(Session) - rescue StandardError + rescue ActiveRecord::RecordNotFound nil end end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb index 3791c5370..15d8f0fa1 100644 --- a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb @@ -4,14 +4,20 @@ module Dradis::Plugins::Echo # project; resolving it through the project's collections gives us # cross-project/record isolation for free — an out-of-scope id raises # ActiveRecord::RecordNotFound, mirroring Projects::GrammarController. + # + # We read the session's stored record_type/record_id directly rather than + # loading session.record: the association would fire a polymorphic query just + # to re-scope through the project. Issues are persisted as 'Issue' even though + # they descend from Note (see Session#record=), so the comparison matches the + # column exactly. module RecordScoping extend ActiveSupport::Concern private - def scoped_record(record) - collection = record.is_a?(Issue) ? current_project.issues : current_project.notes - collection.find(record.id) + def scoped_record(session) + collection = session.record_type == 'Issue' ? current_project.issues : current_project.notes + collection.find(session.record_id) end end end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb index 99fdfb492..8a354c649 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb @@ -23,7 +23,7 @@ def create def set_session @session = Session.find(params[:session_id]) - scoped_record(@session.record) + scoped_record(@session) end end end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb index 220ef6fb0..651ef31a8 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb @@ -22,7 +22,7 @@ def create def set_session @session = Session.find(params[:session_id]) - scoped_record(@session.record) + scoped_record(@session) end end end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb index 9874bf1aa..856ad53a9 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb @@ -6,11 +6,19 @@ class Projects::SessionsController < AuthenticatedController include TurboConfigCheck layout false - before_action :set_type - before_action :set_record, only: [:create] + before_action :set_type, only: [:create, :index] + before_action :set_record, only: [:create, :index] before_action :check_turbo_config, only: [:show, :create] before_action :set_session, only: [:show] + # Lists the record's past sessions plus the prompts available to start a new + # one. Roslin-disabled and prompts empty-state warnings are handled by the + # view. + def index + @sessions = Session.for_record(@record) + @prompts = current_user.prompts.for(@type) + end + def show; end # Starts a conversation from a saved prompt. The Prompt is read only here, at @@ -51,19 +59,21 @@ def record_params end def set_record - raise ActiveRecord::RecordNotFound if @type.blank? - @record = current_project.send(@type.to_s.pluralize).find(record_params[:record]) end def set_session @session = Session.find(record_params[:id]) - @record = scoped_record(@session.record) + @record = scoped_record(@session) end + # Whitelists the record type against Prompt::SCOPES before it reaches a + # dynamic `current_project.send(@type.pluralize)` dispatch. An unknown type + # is an out-of-scope request, not a 500 — raise the same RecordNotFound the + # scoped lookups do. def set_type - allowed = Prompt::SCOPES.map(&:to_s) - @type = allowed.include?(record_params[:type]) ? record_params[:type].to_sym : nil + @type = record_params[:type]&.to_sym + raise ActiveRecord::RecordNotFound unless Prompt::SCOPES.include?(@type) end end end diff --git a/engines/dradis-echo/config/routes.rb b/engines/dradis-echo/config/routes.rb index 5071f3bab..ae64f62bd 100644 --- a/engines/dradis-echo/config/routes.rb +++ b/engines/dradis-echo/config/routes.rb @@ -11,7 +11,7 @@ resources :grammar_corrections, only: [:create], controller: 'projects/grammar_corrections' resources :grammar_suggestions, only: [:create], controller: 'projects/grammar_suggestions' - resources :sessions, only: [:show, :create], controller: 'projects/sessions' do + resources :sessions, only: [:index, :show, :create], controller: 'projects/sessions' do resource :reply, only: [:create], controller: 'projects/sessions/replies' resources :messages, only: [:create], controller: 'projects/sessions/messages' end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb index 148ca6c14..8e2fc8b73 100644 --- a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb @@ -29,6 +29,40 @@ create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") end + describe 'GET /addons/echo/projects/:project_id/sessions' do + it 'lists the record-scoped sessions and prompts' do + prompt # a prompt must exist for the sessions list to render + session = Dradis::Plugins::Echo::Session.create!( + agent: roslin, record: issue, title: 'Existing session', user: user + ) + + get "/addons/echo/projects/#{@project.id}/sessions", + params: { type: 'issue', record: issue.id } + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Existing session') + expect(response.body).to include( + ActionView::RecordIdentifier.dom_id(session) + ) + end + + it 'honours the Prompt::SCOPES whitelist' do + expect do + get "/addons/echo/projects/#{@project.id}/sessions", + params: { type: 'node', record: issue.id } + end.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'denies a record outside the current project scope' do + other_issue = create(:issue, node: create(:node)) + + expect do + get "/addons/echo/projects/#{@project.id}/sessions", + params: { type: 'issue', record: other_issue.id } + end.to raise_error(ActiveRecord::RecordNotFound) + end + end + describe 'POST /addons/echo/projects/:project_id/sessions' do let(:params) do { type: 'issue', record: issue.id, prompt_id: prompt.id, prompt: 'Summarise the SQLi finding' } From 7bc671b991aec80b2fc704e65799098be3c9916a Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Fri, 31 Jul 2026 14:03:07 +0100 Subject: [PATCH 07/10] Echo: authorize socket subscriptions via session.project Ask the session for its project instead of reaching through session.record.project. record is polymorphic, so the delegation is the right seam for the authorization check and keeps callers decoupled from where a session's project currently comes from. --- .../app/channels/dradis/plugins/echo/sessions_channel.rb | 2 +- .../spec/channels/dradis/plugins/echo/sessions_channel_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb index aaa34a8b1..b91fff5bd 100644 --- a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb +++ b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb @@ -27,7 +27,7 @@ def subscribed private def authorized?(session) - Ability.new(current_user).can?(:use, session.record.project) + Ability.new(current_user).can?(:use, session.project) end # The signed name encodes `[session, :messages]`, i.e. diff --git a/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb b/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb index 3c26051b3..b33227e7f 100644 --- a/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb +++ b/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb @@ -19,7 +19,7 @@ expect(subscription).to have_stream_from(stream_name) end - it 'rejects a user whose :use on the record project is denied' do + it "rejects a user whose :use on the session's project is denied" do allow_any_instance_of(Ability).to receive(:can?).and_return(false) subscribe(signed_stream_name: signed_name) From 6c4141e745c719dc52d91cc4513b5b6a645f5f20 Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Fri, 31 Jul 2026 15:27:51 +0100 Subject: [PATCH 08/10] =?UTF-8?q?Echo:=20apply=20caitmich=20review=20?= =?UTF-8?q?=E2=80=94=20only:=20Session,=20role=20callback,=20generalized?= =?UTF-8?q?=20scoping,=20duck-typed=20cable=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dradis/plugins/echo/sessions_channel.rb | 3 +-- .../concerns/dradis/plugins/echo/record_scoping.rb | 7 +++---- .../dradis/plugins/echo/turbo_config_check.rb | 11 ++++++----- .../app/models/dradis/plugins/echo/message.rb | 10 ++++++++++ .../dradis/plugins/echo/turbo_config_check_spec.rb | 5 ++++- .../models/dradis/plugins/echo/message_spec.rb | 14 ++++++++++++++ 6 files changed, 38 insertions(+), 12 deletions(-) diff --git a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb index b91fff5bd..faa9d7c84 100644 --- a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb +++ b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb @@ -40,8 +40,7 @@ def session_from_stream_name name = verified_stream_name_from_params return unless name - record = GlobalID::Locator.locate(name.split(':').first) - record if record.is_a?(Session) + GlobalID::Locator.locate(name.split(':').first, only: Session) rescue ActiveRecord::RecordNotFound nil end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb index 15d8f0fa1..e87712766 100644 --- a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb @@ -8,16 +8,15 @@ module Dradis::Plugins::Echo # We read the session's stored record_type/record_id directly rather than # loading session.record: the association would fire a polymorphic query just # to re-scope through the project. Issues are persisted as 'Issue' even though - # they descend from Note (see Session#record=), so the comparison matches the - # column exactly. + # they descend from Note (see Session#record=), so record_type maps straight + # onto the matching project collection (issues, notes, ...). module RecordScoping extend ActiveSupport::Concern private def scoped_record(session) - collection = session.record_type == 'Issue' ? current_project.issues : current_project.notes - collection.find(session.record_id) + current_project.public_send(session.record_type.underscore.pluralize).find(session.record_id) end end end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb index 0cc2a2edf..92982ff97 100644 --- a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb @@ -3,10 +3,11 @@ module Dradis::Plugins::Echo # Turbo Streams only work if Action Cable can reach its backend, so the view # can warn the user when it can't. # - # Only the Redis adapter needs a reachable external server, so we ping just - # that one and memoize the result. Every other adapter (async in development, - # test in specs) is always treated as healthy — no spurious "can't contact - # Redis" alert and no per-request Redis round-trip. + # Only a Redis-backed adapter needs a reachable external server, so we ping + # just those (duck-typed on the subscription connection) and memoize the + # result. Every other adapter (async in development, test in specs) is always + # treated as healthy — no spurious "can't contact Redis" alert and no + # per-request Redis round-trip. module TurboConfigCheck extend ActiveSupport::Concern @@ -20,7 +21,7 @@ def check_turbo_config def turbo_backend_reachable? adapter = ActionCable.server.pubsub - return true unless adapter.is_a?(ActionCable::SubscriptionAdapter::Redis) + return true unless adapter.respond_to?(:redis_connection_for_subscriptions) adapter.redis_connection_for_subscriptions.ping true diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb index 68a10408f..4b3660e4b 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb @@ -16,6 +16,12 @@ class Message < ApplicationRecord belongs_to :user, optional: true # -- Callbacks ------------------------------------------------------------ + # The author is the source of truth for a user turn, so controllers don't + # repeat `role: :user` on every build. ReplyJob still sets `role: :assistant` + # explicitly (assistant messages carry no user). Runs before the predicate + # callbacks below so `user?` sees the derived role. + before_validation :set_role_from_user + # User messages are authored in full, so they're never mid-stream. before_validation :complete_user_messages, if: :user? @@ -45,5 +51,9 @@ def broadcast_created def complete_user_messages self.status = :complete end + + def set_role_from_user + self.role ||= :user if user.present? + end end end diff --git a/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb b/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb index 119856bca..1a5173083 100644 --- a/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb +++ b/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb @@ -7,9 +7,12 @@ describe '#check_turbo_config' do it 'reports healthy without pinging when the adapter is not Redis' do + # A plain double doesn't respond_to :redis_connection_for_subscriptions, + # so the duck-typed guard returns healthy without a ping. (Stubbing the + # method here would make respond_to? true and defeat the check — an + # unexpected call would instead raise, still failing the example.) adapter = double('non-redis adapter') allow(ActionCable.server).to receive(:pubsub).and_return(adapter) - expect(adapter).not_to receive(:redis_connection_for_subscriptions) expect(controller.send(:check_turbo_config)).to be(true) end diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb index 6ee3f2c65..cd17dde56 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb @@ -46,6 +46,20 @@ end end + describe 'role derivation' do + it 'derives the user role from the author when none is given' do + message = build(:echo_message, role: nil) + message.valid? + expect(message.role).to eq('user') + end + + it 'leaves an explicit assistant role untouched' do + message = build(:assistant_message) + message.valid? + expect(message.role).to eq('assistant') + end + end + describe 'metadata' do it 'is stored as JSON' do message = create(:echo_message, metadata: { 'model' => 'qwen2.5:14b' }) From 7c178fac666cfcab147ab17355b741b784e01ab4 Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Fri, 31 Jul 2026 15:27:56 +0100 Subject: [PATCH 09/10] Echo: extract HasSession concern, scope routes under projects module, expect content param --- .../dradis/plugins/echo/has_session.rb | 21 +++++++++++++++++++ .../projects/sessions/messages_controller.rb | 13 ++---------- .../projects/sessions/replies_controller.rb | 11 +--------- .../echo/projects/sessions_controller.rb | 8 +++++-- engines/dradis-echo/config/routes.rb | 20 +++++++++++------- .../echo/projects/sessions/messages_spec.rb | 7 +++++-- 6 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb new file mode 100644 index 000000000..98d97b358 --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb @@ -0,0 +1,21 @@ +module Dradis::Plugins::Echo + # Loads the URL-nested session for the message/reply controllers and re-scopes + # it through the current project, so an out-of-scope session_id raises + # ActiveRecord::RecordNotFound rather than leaking another project's session. + # Pulls in RecordScoping for the scoped lookup; consumers only include this. + module HasSession + extend ActiveSupport::Concern + include RecordScoping + + included do + before_action :set_session + end + + private + + def set_session + @session = Session.find(params[:session_id]) + scoped_record(@session) + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb index 8a354c649..8c5f31ccb 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb @@ -1,15 +1,13 @@ module Dradis::Plugins::Echo class Projects::Sessions::MessagesController < AuthenticatedController + include HasSession include ProjectScoped - include RecordScoping layout false - before_action :set_session - # Appends a user turn and re-opens the reply gate. The new message # broadcasts itself into the transcript, so there's nothing to render back. def create - message = @session.messages.build(content: params[:content], role: :user, user: current_user) + message = @session.messages.build(content: params.expect(:content), user: current_user) if message.save @session.request_reply! @@ -18,12 +16,5 @@ def create head :unprocessable_entity end end - - private - - def set_session - @session = Session.find(params[:session_id]) - scoped_record(@session) - end end end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb index 651ef31a8..e4cef5c30 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb @@ -1,11 +1,9 @@ module Dradis::Plugins::Echo class Projects::Sessions::RepliesController < AuthenticatedController + include HasSession include ProjectScoped - include RecordScoping layout false - before_action :set_session - # Starts generation for a freshly-created session. The session Stimulus # controller POSTs here from `connect`, i.e. after the browser has subscribed # to the SessionsChannel, so the streaming container ReplyJob broadcasts lands @@ -17,12 +15,5 @@ def create @session.request_reply! if @session.reply_pending? head :ok end - - private - - def set_session - @session = Session.find(params[:session_id]) - scoped_record(@session) - end end end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb index 856ad53a9..10c3e1a91 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb @@ -19,7 +19,9 @@ def index @prompts = current_user.prompts.for(@type) end - def show; end + def show + @messages = @session.messages.order(:created_at, :id) + end # Starts a conversation from a saved prompt. The Prompt is read only here, at # the controller boundary — we copy its title and never store a FK, so a @@ -34,6 +36,8 @@ def show; end # render `show` in the reply_pending? state and let the session Stimulus # controller POST to RepliesController once it has connected. def create + return head :unprocessable_entity if params[:prompt].blank? + prompt = current_user.prompts.for(@type).find(params[:prompt_id]) @session = Session.new( @@ -42,7 +46,7 @@ def create title: prompt.title, user: current_user ) - @session.messages.build(content: params[:prompt], role: :user, user: current_user) + @session.messages.build(content: params[:prompt], user: current_user) if @session.save publish_event('echo_session.created', session: { id: @session.id }) diff --git a/engines/dradis-echo/config/routes.rb b/engines/dradis-echo/config/routes.rb index ae64f62bd..3800bcf1a 100644 --- a/engines/dradis-echo/config/routes.rb +++ b/engines/dradis-echo/config/routes.rb @@ -4,16 +4,20 @@ resources :providers, except: [:show] resources :projects, only: [] do - resources :interactions, only: [:index, :show, :create], controller: 'projects/interactions' do - get :preview, on: :member - end + scope module: 'projects' do + resources :interactions, only: [:index, :show, :create] do + get :preview, on: :member + end - resources :grammar_corrections, only: [:create], controller: 'projects/grammar_corrections' - resources :grammar_suggestions, only: [:create], controller: 'projects/grammar_suggestions' + resources :grammar_corrections, only: [:create] + resources :grammar_suggestions, only: [:create] - resources :sessions, only: [:index, :show, :create], controller: 'projects/sessions' do - resource :reply, only: [:create], controller: 'projects/sessions/replies' - resources :messages, only: [:create], controller: 'projects/sessions/messages' + resources :sessions, only: [:index, :show, :create] do + scope module: 'sessions' do + resource :reply, only: [:create] + resources :messages, only: [:create] + end + end end end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb index e9bf492ad..cca74a084 100644 --- a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb @@ -33,12 +33,15 @@ end it 'rejects a blank message without enqueuing a reply' do + # params.expect(:content) treats a blank required scalar as missing and + # raises ParameterMissing (a 400 in production, before the reply gate is + # ever reached) rather than persisting an invalid message. expect do post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/messages", params: { content: '' } - end.not_to change(Dradis::Plugins::Echo::Message, :count) + end.to raise_error(ActionController::ParameterMissing) - expect(response).to have_http_status(:unprocessable_entity) + expect(session.messages.count).to eq(0) end it 'denies a session whose record is outside the current project scope' do From 4bd83cf84af7a7a76c627e9efbf72c9419d0a9d2 Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Fri, 31 Jul 2026 15:28:01 +0100 Subject: [PATCH 10/10] Echo: extract session messages partial, drop redundant session-link params --- .../plugins/echo/projects/sessions/_messages.html.erb | 7 +++++++ .../dradis/plugins/echo/projects/sessions/index.html.erb | 2 +- .../dradis/plugins/echo/projects/sessions/show.html.erb | 9 +++------ 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_messages.html.erb diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_messages.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_messages.html.erb new file mode 100644 index 000000000..60935049b --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_messages.html.erb @@ -0,0 +1,7 @@ +<%# locals: (messages:) -%> +<%# The live transcript container. ReplyJob and Message#broadcast_created append %> +<%# new turns into #echo-messages, so its id must stay stable across renders. %> +
+ <%= render partial: 'dradis/plugins/echo/projects/sessions/messages/message', + collection: messages, as: :message %> +
diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb index 144cda73f..5bc6007a9 100644 --- a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb @@ -4,7 +4,7 @@
<% @sessions.each do |session| %> <%= link_to session.title.presence || "Session ##{session.id}", - echo.project_session_path(current_project, session, type: @type, record: @record.id), + echo.project_session_path(current_project, session), id: dom_id(session), class: 'list-group-item list-group-item-action', data: { turbo_frame: dom_id(@record, :echo) } %> diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb index 364c02f56..d176a89a4 100644 --- a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb @@ -5,15 +5,12 @@
<% end %> - <%# Live transcript + composer state. Slice 5 adds `channel: SessionsChannel` %> - <%# so the socket re-authorizes on subscribe. %> + <%# Live transcript + composer state. A later slice wires the custom %> + <%# SessionsChannel (`channel:`) so the socket re-authorizes on subscribe. %> <%= turbo_stream_from [@session, :messages] %> <%= turbo_stream_from [@session, :composer_state] %> -
- <%= render partial: 'dradis/plugins/echo/projects/sessions/messages/message', - collection: @session.messages.order(:created_at, :id), as: :message %> -
+ <%= render 'dradis/plugins/echo/projects/sessions/messages', messages: @messages %> <%= render 'dradis/plugins/echo/projects/sessions/composer_state', session: @session %>