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.
Files changed (168) 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 +71 -12
  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 +58 -7
  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 +148 -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 +627 -20
  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 +39 -10
  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 +72 -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 +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. 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
- # Guarded: a host app created with --skip-active-storage (or --minimal)
9
- # has no has_one_attached, and the gem depends on railties rather than
10
- # rails, so it may not even have the gem to require. Without this the
11
- # engine dies during eager load at boot.
12
- has_one_attached :file if defined?(ActiveStorage)
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 attachment is available in this host app.
14
+ # Whether a snapshot's file can be stored in this host app.
15
15
  def self.attachments_available?
16
- defined?(ActiveStorage) && method_defined?(:file)
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. Carries the owner's GitHub token, so it goes to the
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
- github_connection&.checkout_spec(repository, ref: repository_ref)
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
- owners_record(ProviderKey.where(provider: "claude_code"))&.runtime_environment || {}
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
- def provision!
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 && scope.find_by(account_id: account_id)
305
- when :user then user_id && scope.find_by(user_id: user_id)
306
- else scope.first
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
- connection = github_connection
314
- if connection.nil?
315
- errors.add(:repository, "needs a GitHub connection (Settings -> Integrations)")
316
- elsif (repo = connection.repository(repository))
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