Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 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).
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.project)
end

# The signed name encodes `[session, :messages]`, i.e.
# "<session-gid-param>:messages". A tampered name fails verification and
# 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

GlobalID::Locator.locate(name.split(':').first, only: Session)
rescue ActiveRecord::RecordNotFound
nil
end
end
end
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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.
#
# 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 record_type maps straight
# onto the matching project collection (issues, notes, ...).
module RecordScoping
extend ActiveSupport::Concern

private

def scoped_record(session)
current_project.public_send(session.record_type.underscore.pluralize).find(session.record_id)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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 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

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.respond_to?(:redis_connection_for_subscriptions)

adapter.redis_connection_for_subscriptions.ping
true
rescue StandardError
false
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module Dradis::Plugins::Echo
class Projects::Sessions::MessagesController < AuthenticatedController
include HasSession
include ProjectScoped
layout false

# 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.expect(:content), user: current_user)

if message.save
@session.request_reply!
head :ok
else
head :unprocessable_entity
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module Dradis::Plugins::Echo
class Projects::Sessions::RepliesController < AuthenticatedController
include HasSession
include ProjectScoped
layout false

# 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
Comment thread
etdsoft marked this conversation as resolved.
@session.request_reply! if @session.reply_pending?
head :ok
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
module Dradis::Plugins::Echo
class Projects::SessionsController < AuthenticatedController
include EventPublisher
include ProjectScoped
include RecordScoping
include TurboConfigCheck
layout false

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about renaming this scope to something like record_scoped? Session.for_record sounds like we're creating a session based on other naming conventions we have in the app like from_rtp etc

@etdsoft etdsoft Jul 31, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not certain a different name is better. "record scoped" signifies it's a scope for this record, which I think "for_record" kind of already does...

The minions also noticed:

same pattern as Notification.for_user

@prompts = current_user.prompts.for(@type)
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
# 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
return head :unprocessable_entity if params[:prompt].blank?

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], user: current_user)

if @session.save
publish_event('echo_session.created', session: { id: @session.id })
render :show
else
head :unprocessable_entity
end
end

private

def record_params
params.permit(:id, :prompt, :prompt_id, :project_id, :record, :type)
end

def set_record
@record = current_project.send(@type.to_s.pluralize).find(record_params[:record])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is overwritten on L67

end

def set_session
@session = Session.find(record_params[:id])
@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
@type = record_params[:type]&.to_sym
raise ActiveRecord::RecordNotFound unless Prompt::SCOPES.include?(@type)
end
end
end
6 changes: 3 additions & 3 deletions engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions engines/dradis-echo/app/models/dradis/plugins/echo/message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions engines/dradis-echo/app/models/dradis/plugins/echo/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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. %>
<div id="echo-messages" data-behavior="echo-messages">
<%= render partial: 'dradis/plugins/echo/projects/sessions/messages/message',
collection: messages, as: :message %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<% if Dradis::Plugins::Echo::Agents::Roslin.enabled? %>
<% if @prompts.any? %>
<% if @sessions.any? %>
<div class="list-group mb-3" id="echo-sessions" data-behavior="echo-sessions">
<% @sessions.each do |session| %>
<%= link_to session.title.presence || "Session ##{session.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) } %>
<% end %>
</div>
<% end %>

<p>
Interact with <i class="fa-solid fa-robot me-1"></i>Roslin by selecting a
<%= link_to 'saved prompt', echo.prompts_path, data: { turbo_frame: '_top' } %> to start a session for this Issue.
</p>
<p>
You can edit the prompt before sending it, any changes are one-off and
won't affect the saved prompt template.
</p>
<% 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 %>
<div class="alert alert-warning">
<% 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 %>
<i class="fa-solid fa-robot me-1"></i>Roslin is not enabled. Ask an
admin to enable it to allow LLM interactions.
<% end %>
</div>
<p>
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.
</p>
<% end %>
Loading
Loading