actionagent 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +26 -2
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +39 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +16 -4
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +84 -0
  66. data/app/models/action_agent/exploration.rb +847 -0
  67. data/app/models/action_agent/github_installation.rb +159 -0
  68. data/app/models/action_agent/input_request.rb +291 -0
  69. data/app/models/action_agent/project.rb +789 -0
  70. data/app/models/action_agent/project_secret.rb +339 -0
  71. data/app/models/action_agent/provider_key.rb +200 -3
  72. data/app/models/action_agent/recording_event.rb +145 -0
  73. data/app/models/action_agent/recording_snapshot.rb +7 -7
  74. data/app/models/action_agent/sandbox_session.rb +297 -14
  75. data/app/models/action_agent/scenario_catalog.rb +172 -0
  76. data/app/models/action_agent/scenario_product.rb +57 -0
  77. data/app/models/action_agent/scenario_set.rb +138 -0
  78. data/app/models/action_agent/session_recording.rb +121 -14
  79. data/app/models/action_agent/telemetry_trace.rb +74 -19
  80. data/app/models/concerns/action_agent/ownable.rb +84 -13
  81. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  82. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  83. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  84. data/app/services/action_agent/agent_execution_service.rb +450 -72
  85. data/app/services/action_agent/agent_sync.rb +5 -2
  86. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  87. data/app/services/action_agent/agent_toolbox.rb +215 -9
  88. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  89. data/app/services/action_agent/browser_sidecar.rb +213 -0
  90. data/app/services/action_agent/browser_sign_in.rb +163 -0
  91. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  92. data/app/services/action_agent/claude_code_auth.rb +70 -4
  93. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  94. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  95. data/app/services/action_agent/evaluation_fix.rb +101 -0
  96. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  97. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  98. data/app/services/action_agent/evaluation_standing.rb +15 -8
  99. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  100. data/app/services/action_agent/github_client.rb +405 -64
  101. data/app/services/action_agent/live_updates.rb +31 -0
  102. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  103. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  104. data/app/services/action_agent/mcp_client.rb +6 -2
  105. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  106. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  107. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  108. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  109. data/app/services/action_agent/project_boot_fix.rb +309 -0
  110. data/app/services/action_agent/project_capabilities.rb +200 -0
  111. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  112. data/app/services/action_agent/project_preflight.rb +178 -0
  113. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  114. data/app/services/action_agent/project_setup.rb +429 -0
  115. data/app/services/action_agent/provider_credentials.rb +102 -0
  116. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  117. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  118. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  119. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  120. data/app/services/action_agent/sandbox_browser.rb +299 -0
  121. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  122. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  123. data/app/services/action_agent/sandbox_mail.rb +103 -0
  124. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  125. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  126. data/app/services/action_agent/sandbox_patch.rb +253 -0
  127. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  128. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  129. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  130. data/app/services/action_agent/secret_scrubber.rb +33 -4
  131. data/app/services/action_agent/session_index.rb +270 -0
  132. data/app/services/action_agent/session_recording_service.rb +11 -0
  133. data/app/services/action_agent/session_timeline.rb +505 -0
  134. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  135. data/app/views/action_agent/session_player/show.html.erb +19 -0
  136. data/config/routes.rb +152 -1
  137. data/lib/action_agent/claude_login_process.rb +107 -0
  138. data/lib/action_agent/engine.rb +55 -5
  139. data/lib/action_agent/secret_requests.rb +80 -0
  140. data/lib/action_agent/version.rb +1 -1
  141. data/lib/action_agent.rb +507 -22
  142. data/lib/generators/action_agent/install_generator.rb +37 -0
  143. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  144. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  145. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  146. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  147. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  148. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  149. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  150. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  151. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  152. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  153. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  154. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  155. data/lib/tasks/action_agent.rake +33 -0
  156. data/lib/tasks/browser.rake +22 -0
  157. data/lib/tasks/sandbox.rake +1 -1
  158. data/lib/tasks/scenario_catalogs.rake +71 -0
  159. metadata +96 -2
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to start and stop a checkout sandbox's browser, and to account for
5
+ # it: the browser columns of its SandboxSession, the SessionRecording of
6
+ # what it showed, and the minutes it ran.
7
+ #
8
+ # A sandbox runs at most one browser, which the sandbox backend starts
9
+ # (SandboxOrchestrator#start_browser) pointed at the sandbox's app. While
10
+ # it runs, every agent run against the sandbox reaches it as the MCP server
11
+ # "browser:<session_id>" (SandboxSession#browser_server_entry).
12
+ #
13
+ # Browser statuses:
14
+ # starting the backend is starting it
15
+ # running its MCP endpoint answers
16
+ # stopped it was stopped, or its sandbox expired
17
+ # failed it did not start
18
+ class SandboxBrowser
19
+ class Error < StandardError; end
20
+
21
+ STARTED = %w[starting running].freeze
22
+ # A start that has been "starting" this long died before it finished.
23
+ STALE_START = 10.minutes
24
+
25
+ # Starts +sandbox+'s browser and returns the sandbox, with the browser
26
+ # running.
27
+ #
28
+ # @param mode [String, Symbol] one of SandboxSession::BROWSER_MODES
29
+ # @param capabilities [Array<String>] optional tool groups, from
30
+ # SandboxSession::BROWSER_CAPABILITIES
31
+ # @param recording_url [#call, nil] given the browser's new
32
+ # SessionRecording, returns the absolute URL its events are posted to
33
+ # (the recording's events endpoint); nil records nothing
34
+ # @param live_origins [Array<String>] the dashboard origins whose pages
35
+ # may open the browser's live view; none gives it no live view.
36
+ # Anything that is not an http(s) origin is left out.
37
+ # @raise [Error] when the sandbox cannot run a browser now, a browser
38
+ # already runs, or the backend failed to start one
39
+ # @param storage_state [Hash, nil] a Playwright storage state the browser
40
+ # starts with, such as a project's saved sign-in
41
+ def self.start(sandbox, mode:, capabilities: [], recording_url: nil, live_origins: [], storage_state: nil)
42
+ new(sandbox).start(mode.to_s, Array(capabilities).map(&:to_s).uniq, recording_url, origins(live_origins), storage_state)
43
+ end
44
+
45
+ # +values+ as the origins a browser compares an Origin header with:
46
+ # "scheme://host" with the port unless it is the scheme's default.
47
+ #
48
+ # @example
49
+ # origins(["http://LOCALHOST:3000/activeagents", "https://dash.example:443", "file:///etc"])
50
+ # # => ["http://localhost:3000", "https://dash.example"]
51
+ def self.origins(values)
52
+ Array(values).filter_map do |value|
53
+ uri = URI.parse(value.to_s)
54
+ next unless %w[http https].include?(uri.scheme) && uri.host.present?
55
+
56
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
57
+ "#{uri.scheme}://#{uri.host.downcase}#{port}"
58
+ rescue URI::InvalidURIError
59
+ nil
60
+ end.uniq
61
+ end
62
+
63
+ # The sandbox, with a browser running: the one already running, or one
64
+ # started headless (see .start) with +capabilities+ and +storage_state+.
65
+ # Returns [sandbox, whether it started one].
66
+ #
67
+ # @raise [Error] as .start does
68
+ def self.ensure_running!(sandbox, capabilities: [], recording_url: nil, storage_state: nil)
69
+ sandbox.reload
70
+ return [ sandbox, false ] if sandbox.browser_running?
71
+
72
+ [ start(sandbox, mode: "headless", capabilities: capabilities, recording_url: recording_url, storage_state: storage_state), true ]
73
+ end
74
+
75
+ # A recording_url for .start that posts to the events endpoint of the
76
+ # dashboard mounted at +mount_url+ (its absolute URL, the request's base
77
+ # URL and script name), for a start outside a request. Nil without one.
78
+ #
79
+ # @return [Proc, nil]
80
+ def self.recording_url_for(mount_url)
81
+ return nil if mount_url.blank?
82
+
83
+ ->(recording) { "#{mount_url.to_s.chomp('/')}/api/session_recordings/#{recording.id}/events" }
84
+ end
85
+
86
+ # The running browser's cookies and localStorage for the sandbox's app,
87
+ # as a Playwright storage state, read from the sidecar's
88
+ # GET /storage-state next to its MCP endpoint.
89
+ #
90
+ # @raise [Error] when no browser runs or it does not answer
91
+ # @return [Hash]
92
+ def self.storage_state(sandbox)
93
+ entry = sandbox.browser_server_entry or raise Error, "The sandbox's browser is not running"
94
+
95
+ uri = URI.join(entry[:url], "storage-state")
96
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
97
+ http.request(Net::HTTP::Get.new(uri, entry[:headers]))
98
+ end
99
+ raise Error, "The browser did not hand over its sign-in (HTTP #{response.code})" unless response.is_a?(Net::HTTPSuccess)
100
+
101
+ state = JSON.parse(response.body)["storage_state"]
102
+ raise Error, "The browser answered without a storage state" unless state.is_a?(Hash)
103
+
104
+ state
105
+ rescue JSON::ParserError, SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, URI::Error => e
106
+ raise Error, "The browser did not hand over its sign-in: #{e.class}"
107
+ end
108
+
109
+ # Stops +sandbox+'s browser through its backend, then finishes it
110
+ # (finish!). Returns the sandbox.
111
+ #
112
+ # @raise [Error] when the backend could not stop it
113
+ def self.stop(sandbox)
114
+ if STARTED.include?(sandbox.browser_status)
115
+ orchestrator = orchestrator!
116
+ if orchestrator.supports?(:stop_browser) && orchestrator.stop_browser(sandbox) == false
117
+ raise Error, "The browser could not be stopped; try again"
118
+ end
119
+ end
120
+ finish!(sandbox)
121
+ sandbox
122
+ rescue Error
123
+ raise
124
+ rescue StandardError => e
125
+ raise Error, "The browser could not be stopped: #{e.message}"
126
+ end
127
+
128
+ # Stops +sandbox+'s browser through its backend (stop) when one is
129
+ # starting or running and the sandbox is not yet past its expiry. Called
130
+ # before a sandbox is expired, so the browser's last recorded events are
131
+ # posted while its recording still accepts them. Past the expiry the
132
+ # recording would refuse them, and the browser has already stopped itself
133
+ # (SandboxSession#browser_stops_at), so this does nothing. Never raises:
134
+ # a browser it cannot stop is left to finish! and SandboxCleanupJob.
135
+ def self.stop_before_expiry(sandbox)
136
+ sandbox.reload
137
+ return unless STARTED.include?(sandbox.browser_status) && sandbox.expires_at&.future?
138
+
139
+ stop(sandbox)
140
+ rescue StandardError => e
141
+ Rails.logger.warn("[ActionAgent] could not stop the browser of sandbox #{sandbox.session_id} before expiring it: #{e.message}")
142
+ end
143
+
144
+ # The configured sandbox backend, or Error when it cannot be loaded.
145
+ def self.orchestrator!
146
+ SandboxOrchestrator.new
147
+ rescue StandardError, LoadError => e
148
+ raise Error, "The sandbox backend is unavailable: #{e.message}"
149
+ end
150
+
151
+ # Marks +sandbox+'s browser stopped and clears its endpoint and token, so
152
+ # no run reaches it any more, then records the minutes it ran
153
+ # (ActionAgent.record_usage with :browser_minutes, against
154
+ # SandboxSession#metering_owner) and completes the sandbox's live
155
+ # recordings. Asks nothing of the backend. Does nothing unless the
156
+ # browser was starting or running.
157
+ #
158
+ # @return [Integer, nil] the minutes recorded, nil when none were
159
+ def self.finish!(sandbox)
160
+ minutes = nil
161
+ finished = false
162
+ sandbox.with_lock do
163
+ next unless STARTED.include?(sandbox.browser_status)
164
+
165
+ minutes = sandbox.browser_minutes if sandbox.browser_started_at
166
+ sandbox.update!(browser_status: "stopped", browser_mcp_url: nil, browser_live_url: nil, browser_token: nil)
167
+ finished = true
168
+ end
169
+ return nil unless finished
170
+
171
+ ActionAgent.record_usage(sandbox.metering_owner, :browser_minutes, minutes) if minutes
172
+ SessionRecording.recording.where(sandbox_session_id: sandbox.id).find_each(&:complete!)
173
+ minutes
174
+ end
175
+
176
+ def initialize(sandbox)
177
+ @sandbox = sandbox
178
+ end
179
+
180
+ def start(mode, capabilities, recording_url, live_origins, storage_state = nil)
181
+ validate!(mode, capabilities)
182
+ orchestrator = self.class.orchestrator!
183
+ unless orchestrator.supports?(:start_browser)
184
+ raise Error, "The #{orchestrator.backend_name} sandbox backend cannot run a browser"
185
+ end
186
+ unless orchestrator.browser_modes.include?(mode.to_sym)
187
+ raise Error, "The #{orchestrator.backend_name} sandbox backend cannot show a browser window; start the browser headless"
188
+ end
189
+
190
+ token = "aabrw_#{SecureRandom.base58(40)}"
191
+ claim!(mode, token)
192
+ recording = nil
193
+ launch = nil
194
+ begin
195
+ recording, launch_recording = start_recording(recording_url)
196
+ launch = { token: token, app_url: @sandbox.cloud_run_url, capabilities: capabilities, stop_at: @sandbox.browser_stops_at,
197
+ recording: launch_recording, live: live_origins.any? ? { session_id: @sandbox.session_id, origins: live_origins } : nil,
198
+ storage_state: storage_state }.compact
199
+ @sandbox.browser_launch = launch
200
+ result = orchestrator.start_browser(@sandbox, mode: mode.to_sym)
201
+ unless mark_running!(result, token)
202
+ release(orchestrator)
203
+ raise Error, "The sandbox stopped while its browser was starting"
204
+ end
205
+ rescue StandardError, LoadError => e
206
+ failed!(recording, e)
207
+ message = e.is_a?(Error) ? e.message : "The browser did not start: #{e.message}"
208
+ raise Error, SecretScrubber.scrub(message, [ token, launch&.dig(:recording, :token) ])
209
+ ensure
210
+ @sandbox.browser_launch = nil
211
+ end
212
+ @sandbox
213
+ end
214
+
215
+ private
216
+
217
+ def validate!(mode, capabilities)
218
+ raise Error, "Only a checkout sandbox runs a browser" unless @sandbox.app_runtime?
219
+ unless @sandbox.active? && (@sandbox.ready? || @sandbox.running?)
220
+ raise Error, "The sandbox is #{@sandbox.status}; start its browser once it is ready"
221
+ end
222
+ raise Error, "The sandbox expires too soon to start a browser" unless @sandbox.browser_stops_at&.future?
223
+ raise Error, "The sandbox has no app to open" if @sandbox.cloud_run_url.blank?
224
+ unless SandboxSession::BROWSER_MODES.include?(mode)
225
+ raise Error, "mode must be one of #{SandboxSession::BROWSER_MODES.join(', ')}"
226
+ end
227
+
228
+ unknown = capabilities - SandboxSession::BROWSER_CAPABILITIES
229
+ raise Error, "Unknown browser capabilities: #{unknown.join(', ')}" if unknown.any?
230
+ end
231
+
232
+ # Takes the browser for this start, under the row lock: refused while
233
+ # another start or browser holds it.
234
+ def claim!(mode, token)
235
+ @sandbox.with_lock do
236
+ if STARTED.include?(@sandbox.browser_status) && !stale_start?
237
+ raise Error, "The sandbox's browser is already #{@sandbox.browser_status}"
238
+ end
239
+
240
+ @sandbox.update!(browser_status: "starting", browser_mode: mode, browser_token: token, browser_mcp_url: nil,
241
+ browser_live_url: nil, browser_started_at: nil)
242
+ end
243
+ end
244
+
245
+ def stale_start?
246
+ @sandbox.browser_status == "starting" && @sandbox.updated_at < STALE_START.ago
247
+ end
248
+
249
+ # A new recording for the browser, and what the backend needs to post to
250
+ # it: nil for both without +recording_url+.
251
+ def start_recording(recording_url)
252
+ return [ nil, nil ] if recording_url.nil?
253
+
254
+ recording = SessionRecording.start!(sandbox_session: @sandbox, source: "agent",
255
+ name: "browser_#{Time.current.strftime('%Y%m%d_%H%M%S')}")
256
+ limits = RecordingEvent.limits
257
+ [ recording, {
258
+ url: recording_url.call(recording),
259
+ token: recording.issue_ingest_token!,
260
+ batch_events: limits[:batch_events],
261
+ batch_bytes: limits[:batch_bytes]
262
+ } ]
263
+ end
264
+
265
+ # Records the running browser, unless the sandbox was stopped or expired
266
+ # while it started. Returns whether it did.
267
+ def mark_running!(result, token)
268
+ @sandbox.with_lock do
269
+ next false unless @sandbox.browser_status == "starting" && @sandbox.active?
270
+
271
+ @sandbox.update!(
272
+ browser_status: "running",
273
+ browser_mcp_url: result[:mcp_url],
274
+ browser_token: result[:mcp_token].presence || token,
275
+ browser_live_url: result[:live_url],
276
+ browser_started_at: Time.current
277
+ )
278
+ true
279
+ end
280
+ end
281
+
282
+ def release(orchestrator)
283
+ orchestrator.stop_browser(@sandbox)
284
+ rescue StandardError => e
285
+ Rails.logger.warn("[ActionAgent] could not stop the browser of sandbox #{@sandbox.session_id}: #{e.message}")
286
+ end
287
+
288
+ def failed!(recording, error)
289
+ @sandbox.with_lock do
290
+ next unless @sandbox.browser_status == "starting"
291
+
292
+ @sandbox.update!(browser_status: "failed", browser_token: nil, browser_mcp_url: nil, browser_live_url: nil)
293
+ end
294
+ recording&.fail!("The browser did not start")
295
+ rescue StandardError => e
296
+ Rails.logger.warn("[ActionAgent] could not record a failed browser start (#{error.class}): #{e.message}")
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to drive a sandbox's running browser from the engine itself, for the
5
+ # steps no model takes: opening the app at a project's start URL, and
6
+ # signing in with credentials the model never sees (BrowserSignIn).
7
+ #
8
+ # It talks to the browser's MCP endpoint in an MCP session of its own,
9
+ # which drives the same tab as an agent's session. Its calls are not agent
10
+ # tool calls, so they are neither traced nor recorded as actions; the
11
+ # browser's own recording masks what is typed.
12
+ class SandboxBrowserDriver
13
+ class Error < StandardError; end
14
+
15
+ # The line Playwright MCP reports a page's address on.
16
+ PAGE_URL = /^- Page URL: (\S+)/
17
+
18
+ # @raise [Error] when the sandbox's browser is not running
19
+ def initialize(sandbox)
20
+ @sandbox = sandbox
21
+ entry = sandbox.browser_server_entry or raise Error, "The sandbox's browser is not running"
22
+ @client = MCPClient.new(url: entry[:url], label: entry[:name], headers: entry[:headers] || {})
23
+ end
24
+
25
+ # Calls +tool+ and returns its text. With +log_arguments+ false the
26
+ # arguments' values are left out of the log.
27
+ #
28
+ # @raise [Error] when the tool fails or the browser does not answer
29
+ # @return [String]
30
+ def call(tool, arguments = {}, log_arguments: true)
31
+ result = @client.call_tool(tool.to_s, arguments, log_arguments: log_arguments)
32
+ raise Error, "#{tool} failed in the sandbox's browser" if result[:is_error]
33
+
34
+ result[:text].to_s
35
+ rescue MCPClient::Error => e
36
+ raise Error, "The sandbox's browser did not answer #{tool}: #{e.class}"
37
+ end
38
+
39
+ # Opens +path+ (a path on the sandbox's app) and returns the page URL
40
+ # the browser reports, or nil.
41
+ def open(path)
42
+ page_url(call("browser_navigate", { url: path.to_s.presence || "/" }))
43
+ end
44
+
45
+ # Runs +function+, the source of a JavaScript function taking no
46
+ # arguments, in the page and returns what it returned, parsed from JSON
47
+ # (nil when it returned nothing readable).
48
+ def evaluate(function)
49
+ text = call("browser_evaluate", { function: function })
50
+ json = text[/^### Result\n(.*?)(?=\n### |\z)/m, 1]
51
+ json && JSON.parse(json.strip)
52
+ rescue JSON::ParserError
53
+ nil
54
+ end
55
+
56
+ # Types +text+ into the element +selector+ names, a CSS selector. The
57
+ # text is a credential, so it is never logged.
58
+ def type(selector, text, submit: false)
59
+ call("browser_type", { target: selector, text: text, submit: submit }, log_arguments: false)
60
+ nil
61
+ end
62
+
63
+ def click(selector)
64
+ call("browser_click", { target: selector })
65
+ nil
66
+ end
67
+
68
+ # The path of the page the browser shows, or nil.
69
+ def current_path
70
+ path = evaluate("() => location.pathname")
71
+ path.is_a?(String) ? path : nil
72
+ end
73
+
74
+ def wait(seconds)
75
+ call("browser_wait_for", { time: seconds })
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ def page_url(text)
82
+ text[PAGE_URL, 1]
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Defense in depth for custom adapters and accidental copies into app/.
5
+ # The actual credentials live outside the checkout and are never read.
6
+ #
7
+ # Only what a copy of them would look like is excluded: a Claude Code
8
+ # credentials file anywhere, and the sandbox's own config directories at
9
+ # the checkout's root. A repository's .claude/ settings, or an app's
10
+ # lib/claude/ code, stay in diffs, file reads and pull requests.
11
+ module SandboxCredentialPaths
12
+ DIRECTORIES = %w[claude claude-api claude-home claude-login].freeze
13
+ FILE = ".credentials.json"
14
+ module_function
15
+
16
+ def protected?(path)
17
+ parts = path.to_s.delete_prefix("./").split("/")
18
+ DIRECTORIES.any? { |name| parts.first.to_s.casecmp?(name) } || parts.any? { |part| part.casecmp?(FILE) }
19
+ end
20
+
21
+ def pathspecs
22
+ DIRECTORIES.map { |name| ":(exclude,glob,icase)#{name}/**" } +
23
+ [ ":(exclude,glob,icase)**/#{FILE}", ":(exclude,glob,icase)#{FILE}" ]
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to read the mail a sandbox's app sent, such as a sign-up's
5
+ # verification link.
6
+ #
7
+ # A sandbox backend sets DIRECTORY_ENV in the app's environment, and the
8
+ # engine, which every sandbox app bundles, then switches Action Mailer to
9
+ # Mail's file delivery into that directory (see ActionAgent::Engine). File
10
+ # delivery appends each message to one file per recipient address, so the
11
+ # newest message for an address is the last one in its file. The setting
12
+ # lives only in the sandbox's environment, never in the checkout.
13
+ module SandboxMail
14
+ # Raised when the sandbox's backend cannot read its files.
15
+ class Unsupported < StandardError; end
16
+
17
+ DIRECTORY_ENV = "ACTION_AGENT_SANDBOX_MAIL_DIR"
18
+ # Where mail is written, relative to the checkout.
19
+ DIRECTORY = "tmp/activeagents/mail"
20
+ ADDRESS = /\A[^\s@\/\\]+@[^\s@\/\\]+\z/
21
+ MAX_TEXT = 4_000
22
+ MAX_LINKS = 20
23
+ LINK = %r{https?://[^\s"'<>()]+}
24
+
25
+ module_function
26
+
27
+ # The newest message the app sent to +to+ as { subject:, from:, to:,
28
+ # date:, text:, links: [{ url:, path: }] }, scrubbed of +secrets+, or {}
29
+ # when none was sent. Each link's path is its path and query, which the
30
+ # sandbox browser opens on the app whatever host the mail named.
31
+ #
32
+ # @raise [ArgumentError] when +to+ is not an email address
33
+ # @raise [Unsupported] when the backend cannot read the sandbox's files
34
+ # @return [Hash]
35
+ def last_message(sandbox, to:, secrets: [], orchestrator: SandboxOrchestrator.new)
36
+ address = to.to_s.strip
37
+ raise ArgumentError, "to must be an email address" unless address.match?(ADDRESS)
38
+ unless orchestrator.supports?(:read_file)
39
+ raise Unsupported, "The #{orchestrator.backend_name} sandbox backend cannot read the sandbox's mail"
40
+ end
41
+
42
+ mailbox = orchestrator.read_file(sandbox, "#{DIRECTORY}/#{address}")
43
+ return {} if mailbox.blank?
44
+
45
+ message = parse(last_raw_message(mailbox.to_s.dup.force_encoding(Encoding::BINARY)))
46
+ message ? SecretScrubber.scrub(message, secrets) : {}
47
+ end
48
+
49
+ # The last message in +mailbox+, a file Mail's file delivery appended
50
+ # messages to, each followed by a blank line. A message starts with a
51
+ # header line after that blank line (Date, or Return-Path when the app
52
+ # sets one), and its header block carries a Message-ID.
53
+ def last_raw_message(mailbox)
54
+ starts = [ 0 ]
55
+ mailbox.scan(/\r?\n\r?\n(?=[A-Za-z][A-Za-z0-9-]*: )/) { starts << Regexp.last_match.end(0) }
56
+ start = starts.reverse.find do |offset|
57
+ header_end = mailbox.index(/\r?\n\r?\n/, offset) || mailbox.length
58
+ mailbox[offset...header_end].match?(/^Message-ID:/i)
59
+ end
60
+ mailbox[(start || 0)..]
61
+ end
62
+
63
+ def parse(raw)
64
+ require "mail"
65
+ mail = Mail.new(raw)
66
+ text = message_text(mail)
67
+ {
68
+ subject: mail.subject.to_s,
69
+ from: Array(mail.from).join(", "),
70
+ to: Array(mail.to).join(", "),
71
+ date: mail.date&.iso8601,
72
+ text: text.truncate(MAX_TEXT),
73
+ links: links(text, mail)
74
+ }
75
+ rescue LoadError
76
+ raise Unsupported, "Reading the sandbox's mail needs the mail gem in the dashboard app"
77
+ rescue StandardError => e
78
+ Rails.logger.warn("[ActionAgent] could not parse a sandbox's mail: #{e.class}")
79
+ nil
80
+ end
81
+
82
+ def message_text(mail)
83
+ part = mail.multipart? ? (mail.text_part || mail.html_part) : mail
84
+ body = part&.decoded.to_s.dup.force_encoding(Encoding::UTF_8).scrub
85
+ part&.mime_type.to_s.include?("html") ? html_text(body) : body.strip
86
+ end
87
+
88
+ def html_text(html)
89
+ ActionController::Base.helpers.strip_tags(html.gsub(/<(br|\/p|\/div|\/li|\/h\d)[^>]*>/i, "\n")).gsub(/\n{3,}/, "\n\n").strip
90
+ end
91
+
92
+ def links(text, mail)
93
+ html = mail.multipart? ? mail.html_part&.decoded.to_s : (mail.mime_type.to_s.include?("html") ? mail.decoded.to_s : "")
94
+ urls = (text.scan(LINK) + html.scan(/href=["'](https?:[^"']+)["']/i).flatten).map { |url| CGI.unescapeHTML(url) }
95
+ urls.uniq.first(MAX_LINKS).map do |url|
96
+ uri = URI.parse(url)
97
+ { url: url, path: [ uri.path.presence || "/", uri.query ].compact.join("?") }
98
+ rescue URI::InvalidURIError
99
+ { url: url, path: nil }
100
+ end
101
+ end
102
+ end
103
+ end