Skip to content

Commit c38effa

Browse files
authored
Toolbar and /tidewave/app (#106)
1 parent 6361cf9 commit c38effa

8 files changed

Lines changed: 423 additions & 11 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ Your agent will be able to use this MCP server to talk to your running Rails app
1212

1313
This MCP server is an open-source component of [Tidewave](https://tidewave.ai), the agentic development environment for Rails and Phoenix.
1414

15-
You can use this project as a standalone MCP server or integrated with the [Tidewave product](https://tidewave.ai).
16-
17-
To use it as a standalone MCP server, follow the installation instructions below.
15+
You can use this project as a standalone MCP server or integrated with the [Tidewave product](https://tidewave.ai) by following the instructions below.
1816

1917
## Installation
2018

@@ -117,6 +115,10 @@ Also, because it resolves the location from your running app instead of parsing
117115
118116
## Troubleshooting
119117

118+
### The Tidewave toolbar is missing
119+
120+
This may happen if you are compressing your responses (gzip, brotli, etc) after the Tidewave middleware runs. Use `bin/rails middleware` and make sure Tidewave comes after `Rack::Deflater` or similar. Also look into your browser and terminal logs for any errors.
121+
120122
### Using multiple hosts/subdomains
121123

122124
If you are using multiple hosts/subdomains during development, you must use `*.localhost`, as such domains are considered secure by browsers. Additionally, add the following to `config/initializers/development.rb`:
@@ -135,7 +137,7 @@ The above will allow your application to run embedded within Tidewave across mul
135137

136138
### Content security policy
137139

138-
If you have enabled Content-Security-Policy, Tidewave will automatically enable "unsafe-eval" under `script-src` in order for contextual browser testing to work correctly. It also disables the `frame-ancestors` directive.
140+
If you have enabled Content-Security-Policy, Tidewave will automatically enable "unsafe-eval" under `script-src` in order for contextual browser testing to work correctly. It also disables the `frame-ancestors` directive. This is done only in the environments that Tidewave is loadead (development by default).
139141

140142
### Production Environment
141143

@@ -161,6 +163,8 @@ The following config is available:
161163

162164
* `team` - set your Tidewave Team configuration, such as `config.tidewave.team = { id: "my-company" }`
163165

166+
* `toolbar` - controls whether the Tidewave toolbar is injected into HTML pages. Defaults to `true`
167+
164168
## Acknowledgements
165169

166170
A thank you to Yorick Jacquin for the initial version of this project.

lib/tidewave.rb

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "fileutils"
4+
require "cgi"
45
require "ipaddr"
56
require "json"
67
require "pathname"
@@ -29,9 +30,50 @@ module Tools
2930
end
3031

3132
class Tidewave
33+
class ToolbarBody
34+
def initialize(body, toolbar)
35+
@body = body
36+
@toolbar = toolbar
37+
@closed = false
38+
end
39+
40+
def each
41+
return enum_for(:each) unless block_given?
42+
43+
pending = +""
44+
injected = false
45+
46+
@body.each do |part|
47+
if injected
48+
yield part
49+
else
50+
pending << part
51+
52+
if closing_head = pending.downcase.index("</head>")
53+
toolbar = @toolbar.dup.force_encoding(pending.encoding)
54+
output = pending.insert(closing_head, toolbar)
55+
pending = nil
56+
injected = true
57+
yield output
58+
end
59+
end
60+
end
61+
62+
yield pending unless injected || pending.empty?
63+
end
64+
65+
def close
66+
return if @closed
67+
68+
@closed = true
69+
@body.close if @body.respond_to?(:close)
70+
end
71+
end
72+
3273
TIDEWAVE_ROUTE = "tidewave".freeze
3374
MCP_ROUTE = "mcp".freeze
3475
CONFIG_ROUTE = "config".freeze
76+
APP_ROUTE = "app".freeze
3577
UPLOAD_ROUTE = "upload".freeze
3678
PROTOCOL_VERSION = "2025-03-26".freeze
3779
MAX_UPLOAD_SIZE = 10_000_000
@@ -47,12 +89,18 @@ class Tidewave
4789

4890
INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze
4991
INVALID_UPLOAD = "Bad Request: missing or invalid file parameter".freeze
92+
ENCODED_HTML_WARNING = <<~TEXT.freeze
93+
Tidewave could not inject the toolbar because the HTML response is encoded.
94+
95+
If you use Rack::Deflater or another compression middleware, place it before Tidewave in the middleware stack.
96+
TEXT
5097

5198
DEFAULT_OPTIONS = {
5299
allow_remote_access: false,
53100
client_url: "https://tidewave.ai",
54101
framework_type: "rack",
55-
team: {}
102+
team: {},
103+
toolbar: true
56104
}.freeze
57105

58106
def initialize(app, options = {})
@@ -77,6 +125,8 @@ def call(env)
77125
case [ request.request_method, path ]
78126
when [ "GET", [ TIDEWAVE_ROUTE ] ]
79127
home_endpoint(request)
128+
when [ "GET", [ TIDEWAVE_ROUTE, APP_ROUTE ] ]
129+
app_endpoint(request)
80130
when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
81131
config_endpoint(request)
82132
when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ]
@@ -89,7 +139,7 @@ def call(env)
89139
path == [ TIDEWAVE_ROUTE, MCP_ROUTE ] ? method_not_allowed() : not_found()
90140
end
91141
else
92-
strip_x_frame_options(@app.call(env))
142+
inject_toolbar(request, strip_x_frame_options(@app.call(env)))
93143
end
94144
end
95145

@@ -118,6 +168,25 @@ def home_endpoint(_request)
118168
[ 200, response_headers("text/html", body), [ body ] ]
119169
end
120170

171+
def app_endpoint(_request)
172+
client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
173+
body = <<~HTML
174+
<!DOCTYPE html>
175+
<html>
176+
<head>
177+
<meta charset="UTF-8" />
178+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
179+
<script type="module" src="#{client_url}/tc/control.js"></script>
180+
</head>
181+
<body></body>
182+
</html>
183+
HTML
184+
185+
headers = response_headers("text/html", body)
186+
headers["content-security-policy"] = "base-uri 'self'; frame-ancestors 'self';"
187+
[ 200, headers, [ body ] ]
188+
end
189+
121190
def config_endpoint(request)
122191
json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
123192
end
@@ -167,10 +236,70 @@ def config_data(request)
167236
"team" => @options[:team] || {},
168237
"tidewave_version" => VERSION,
169238
"local_port" => local_port(request),
170-
"tmp_dir" => TMP_DIR
239+
"tmp_dir" => TMP_DIR,
240+
"wsl_distro" => ENV["WSL_DISTRO_NAME"]
171241
}
172242
end
173243

244+
def inject_toolbar(request, response)
245+
status, headers, body = response
246+
return response if @options[:toolbar] == false || !html_response?(headers)
247+
248+
if encoded_response?(headers)
249+
warn_encoded_html
250+
return response
251+
end
252+
253+
return response unless body.respond_to?(:each)
254+
255+
delete_response_header(headers, "content-length")
256+
delete_response_header(headers, "etag")
257+
[ status, headers, ToolbarBody.new(body, toolbar_html(request)) ]
258+
end
259+
260+
def html_response?(headers)
261+
content_types = Array(response_header(headers, "content-type"))
262+
263+
content_types.any? { |content_type| content_type.to_s.downcase.start_with?("text/html") }
264+
end
265+
266+
def encoded_response?(headers)
267+
Array(response_header(headers, "content-encoding")).any? do |content_encoding|
268+
content_encoding.to_s.split(",").any? do |encoding|
269+
!encoding.strip.empty? && encoding.strip.downcase != "identity"
270+
end
271+
end
272+
end
273+
274+
def response_header(headers, name)
275+
key = headers.keys.find { |header| header.downcase == name }
276+
headers[key] if key
277+
end
278+
279+
def delete_response_header(headers, name)
280+
headers.delete_if { |header, _value| header.downcase == name }
281+
end
282+
283+
def warn_encoded_html
284+
return if @warned_encoded_html
285+
286+
@warned_encoded_html = true
287+
@logger&.warn(ENCODED_HTML_WARNING)
288+
end
289+
290+
def toolbar_html(request)
291+
client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
292+
payload = {
293+
"tidewave" => config_data(request),
294+
"root" => @root.to_s
295+
}
296+
297+
<<~HTML
298+
<meta name="tidewave:config" content="#{CGI.escapeHTML(JSON.generate(payload))}" />
299+
<script async type="module" src="#{client_url}/tc/toolbar.js"></script>
300+
HTML
301+
end
302+
174303
def upload_endpoint(request)
175304
return text_response(400, INVALID_UPLOAD) if upload_too_large?(request)
176305

@@ -232,6 +361,7 @@ def response_headers(content_type, body)
232361
def origin_allowed_path?(path)
233362
[
234363
[ TIDEWAVE_ROUTE ],
364+
[ TIDEWAVE_ROUTE, APP_ROUTE ],
235365
[ TIDEWAVE_ROUTE, CONFIG_ROUTE ],
236366
[ TIDEWAVE_ROUTE, UPLOAD_ROUTE ]
237367
].include?(path)

lib/tidewave/configuration.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
class Tidewave
44
class Configuration
5-
attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware
5+
attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware, :toolbar
66

77
def initialize
88
# Rails has a hosts middleware which already checks for this
@@ -13,6 +13,7 @@ def initialize
1313
@client_url = "https://tidewave.ai"
1414
@team = {}
1515
@logger_middleware = nil
16+
@toolbar = true
1617
end
1718
end
1819
end

lib/tidewave/railtie.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "logger"
4+
require "uri"
45
require "tidewave/configuration"
56
require "tidewave/exceptions_middleware"
67
require "tidewave/quiet_requests_middleware"
@@ -24,6 +25,7 @@ class Railtie < Rails::Railtie
2425
framework_type: "rails",
2526
project_name: app.class.module_parent.name,
2627
team: tidewave_config.team,
28+
toolbar: tidewave_config.toolbar,
2729
logger: tidewave_config.logger || Rails.logger,
2830
root: Rails.root,
2931
log_file: Rails.root.join("log", "#{Rails.env}.log"),
@@ -35,11 +37,21 @@ class Railtie < Rails::Railtie
3537
# If the user configured CSP, we need to alter it in dev
3638
# to allow TC to run browser_eval.
3739
app.config.content_security_policy.try do |content_security_policy|
38-
content_security_policy.directives["script-src"].try do |script_src|
40+
directives = content_security_policy.directives
41+
script_src = directives["script-src"] || directives["default-src"]&.dup
42+
client_origin = URI.parse(tidewave_config.client_url.to_s).origin
43+
44+
script_src.try do
3945
script_src << "'unsafe-eval'" unless script_src.include?("'unsafe-eval'")
46+
script_src << client_origin unless script_src.include?(client_origin)
47+
directives["script-src"] = script_src
48+
end
49+
50+
directives["script-src-elem"].try do |script_src_elem|
51+
script_src_elem << client_origin unless script_src_elem.include?(client_origin)
4052
end
4153

42-
content_security_policy.directives.delete("frame-ancestors")
54+
directives.delete("frame-ancestors")
4355
end
4456
end
4557
end

test/rack_lint_test.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ def test_config_endpoint_is_rack_conformant
3030
assert_lint_ok { @mock.get("/tidewave/config") }
3131
end
3232

33+
def test_app_endpoint_is_rack_conformant
34+
assert_lint_ok { @mock.get("/tidewave/app") }
35+
end
36+
37+
def test_toolbar_response_is_rack_conformant
38+
downstream = ->(_env) { [ 200, { "content-type" => "text/html" }, [ "<html><head></head></html>" ] ] }
39+
app = Rack::Lint.new(Tidewave.new(downstream, project_name: "lint", allow_remote_access: true))
40+
41+
response = Rack::MockRequest.new(app).get("/")
42+
43+
assert_includes response.body, "/tc/toolbar.js"
44+
end
45+
3346
def test_mcp_post_is_rack_conformant
3447
assert_lint_ok { @mock.post("/tidewave/mcp", input: @init) }
3548
end

test/railtie_test.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@ def test_railtie_boots_and_inserts_middleware_with_expected_options
2020
assert_equal "rails", options[:framework_type]
2121
assert_equal "TidewaveRailtieTestApp", options[:project_name]
2222
assert_equal({ id: "dashbit" }, options[:team])
23+
assert_equal true, options[:toolbar]
2324
assert_same TidewaveRailtieTestApp::LOGGER, options[:logger]
2425
assert_equal TidewaveRailtieTestApp::ROOT, options[:root]
2526
assert_equal TidewaveRailtieTestApp::ROOT.join("log", "#{Rails.env}.log"), options[:log_file]
2627
assert_equal :sequel, options[:orm_adapter]
2728
assert_kind_of Proc, options[:before_reload]
29+
30+
directives = app.config.content_security_policy.directives
31+
assert_equal [ "'self'" ], directives["default-src"]
32+
assert_equal [ "'self'", "'unsafe-eval'", "https://example.test" ], directives["script-src"]
33+
assert_equal [ "'self'", "https://example.test" ], directives["script-src-elem"]
34+
refute directives.key?("frame-ancestors")
2835
end
2936
end

test/test_helper.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ class Application < Rails::Application
3131
config.tidewave.preferred_orm = :sequel
3232
config.tidewave.logger = LOGGER
3333
config.tidewave.logger_middleware = ActionDispatch::ShowExceptions
34+
config.content_security_policy do |policy|
35+
policy.default_src :self
36+
policy.frame_ancestors :self
37+
policy.script_src_elem :self
38+
end
3439
end
3540
end
3641

0 commit comments

Comments
 (0)