actionagent 1.8.0 → 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 +71 -12
- 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 +58 -7
- 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 +148 -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 +627 -20
- 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 +39 -10
- 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 +72 -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 +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- 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/model_pricing.rb +214 -34
- 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 +207 -4
- 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 +300 -16
- 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/evaluation_serializer.rb +41 -10
- 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_scorecard.rb +50 -21
- 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/codex_session_events.rb +37 -0
- 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_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- 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 +1263 -102
- 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 +290 -23
- 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 +121 -17
- 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 +153 -2
- 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 +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -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 +100 -2
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# A batch of one kind of event that a browser recorded during a session.
|
|
5
|
+
#
|
|
6
|
+
# Kinds:
|
|
7
|
+
# rrweb DOM snapshots and mutations from the rrweb recorder
|
|
8
|
+
# action an agent's browser tool call, written by MCPRecordingMiddleware
|
|
9
|
+
# console a console line from the recorded page
|
|
10
|
+
# marker a label a recorder placed on the timeline
|
|
11
|
+
# human_input input a person relayed while taking over a browser
|
|
12
|
+
#
|
|
13
|
+
# Each event in a row is a Hash of "at", its server time in epoch
|
|
14
|
+
# milliseconds, and "data", what the recorder sent. A browser's events are
|
|
15
|
+
# stored in server time: the client's timestamp plus the clock offset of the
|
|
16
|
+
# batch they arrived in. Rows are ordered by occurred_from, batch_index, id.
|
|
17
|
+
#
|
|
18
|
+
# The payload is the row's events as gzip JSON. A payload up to
|
|
19
|
+
# INLINE_PAYLOAD_LIMIT compressed bytes is kept in the row. A larger one is
|
|
20
|
+
# attached through Active Storage when the host has it, and kept in the row
|
|
21
|
+
# otherwise.
|
|
22
|
+
class RecordingEvent < ApplicationRecord
|
|
23
|
+
include Ownable
|
|
24
|
+
owned_by :account, :user
|
|
25
|
+
|
|
26
|
+
KINDS = %w[rrweb action console marker human_input].freeze
|
|
27
|
+
# The kinds a browser may post. The server writes the others.
|
|
28
|
+
CLIENT_KINDS = %w[rrweb console marker].freeze
|
|
29
|
+
|
|
30
|
+
# See ActionAgent.recording_limits.
|
|
31
|
+
DEFAULT_LIMITS = {
|
|
32
|
+
batch_events: 1_000,
|
|
33
|
+
batch_bytes: 1.megabyte,
|
|
34
|
+
recording_events: 100_000,
|
|
35
|
+
recording_bytes: 100.megabytes
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
INLINE_PAYLOAD_LIMIT = 64.kilobytes
|
|
39
|
+
|
|
40
|
+
# How deep a batch's JSON may nest. An rrweb full snapshot nests two
|
|
41
|
+
# levels per DOM element, so an ordinary page passes the JSON gem's
|
|
42
|
+
# default of 100.
|
|
43
|
+
MAX_NESTING = 512
|
|
44
|
+
|
|
45
|
+
belongs_to :session_recording
|
|
46
|
+
|
|
47
|
+
# Per ActionAgent.active_storage, like RecordingSnapshot: the gem depends
|
|
48
|
+
# on railties, not rails, so a host may have no Active Storage at all, or
|
|
49
|
+
# may switch attachments off.
|
|
50
|
+
has_one_attached :payload_file, **ActionAgent.attachment_options if ActionAgent.active_storage_macros?
|
|
51
|
+
|
|
52
|
+
validates :kind, inclusion: { in: KINDS }
|
|
53
|
+
validates :occurred_from, :occurred_to, presence: true
|
|
54
|
+
|
|
55
|
+
before_validation :copy_owner_from_recording, on: :create
|
|
56
|
+
|
|
57
|
+
scope :chronological, -> { order(:occurred_from, :batch_index, :id) }
|
|
58
|
+
|
|
59
|
+
# The caps in force: DEFAULT_LIMITS with ActionAgent.recording_limits
|
|
60
|
+
# merged over them.
|
|
61
|
+
# @return [Hash{Symbol => Integer}]
|
|
62
|
+
def self.limits
|
|
63
|
+
DEFAULT_LIMITS.merge((ActionAgent.recording_limits || {}).to_h.symbolize_keys)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Whether payloads can be attached in this host app: Active Storage on
|
|
67
|
+
# per ActionAgent.active_storage, the macro applied, and its tables
|
|
68
|
+
# migrated.
|
|
69
|
+
def self.attachments_available?
|
|
70
|
+
ActionAgent.active_storage_available? && method_defined?(:payload_file)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Parses JSON text nested up to MAX_NESTING levels.
|
|
74
|
+
def self.parse_json(text)
|
|
75
|
+
JSON.parse(text, max_nesting: MAX_NESTING)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns +value+ as JSON text at any depth. Responses that carry event
|
|
79
|
+
# data are generated with it: the default encoder refuses more than 100
|
|
80
|
+
# levels, and event data was parsed with a limit of MAX_NESTING.
|
|
81
|
+
def self.generate_json(value)
|
|
82
|
+
JSON.generate(value.as_json, max_nesting: false)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Converts epoch milliseconds to a Time.
|
|
86
|
+
def self.time_at(milliseconds)
|
|
87
|
+
Time.zone.at(milliseconds.to_r / 1000)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Converts a Time to epoch milliseconds.
|
|
91
|
+
def self.milliseconds(time)
|
|
92
|
+
(time.to_r * 1000).floor
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The row's events, decoded. Empty when the payload cannot be read.
|
|
96
|
+
# @return [Array<Hash>]
|
|
97
|
+
def events
|
|
98
|
+
@events ||= begin
|
|
99
|
+
compressed = payload.nil? || payload.empty? ? attached_payload : payload
|
|
100
|
+
compressed ? self.class.parse_json(ActiveSupport::Gzip.decompress(compressed)) : []
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
Rails.logger.warn("[ActionAgent] recording event #{id} payload unreadable: #{e.class}: #{e.message}")
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Stores +list+ as the payload and sets the counts and the time range
|
|
108
|
+
# from it. Each entry is a Hash with an "at" in epoch milliseconds.
|
|
109
|
+
def events=(list)
|
|
110
|
+
json = self.class.generate_json(list)
|
|
111
|
+
compressed = ActiveSupport::Gzip.compress(json)
|
|
112
|
+
times = list.map { |event| event["at"].to_i }
|
|
113
|
+
|
|
114
|
+
self.event_count = list.size
|
|
115
|
+
self.byte_size = json.bytesize
|
|
116
|
+
self.occurred_from = self.class.time_at(times.min || 0)
|
|
117
|
+
self.occurred_to = self.class.time_at(times.max || 0)
|
|
118
|
+
|
|
119
|
+
if compressed.bytesize > INLINE_PAYLOAD_LIMIT && self.class.attachments_available?
|
|
120
|
+
self.payload = nil
|
|
121
|
+
payload_file.attach(io: StringIO.new(compressed), filename: "recording-events.json.gz", content_type: "application/gzip")
|
|
122
|
+
else
|
|
123
|
+
self.payload = compressed
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@events = list
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def attached_payload
|
|
132
|
+
return nil unless self.class.attachments_available? && payload_file.attached?
|
|
133
|
+
|
|
134
|
+
payload_file.download
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Both owner columns, whichever one this install owns through.
|
|
138
|
+
def copy_owner_from_recording
|
|
139
|
+
return unless session_recording
|
|
140
|
+
|
|
141
|
+
self.user_id = session_recording.user_id
|
|
142
|
+
self.account_id = session_recording.account_id
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -5,15 +5,15 @@ module ActionAgent
|
|
|
5
5
|
belongs_to :session_recording
|
|
6
6
|
belongs_to :recording_action, optional: true
|
|
7
7
|
|
|
8
|
-
#
|
|
9
|
-
# has no has_one_attached, and the
|
|
10
|
-
# rails, so it may not even have the
|
|
11
|
-
#
|
|
12
|
-
has_one_attached :file if
|
|
8
|
+
# Per ActionAgent.active_storage: a host app created with
|
|
9
|
+
# --skip-active-storage (or --minimal) has no has_one_attached, and the
|
|
10
|
+
# gem depends on railties rather than rails, so it may not even have the
|
|
11
|
+
# gem to require; a host may also switch attachments off.
|
|
12
|
+
has_one_attached :file, **ActionAgent.attachment_options if ActionAgent.active_storage_macros?
|
|
13
13
|
|
|
14
|
-
# Whether file
|
|
14
|
+
# Whether a snapshot's file can be stored in this host app.
|
|
15
15
|
def self.attachments_available?
|
|
16
|
-
|
|
16
|
+
ActionAgent.active_storage_available? && method_defined?(:file)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
SNAPSHOT_TYPES = %w[screenshot dom full_page].freeze
|
|
@@ -6,7 +6,10 @@ module ActionAgent
|
|
|
6
6
|
owned_by :user, :account
|
|
7
7
|
|
|
8
8
|
belongs_to :agent_template, optional: true
|
|
9
|
+
# The project this checkout was booted for, if any.
|
|
10
|
+
belongs_to :project, class_name: "ActionAgent::Project", optional: true
|
|
9
11
|
has_many :code_sessions, dependent: :destroy
|
|
12
|
+
has_many :draft_pull_requests, dependent: :destroy
|
|
10
13
|
|
|
11
14
|
# Session statuses
|
|
12
15
|
enum :status, {
|
|
@@ -28,6 +31,23 @@ module ActionAgent
|
|
|
28
31
|
# mcp_servers lists them: "sandbox:<session_id>".
|
|
29
32
|
RUNTIME_SERVER_PREFIX = "sandbox:"
|
|
30
33
|
|
|
34
|
+
# MCP server keys naming a checkout sandbox's browser:
|
|
35
|
+
# "browser:<session_id>". A run reaches one only through the sandbox it
|
|
36
|
+
# runs against (MCPToolDispatcher's extra_server_keys), never through an
|
|
37
|
+
# agent's saved servers.
|
|
38
|
+
BROWSER_SERVER_PREFIX = "browser:"
|
|
39
|
+
|
|
40
|
+
# A browser runs without a window, or with one a person can watch.
|
|
41
|
+
BROWSER_MODES = %w[headless headed].freeze
|
|
42
|
+
BROWSER_STATUSES = %w[starting running stopped failed].freeze
|
|
43
|
+
# Optional Playwright MCP tool groups a browser may be started with.
|
|
44
|
+
BROWSER_CAPABILITIES = %w[testing vision pdf].freeze
|
|
45
|
+
# A browser stops on its own this long before its session expires, so the
|
|
46
|
+
# last events it records are posted while its recording still takes them:
|
|
47
|
+
# SessionRecording#ingest_token_valid? refuses every post once the session
|
|
48
|
+
# has expired.
|
|
49
|
+
BROWSER_STOP_LEAD = 30.seconds
|
|
50
|
+
|
|
31
51
|
# Free tier limits
|
|
32
52
|
FREE_TIER_LIMITS = {
|
|
33
53
|
max_runs: 10,
|
|
@@ -43,6 +63,26 @@ module ActionAgent
|
|
|
43
63
|
APP_RUNTIME_SESSION_DURATION = 2.hours
|
|
44
64
|
|
|
45
65
|
encrypts :runtime_mcp_token if ActionAgent.encrypt_credentials
|
|
66
|
+
encrypts :browser_token if ActionAgent.encrypt_credentials
|
|
67
|
+
|
|
68
|
+
# What a backend's start_browser needs beyond the session's own columns,
|
|
69
|
+
# set by SandboxBrowser for that one call and never stored. Carries
|
|
70
|
+
# secrets, so it goes to the backend and nowhere else:
|
|
71
|
+
#
|
|
72
|
+
# token the bearer token the browser's MCP endpoint is to expect
|
|
73
|
+
# app_url the sandbox app the browser may open
|
|
74
|
+
# capabilities BROWSER_CAPABILITIES to enable
|
|
75
|
+
# stop_at when the browser stops on its own (browser_stops_at)
|
|
76
|
+
# recording where to post recorded events: { url:, token:,
|
|
77
|
+
# batch_events:, batch_bytes: }, or nil to record nothing
|
|
78
|
+
# live the live view: { session_id:, origins: }, the session
|
|
79
|
+
# its tickets name and the dashboard origins that may
|
|
80
|
+
# open it, or nil for none (BrowserLiveTicket)
|
|
81
|
+
# storage_state a Playwright storage state to start signed in with,
|
|
82
|
+
# or absent
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash, nil]
|
|
85
|
+
attr_accessor :browser_launch
|
|
46
86
|
|
|
47
87
|
# Validations
|
|
48
88
|
validates :session_id, presence: true, uniqueness: true
|
|
@@ -51,6 +91,8 @@ module ActionAgent
|
|
|
51
91
|
validates :repository_ref, length: { maximum: 255 }, format: { without: /\A-|\s|\.\./, message: "is not a valid git ref" },
|
|
52
92
|
allow_blank: true
|
|
53
93
|
validate :repository_available, on: :create, if: :app_runtime?
|
|
94
|
+
validates :browser_mode, inclusion: { in: BROWSER_MODES }, allow_nil: true
|
|
95
|
+
validates :browser_status, inclusion: { in: BROWSER_STATUSES }, allow_nil: true
|
|
54
96
|
|
|
55
97
|
# Callbacks
|
|
56
98
|
before_validation :generate_session_id, on: :create
|
|
@@ -74,6 +116,21 @@ module ActionAgent
|
|
|
74
116
|
key.to_s.start_with?(RUNTIME_SERVER_PREFIX)
|
|
75
117
|
end
|
|
76
118
|
|
|
119
|
+
def self.browser_server_key?(key)
|
|
120
|
+
key.to_s.start_with?(BROWSER_SERVER_PREFIX)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The MCP catalog entry for a sandbox's browser, looked up among +owner+'s
|
|
124
|
+
# sessions only. Nil unless the session and its browser are running.
|
|
125
|
+
#
|
|
126
|
+
# @return [Hash, nil]
|
|
127
|
+
def self.browser_server_entry(key, owner:)
|
|
128
|
+
return nil unless browser_server_key?(key)
|
|
129
|
+
|
|
130
|
+
session = for_owner(owner).find_by(session_id: key.to_s.delete_prefix(BROWSER_SERVER_PREFIX))
|
|
131
|
+
session&.browser_server_entry
|
|
132
|
+
end
|
|
133
|
+
|
|
77
134
|
# The MCP catalog entry for a checkout sandbox's app runtime, looked up
|
|
78
135
|
# among +owner+'s sessions only. Nil unless the session is live and its
|
|
79
136
|
# backend reported an endpoint.
|
|
@@ -122,6 +179,80 @@ module ActionAgent
|
|
|
122
179
|
}
|
|
123
180
|
end
|
|
124
181
|
|
|
182
|
+
def browser_server_key
|
|
183
|
+
"#{BROWSER_SERVER_PREFIX}#{session_id}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def browser_running?
|
|
187
|
+
browser_status == "running" && browser_mcp_url.present? && active?
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# This session's browser as an MCP catalog entry, the shape
|
|
191
|
+
# MCPToolDispatcher reaches servers through, or nil unless it is running.
|
|
192
|
+
# Carries the browser's token, so it is for the dispatcher only.
|
|
193
|
+
#
|
|
194
|
+
# @return [Hash, nil]
|
|
195
|
+
def browser_server_entry
|
|
196
|
+
return nil unless browser_running?
|
|
197
|
+
|
|
198
|
+
{
|
|
199
|
+
key: browser_server_key,
|
|
200
|
+
name: "Browser (#{repository} sandbox)",
|
|
201
|
+
description: "The browser of the sandbox booted from #{repository}",
|
|
202
|
+
transport: "streamable_http",
|
|
203
|
+
url: browser_mcp_url,
|
|
204
|
+
headers: browser_token.present? ? { "Authorization" => "Bearer #{browser_token}" } : {}
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# When the browser stops on its own: BROWSER_STOP_LEAD before the session
|
|
209
|
+
# expires.
|
|
210
|
+
#
|
|
211
|
+
# @return [Time, nil]
|
|
212
|
+
def browser_stops_at
|
|
213
|
+
expires_at && expires_at - BROWSER_STOP_LEAD
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The minutes the browser has run, from its start to +at+ or to
|
|
217
|
+
# browser_stops_at if that is sooner, rounded up to a whole minute and at
|
|
218
|
+
# least 1; 0 when it never started. Capped so that a reaper running late
|
|
219
|
+
# does not count time the browser was no longer running.
|
|
220
|
+
#
|
|
221
|
+
# @return [Integer]
|
|
222
|
+
def browser_minutes(at = Time.current)
|
|
223
|
+
return 0 unless browser_started_at
|
|
224
|
+
|
|
225
|
+
stopped_at = [ at, browser_stops_at ].compact.min
|
|
226
|
+
[ ((stopped_at - browser_started_at) / 60.0).ceil, 1 ].max
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Who this session's browser minutes are metered against: the tenant in a
|
|
230
|
+
# multi-tenant install, the owner ApplicationController#current_owner
|
|
231
|
+
# resolves and the quota checker is asked about, and the session's owner
|
|
232
|
+
# otherwise.
|
|
233
|
+
def metering_owner
|
|
234
|
+
return owner unless ActionAgent.multi_tenant?
|
|
235
|
+
|
|
236
|
+
account_class = ActionAgent.account_class.to_s.safe_constantize
|
|
237
|
+
account_class.find_by(id: account_id) if account_class && account_id
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The browser for an API response: never its token or its MCP endpoint.
|
|
241
|
+
#
|
|
242
|
+
# @return [Hash]
|
|
243
|
+
def browser_summary
|
|
244
|
+
{
|
|
245
|
+
mode: browser_mode,
|
|
246
|
+
status: browser_status,
|
|
247
|
+
started_at: browser_started_at&.iso8601,
|
|
248
|
+
# The key an agent run reaches the browser by; nil unless it runs.
|
|
249
|
+
server_key: browser_running? ? browser_server_key : nil,
|
|
250
|
+
# The live view's WebSocket, opened with a ticket from
|
|
251
|
+
# POST .../browser/tickets; nil unless it runs and has one.
|
|
252
|
+
live_url: browser_running? ? browser_live_url : nil
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
|
|
125
256
|
# This runtime as a token-free MCP server listing (see
|
|
126
257
|
# runtime_server_listings), or nil when it is not live.
|
|
127
258
|
def runtime_server_listing
|
|
@@ -136,32 +267,109 @@ module ActionAgent
|
|
|
136
267
|
|
|
137
268
|
# What a sandbox backend clones for an app_runtime session: repository,
|
|
138
269
|
# ref, clone URL and the credentials to fetch it. Nil for any other
|
|
139
|
-
# sandbox type
|
|
140
|
-
# backend and never into a response.
|
|
270
|
+
# sandbox type, and when the checkout is no longer available. Carries a
|
|
271
|
+
# GitHub token, so it goes to the backend and never into a response.
|
|
272
|
+
#
|
|
273
|
+
# Reading it never calls GitHub. A checkout through the OAuth connection
|
|
274
|
+
# carries the connection's stored token. One through a GitHub App
|
|
275
|
+
# installation carries a token only on the object #mint_checkout_spec!
|
|
276
|
+
# was called on, and +token+ is nil anywhere else.
|
|
141
277
|
#
|
|
142
278
|
# @return [Hash, nil]
|
|
143
279
|
def checkout_spec
|
|
144
280
|
return nil unless app_runtime?
|
|
281
|
+
return @minted_checkout_spec if @minted_checkout_spec
|
|
282
|
+
|
|
283
|
+
if github_installation_id
|
|
284
|
+
installation = checkout_installation
|
|
285
|
+
installation&.repository(repository) && installation.checkout_spec(repository, ref: repository_ref)
|
|
286
|
+
else
|
|
287
|
+
github_connection&.checkout_spec(repository, ref: repository_ref)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Mints the token a GitHub App checkout is fetched with, and keeps the
|
|
292
|
+
# spec on this object, so #checkout_spec answers with it from here on.
|
|
293
|
+
# SandboxProvisionJob calls this once per provision, hands this same
|
|
294
|
+
# object to the backend, and scrubs the same value from what the boot
|
|
295
|
+
# reports. The token is never stored. A checkout through the OAuth
|
|
296
|
+
# connection mints nothing and returns #checkout_spec.
|
|
297
|
+
#
|
|
298
|
+
# @raise [GithubClient::InstallationUnavailable] when GitHub reports the installation removed or suspended
|
|
299
|
+
# @return [Hash, nil]
|
|
300
|
+
def mint_checkout_spec!
|
|
301
|
+
return checkout_spec unless app_runtime? && github_installation_id
|
|
302
|
+
return @minted_checkout_spec if @minted_checkout_spec
|
|
303
|
+
|
|
304
|
+
installation = checkout_installation
|
|
305
|
+
return nil unless installation&.repository(repository)
|
|
145
306
|
|
|
146
|
-
|
|
307
|
+
@minted_checkout_spec = installation.checkout_spec!(repository, ref: repository_ref)
|
|
147
308
|
end
|
|
148
309
|
|
|
149
310
|
# The GitHub connection whose selection this session's checkout comes
|
|
150
|
-
# from.
|
|
311
|
+
# from, when it does not come from a GitHub App installation.
|
|
151
312
|
def github_connection
|
|
152
313
|
owners_record(GithubConnection)
|
|
153
314
|
end
|
|
154
315
|
|
|
316
|
+
# The owner's GitHub App installation this session's checkout comes from,
|
|
317
|
+
# or nil when it comes from the OAuth connection or the installation was
|
|
318
|
+
# unlinked.
|
|
319
|
+
def checkout_installation
|
|
320
|
+
return nil if github_installation_id.nil?
|
|
321
|
+
|
|
322
|
+
owners_record(GithubInstallation.where(id: github_installation_id))
|
|
323
|
+
end
|
|
324
|
+
|
|
155
325
|
# Environment the backend passes into an app_runtime checkout, so the
|
|
156
326
|
# booted app can run Claude Code sessions against it with the owner's
|
|
157
327
|
# connected credential (Settings -> Integrations). Empty when none is
|
|
158
328
|
# connected. Secret — for the backend, never a response.
|
|
159
329
|
#
|
|
160
330
|
# @return [Hash{String => String}]
|
|
161
|
-
def runtime_environment
|
|
331
|
+
def runtime_environment(runner: "claude_code")
|
|
162
332
|
return {} unless app_runtime?
|
|
333
|
+
return {} unless ProviderKey::CONNECTION_PROVIDERS.include?(runner)
|
|
334
|
+
|
|
335
|
+
owners_record(ProviderKey.where(provider: runner))&.runtime_environment || {}
|
|
336
|
+
end
|
|
163
337
|
|
|
164
|
-
|
|
338
|
+
# The values of the secrets of the project this checkout was booted for,
|
|
339
|
+
# with their encodings (Project#scrub_values), for scrubbing whatever the
|
|
340
|
+
# sandbox outputs. Empty for a sandbox no project booted, on an install
|
|
341
|
+
# that has not run the projects migration, and when the secrets cannot be
|
|
342
|
+
# read.
|
|
343
|
+
#
|
|
344
|
+
# @return [Array<String>]
|
|
345
|
+
def project_scrub_values
|
|
346
|
+
return [] unless app_runtime? && has_attribute?(:project_id) && project_id
|
|
347
|
+
|
|
348
|
+
project&.scrub_values || []
|
|
349
|
+
rescue StandardError => e
|
|
350
|
+
Rails.logger.warn("[ActionAgent] sandbox #{session_id}: could not read its project's secrets: #{e.class}")
|
|
351
|
+
[]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# The values this session's sandbox holds that must never leave it: the
|
|
355
|
+
# checkout token where one is stored, the Claude Code and Codex
|
|
356
|
+
# credentials it hands its checkout, its runtime's MCP token, and the
|
|
357
|
+
# secrets of the project it was booted for. Reads without minting.
|
|
358
|
+
#
|
|
359
|
+
# @return [Array<String>]
|
|
360
|
+
def secret_values
|
|
361
|
+
spec = begin
|
|
362
|
+
checkout_spec
|
|
363
|
+
rescue StandardError
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
366
|
+
[
|
|
367
|
+
spec&.dig(:token),
|
|
368
|
+
*runtime_environment.values,
|
|
369
|
+
*runtime_environment(runner: "codex").values,
|
|
370
|
+
runtime_mcp_token,
|
|
371
|
+
*project_scrub_values
|
|
372
|
+
].compact.map(&:to_s).uniq
|
|
165
373
|
end
|
|
166
374
|
|
|
167
375
|
# Check if session is still valid
|
|
@@ -203,7 +411,11 @@ module ActionAgent
|
|
|
203
411
|
end
|
|
204
412
|
|
|
205
413
|
# Provision the Cloud Run sandbox
|
|
206
|
-
|
|
414
|
+
#
|
|
415
|
+
# @param boot [Hash, nil] how a checkout boots, as
|
|
416
|
+
# SandboxBootSpec.request_options returns it; nil for the default
|
|
417
|
+
# ("auto"). Ignored for other sandbox types.
|
|
418
|
+
def provision!(boot: nil)
|
|
207
419
|
return if provisioning? || ready?
|
|
208
420
|
|
|
209
421
|
update!(status: :provisioning)
|
|
@@ -215,11 +427,32 @@ module ActionAgent
|
|
|
215
427
|
# synchronously for immediate feedback.
|
|
216
428
|
if !app_runtime? && (Rails.env.development? || Rails.env.test?)
|
|
217
429
|
SandboxProvisionJob.perform_now(id)
|
|
430
|
+
elsif app_runtime? && boot
|
|
431
|
+
SandboxProvisionJob.perform_later(id, "boot" => boot)
|
|
218
432
|
else
|
|
219
433
|
SandboxProvisionJob.perform_later(id)
|
|
220
434
|
end
|
|
221
435
|
end
|
|
222
436
|
|
|
437
|
+
# Continues a checkout whose boot failed and was kept (see
|
|
438
|
+
# SandboxOrchestrator#resume_boot), from the step named +from+, or from
|
|
439
|
+
# the one that failed. False, enqueuing nothing, unless the session is a
|
|
440
|
+
# failed checkout that has not expired.
|
|
441
|
+
def resume_boot!(from: nil)
|
|
442
|
+
resumed = with_lock do
|
|
443
|
+
next false unless app_runtime? && failed? && !past_expiry?
|
|
444
|
+
|
|
445
|
+
update!(status: :provisioning, error_message: nil)
|
|
446
|
+
true
|
|
447
|
+
end
|
|
448
|
+
SandboxProvisionJob.perform_later(id, "resume" => { "from" => from.presence&.to_s }) if resumed
|
|
449
|
+
resumed
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def past_expiry?
|
|
453
|
+
expires_at.present? && expires_at <= Time.current
|
|
454
|
+
end
|
|
455
|
+
|
|
223
456
|
# Mark as ready with Cloud Run URL. A checkout sandbox's backend also
|
|
224
457
|
# reports the app runtime's MCP endpoint and the token it expects.
|
|
225
458
|
def mark_ready!(cloud_run_url:, cloud_run_job_id: nil, runtime_mcp_url: nil, runtime_mcp_token: nil)
|
|
@@ -239,13 +472,28 @@ module ActionAgent
|
|
|
239
472
|
# marked it ready since. Acting on the stale copy would neither clear the
|
|
240
473
|
# endpoint it recorded (nil -> nil writes nothing) nor see the handle to
|
|
241
474
|
# terminate, leaving the booted sandbox running.
|
|
475
|
+
#
|
|
476
|
+
# Its browser is stopped first while the session is still live, so the
|
|
477
|
+
# browser's last recorded events are accepted (SandboxBrowser.stop_before_expiry).
|
|
478
|
+
# When that cannot be done, it stops being reachable the same way as the
|
|
479
|
+
# runtime, its minutes are counted here (SandboxBrowser.finish!), and the
|
|
480
|
+
# cleanup job stops it.
|
|
242
481
|
def expire!
|
|
482
|
+
SandboxBrowser.stop_before_expiry(self)
|
|
243
483
|
with_lock { update!(status: :expired, runtime_mcp_url: nil, runtime_mcp_token: nil) }
|
|
484
|
+
SandboxBrowser.finish!(self)
|
|
244
485
|
# A checkout with no handle may still have a boot behind it (its job
|
|
245
486
|
# died mid-boot, say); the cleanup job asks the backend for it.
|
|
246
487
|
SandboxCleanupJob.perform_later(id) if cloud_run_job_id.present? || app_runtime?
|
|
247
488
|
end
|
|
248
489
|
|
|
490
|
+
# The browser token never leaves the model, whatever serializes it.
|
|
491
|
+
def serializable_hash(options = nil)
|
|
492
|
+
options = (options || {}).dup
|
|
493
|
+
options[:except] = Array(options[:except]).map(&:to_s) | %w[browser_token]
|
|
494
|
+
super(options)
|
|
495
|
+
end
|
|
496
|
+
|
|
249
497
|
# Summary for API responses
|
|
250
498
|
def summary
|
|
251
499
|
{
|
|
@@ -262,15 +510,30 @@ module ActionAgent
|
|
|
262
510
|
mcp_servers: Array(mcp_servers),
|
|
263
511
|
repository: repository,
|
|
264
512
|
repository_ref: repository_ref,
|
|
513
|
+
# How a checkout reaches GitHub: "app" (a GitHub App installation) or
|
|
514
|
+
# "oauth" (the OAuth connection).
|
|
515
|
+
checkout_source: app_runtime? ? (github_installation_id ? "app" : "oauth") : nil,
|
|
265
516
|
# The key an agent adds to its mcp_servers to use this runtime's
|
|
266
517
|
# tools; nil until the backend has reported the endpoint.
|
|
267
518
|
runtime_server_key: runtime_mcp_url.present? ? runtime_server_key : nil,
|
|
268
519
|
# Why provisioning failed. Scrubbed of the session's secrets when
|
|
269
520
|
# SandboxProvisionJob stored it.
|
|
270
|
-
error_message: error_summary
|
|
521
|
+
error_message: error_summary,
|
|
522
|
+
browser: browser_summary,
|
|
523
|
+
# The project this checkout was booted for, so a listing can link to
|
|
524
|
+
# its page; nil for a sandbox started on its own.
|
|
525
|
+
project: project_link
|
|
271
526
|
}
|
|
272
527
|
end
|
|
273
528
|
|
|
529
|
+
# { id:, name: } of the project this checkout was booted for, or nil. An
|
|
530
|
+
# install that has not run the projects migration has no column for it.
|
|
531
|
+
def project_link
|
|
532
|
+
return nil unless has_attribute?(:project_id) && project_id
|
|
533
|
+
|
|
534
|
+
project && { id: project.id, name: project.name }
|
|
535
|
+
end
|
|
536
|
+
|
|
274
537
|
# A failed boot's message is a one-line reason followed by the tail of
|
|
275
538
|
# the failing step's log, and the log's last lines usually hold the
|
|
276
539
|
# actual error — so a long message keeps its head and its end.
|
|
@@ -297,26 +560,47 @@ module ActionAgent
|
|
|
297
560
|
# The owner's record in +scope+, found through that model's own owner
|
|
298
561
|
# column. GitHub connections and provider keys are account-owned before
|
|
299
562
|
# user-owned, the opposite of a session, so the session's #owner is not
|
|
300
|
-
# necessarily theirs.
|
|
563
|
+
# necessarily theirs. Only the rows the model's .owned_rows admits are
|
|
564
|
+
# read, so a member's personal provider key never reaches a sandbox,
|
|
565
|
+
# which every member of the account shares.
|
|
301
566
|
def owners_record(scope)
|
|
567
|
+
owners_records(scope).first
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Every record of the owner's in +scope+, found the same way.
|
|
571
|
+
def owners_records(scope)
|
|
302
572
|
scope = scope.all
|
|
573
|
+
scope = scope.merge(scope.klass.owned_rows)
|
|
303
574
|
case scope.klass.owner_association
|
|
304
|
-
when :account then account_id
|
|
305
|
-
when :user then user_id
|
|
306
|
-
else scope
|
|
575
|
+
when :account then account_id ? scope.where(account_id: account_id) : scope.none
|
|
576
|
+
when :user then user_id ? scope.where(user_id: user_id) : scope.none
|
|
577
|
+
else scope
|
|
307
578
|
end
|
|
308
579
|
end
|
|
309
580
|
|
|
581
|
+
# A repository selected on one of the owner's GitHub App installations is
|
|
582
|
+
# checked out through that installation, ahead of the OAuth connection,
|
|
583
|
+
# so the checkout gets a short-lived token limited to that repository.
|
|
584
|
+
# Installations count only while a GitHub App is configured, since
|
|
585
|
+
# nothing else can mint their tokens.
|
|
310
586
|
def repository_available
|
|
311
587
|
return if repository.blank?
|
|
312
588
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
589
|
+
installations = ActionAgent.github_app_configured? ? owners_records(GithubInstallation).order(:id).to_a : []
|
|
590
|
+
installation = installations.find { |candidate| candidate.usable? && candidate.repository(repository) }
|
|
591
|
+
connection = github_connection unless installation
|
|
592
|
+
repo = installation&.repository(repository) || connection&.repository(repository)
|
|
593
|
+
|
|
594
|
+
if repo
|
|
595
|
+
self.github_installation_id = installation&.id
|
|
317
596
|
# Canonical spelling, and the default branch unless a ref was asked for.
|
|
318
597
|
self.repository = repo["full_name"]
|
|
319
598
|
self.repository_ref = repository_ref.presence || repo["default_branch"]
|
|
599
|
+
elsif installations.any? { |candidate| candidate.repository(repository) }
|
|
600
|
+
errors.add(:repository, "is selected on a GitHub App installation that was removed or suspended: " \
|
|
601
|
+
"reinstall the GitHub App, or Check again once it is unsuspended, in Settings -> Integrations")
|
|
602
|
+
elsif connection.nil? && installations.empty?
|
|
603
|
+
errors.add(:repository, "needs a GitHub connection (Settings -> Integrations)")
|
|
320
604
|
else
|
|
321
605
|
errors.add(:repository, "is not one of the repositories selected in Settings -> Integrations")
|
|
322
606
|
end
|