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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +152 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- 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
|