actionagent 1.8.1 → 1.9.1

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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
  18. data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
  19. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  20. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  21. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  22. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  23. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  24. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  25. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  26. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  27. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  28. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  29. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  30. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  31. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  32. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  33. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  34. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  35. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  36. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  37. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  38. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  39. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  40. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  41. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  42. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  43. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  44. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  45. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  46. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  47. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  48. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  49. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  50. data/app/jobs/action_agent/code_session_job.rb +26 -2
  51. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  52. data/app/jobs/action_agent/exploration_job.rb +80 -0
  53. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  54. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  55. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  56. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  57. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  58. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  59. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  60. data/app/models/action_agent/agent.rb +39 -26
  61. data/app/models/action_agent/agent_run.rb +134 -23
  62. data/app/models/action_agent/agent_template.rb +35 -1
  63. data/app/models/action_agent/api_key.rb +14 -0
  64. data/app/models/action_agent/catalog_scenario.rb +75 -0
  65. data/app/models/action_agent/code_session.rb +16 -4
  66. data/app/models/action_agent/draft_pull_request.rb +131 -0
  67. data/app/models/action_agent/evaluation.rb +84 -0
  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/interaction_feedback.rb +24 -0
  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 +200 -3
  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 +297 -14
  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/input_request_serializer.rb +47 -0
  85. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  86. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  87. data/app/services/action_agent/agent_execution_service.rb +450 -72
  88. data/app/services/action_agent/agent_sync.rb +5 -2
  89. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  90. data/app/services/action_agent/agent_toolbox.rb +215 -9
  91. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  92. data/app/services/action_agent/browser_sidecar.rb +213 -0
  93. data/app/services/action_agent/browser_sign_in.rb +163 -0
  94. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  95. data/app/services/action_agent/claude_code_auth.rb +70 -4
  96. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  97. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  98. data/app/services/action_agent/evaluation_fix.rb +101 -0
  99. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  100. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  101. data/app/services/action_agent/evaluation_standing.rb +15 -8
  102. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  103. data/app/services/action_agent/github_client.rb +405 -64
  104. data/app/services/action_agent/live_updates.rb +31 -0
  105. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  106. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  107. data/app/services/action_agent/mcp_client.rb +6 -2
  108. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  109. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  110. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  111. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  112. data/app/services/action_agent/project_boot_fix.rb +309 -0
  113. data/app/services/action_agent/project_capabilities.rb +200 -0
  114. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  115. data/app/services/action_agent/project_preflight.rb +178 -0
  116. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  117. data/app/services/action_agent/project_setup.rb +429 -0
  118. data/app/services/action_agent/provider_credentials.rb +102 -0
  119. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  120. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  121. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  122. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  123. data/app/services/action_agent/sandbox_browser.rb +299 -0
  124. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  125. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  126. data/app/services/action_agent/sandbox_mail.rb +103 -0
  127. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  128. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  129. data/app/services/action_agent/sandbox_patch.rb +253 -0
  130. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  131. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  132. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  133. data/app/services/action_agent/secret_scrubber.rb +33 -4
  134. data/app/services/action_agent/session_index.rb +270 -0
  135. data/app/services/action_agent/session_recording_service.rb +11 -0
  136. data/app/services/action_agent/session_timeline.rb +505 -0
  137. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  138. data/app/views/action_agent/session_player/show.html.erb +19 -0
  139. data/config/routes.rb +155 -1
  140. data/lib/action_agent/claude_login_process.rb +107 -0
  141. data/lib/action_agent/engine.rb +55 -5
  142. data/lib/action_agent/secret_requests.rb +80 -0
  143. data/lib/action_agent/version.rb +1 -1
  144. data/lib/action_agent.rb +507 -22
  145. data/lib/generators/action_agent/install_generator.rb +37 -0
  146. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  147. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  148. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  149. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  150. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  151. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  152. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  153. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  154. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  155. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  156. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  157. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  158. data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
  159. data/lib/tasks/action_agent.rake +33 -0
  160. data/lib/tasks/browser.rake +22 -0
  161. data/lib/tasks/sandbox.rake +1 -1
  162. data/lib/tasks/scenario_catalogs.rake +71 -0
  163. metadata +100 -2
@@ -2,21 +2,50 @@
2
2
 
3
3
  module ActionAgent
4
4
  module Api
5
- # Per-account LLM provider credentials (Settings -> Provider API Keys).
6
- # API keys are write-only: responses carry a masked hint, never the key.
7
- # Ollama's credential is a host URL and is echoed back in full; its
8
- # optional API key (remote servers) is masked like the others. Claude
9
- # Code's connection API key is stored here too, write-only like a key.
5
+ # LLM provider credentials (Settings -> Provider API Keys, and the
6
+ # Organization view's Provider keys). API keys are write-only: responses
7
+ # carry a masked hint, never the key. Ollama's credential is a host URL
8
+ # and is echoed back in full; its optional API key (remote servers) is
9
+ # masked like the others. Claude Code's connection API key is stored here
10
+ # too, write-only like a key.
11
+ #
12
+ # Every action takes a `scope` param:
13
+ #
14
+ # organization (default) the owner's shared keys. Writes and tests
15
+ # ask permission_checker for :manage_credentials.
16
+ # personal the signed-in user's own keys for the owner. Only
17
+ # that user's rows are read or changed, and every write
18
+ # and test is refused with 422 unless
19
+ # ProviderKey.personal_keys_enabled?. Saving a key needs
20
+ # no permission. Saving or testing a host (Ollama) asks
21
+ # for :manage_credentials, with the personal key as the
22
+ # subject, because the host decides where the server
23
+ # sends requests. Removing one's own key never asks.
10
24
  class ProviderKeysController < BaseController
11
- before_action :require_owner!
25
+ SCOPES = %w[organization personal].freeze
12
26
 
13
- # GET /api/provider_keys — one row per supported provider, configured or not.
27
+ before_action :require_owner!
28
+ before_action :require_known_scope!
29
+ before_action :require_personal_keys!, only: %i[create test destroy], if: :personal_scope?
30
+
31
+ # GET /api/provider_keys — one row per supported provider in the
32
+ # requested scope, configured or not.
33
+ #
34
+ # Beside the rows:
35
+ # scope the scope listed
36
+ # personal_keys_enabled whether members may keep personal keys
37
+ # can_manage_organization_keys whether the caller may change the
38
+ # organization's keys
14
39
  def index
15
- configured = owned(ProviderKey).index_by(&:provider)
40
+ configured = scoped_keys.to_a.index_by(&:provider)
41
+ setters = setters_for(configured.values)
16
42
 
17
43
  render json: {
44
+ scope: requested_scope,
45
+ personal_keys_enabled: ProviderKey.personal_keys_enabled?,
46
+ can_manage_organization_keys: can_manage_organization_keys?,
18
47
  provider_keys: ProviderKey::PROVIDERS.map do |provider|
19
- serialize(provider, configured[provider])
48
+ serialize(provider, configured[provider], setters)
20
49
  end
21
50
  }
22
51
  end
@@ -26,46 +55,160 @@ module ActionAgent
26
55
  # keeps the stored one, sending an empty string clears it.
27
56
  def create
28
57
  provider = params.require(:provider)
29
- credential = params.require(:credential)
30
-
31
- record = owned(ProviderKey).find_or_initialize_by(provider: provider)
32
- attributes = { credential: credential }
33
- attributes[:api_key] = params[:api_key].presence if params.key?(:api_key) && record.host_based?
34
- record.update!(**attributes)
58
+ attempts = 0
59
+ begin
60
+ record = scoped_keys.find_or_initialize_by(provider: provider)
61
+ return unless authorize_write!(record)
62
+
63
+ attributes = { credential: params.require(:credential), set_by_id: signed_in_user&.id }
64
+ attributes[:api_key] = params[:api_key].presence if params.key?(:api_key) && record.host_based?
65
+ record.update!(**attributes)
66
+ rescue ActiveRecord::RecordNotUnique
67
+ # Another request saved this scope's key for the provider between
68
+ # the lookup and the insert. Looking again finds that row, and this
69
+ # request's credential replaces it, as a later save would.
70
+ retry if (attempts += 1) == 1
71
+ raise
72
+ end
35
73
 
36
- render json: { provider_key: serialize(provider, record) }, status: :created
74
+ render json: { provider_key: serialize(provider, record, setters_for([ record ])) }, status: :created
37
75
  end
38
76
 
39
77
  # POST /api/provider_keys/test — checks that a host-based provider is
40
78
  # reachable and lists the models it serves. Tests the submitted host
41
79
  # (and api_key) when given, so a URL can be checked before saving;
42
- # otherwise the stored credential, else the host app's default. Never
43
- # persists anything.
80
+ # otherwise the scope's stored host, else the host app's default.
81
+ # Never persists anything.
82
+ #
83
+ # The stored api_key is sent only to the stored host: a submitted host
84
+ # that normalizes to anything else is probed with the submitted
85
+ # api_key, or with none.
44
86
  def test
45
87
  provider = params.require(:provider)
46
88
  unless ProviderKey::HOST_PROVIDERS.include?(provider)
47
89
  return render json: { error: "#{provider} is not a host-based provider" }, status: :unprocessable_entity
48
90
  end
49
91
 
50
- stored = owned(ProviderKey).find_by(provider: provider)
51
- host = params[:credential].presence || stored&.credential || platform_host(provider)
52
- api_key = params.key?(:api_key) ? params[:api_key].presence : stored&.api_key
92
+ stored = scoped_keys.find_by(provider: provider)
93
+ return unless authorize_write!(stored || scoped_keys.new(provider: provider))
53
94
 
95
+ host = params[:credential].presence || stored&.credential || platform_host(provider)
54
96
  if host.blank?
55
97
  return render json: { ok: false, host: nil, models: [], latency_ms: nil, error: "No host configured" }
56
98
  end
57
99
 
58
- render json: OllamaHostProbe.call(host: host, api_key: api_key).to_h
100
+ render json: OllamaHostProbe.call(host: host, api_key: probe_api_key(stored, host)).to_h
59
101
  end
60
102
 
61
103
  # DELETE /api/provider_keys/:provider
62
104
  def destroy
63
- owned(ProviderKey).find_by!(provider: params[:provider]).destroy!
105
+ record = scoped_keys.find_by!(provider: params[:provider])
106
+ return unless personal_scope? || authorize_write!(record)
107
+
108
+ record.destroy!
64
109
  head :no_content
65
110
  end
66
111
 
67
112
  private
68
113
 
114
+ def requested_scope
115
+ @requested_scope ||= params[:scope].presence || "organization"
116
+ end
117
+
118
+ def personal_scope?
119
+ requested_scope == "personal"
120
+ end
121
+
122
+ def require_known_scope!
123
+ return if SCOPES.include?(requested_scope)
124
+
125
+ render json: { error: "scope must be one of #{SCOPES.join(', ')}" }, status: :unprocessable_entity
126
+ end
127
+
128
+ def require_personal_keys!
129
+ return if personal_keys_writable?
130
+
131
+ render json: {
132
+ error: "Personal provider keys are not enabled on this dashboard",
133
+ code: "personal_keys_disabled"
134
+ }, status: :unprocessable_entity
135
+ end
136
+
137
+ # Whether the signed-in user can hold personal keys here. The owner has
138
+ # to resolve to an account, or a personal write would have no account
139
+ # to belong to.
140
+ def personal_keys_writable?
141
+ ProviderKey.personal_keys_enabled? && signed_in_user.present? && ProviderKey.resolve_owner(current_owner).present?
142
+ end
143
+
144
+ # The keys of the requested scope: the organization's, or the signed-in
145
+ # user's own.
146
+ def scoped_keys
147
+ personal_scope? ? ProviderKey.personal_for(current_owner, signed_in_user) : owned(ProviderKey)
148
+ end
149
+
150
+ # The signed-in user, when it is an instance of the configured user
151
+ # class: the only caller that can hold personal keys or be recorded as
152
+ # a key's setter.
153
+ def signed_in_user
154
+ user_class = ProviderKey.owner_class_for(:user)
155
+ current_user if user_class && current_user.is_a?(user_class)
156
+ end
157
+
158
+ def authorize_write!(record)
159
+ return true unless needs_permission?(record)
160
+
161
+ authorize_action!(:manage_credentials, record)
162
+ end
163
+
164
+ # Whether saving or testing +record+ asks for :manage_credentials.
165
+ def needs_permission?(record)
166
+ !personal_scope? || record.host_based?
167
+ end
168
+
169
+ # Whether the caller may save and test +provider+'s credential in the
170
+ # requested scope.
171
+ def editable?(provider, record)
172
+ return can_manage_organization_keys? unless personal_scope?
173
+ return false if !personal_keys_writable? || ProviderKey::CONNECTION_PROVIDERS.include?(provider)
174
+
175
+ record ||= scoped_keys.new(provider: provider)
176
+ !needs_permission?(record) || ActionAgent.permitted?(current_user, :manage_credentials, record)
177
+ end
178
+
179
+ def can_manage_organization_keys?
180
+ return @can_manage_organization_keys if defined?(@can_manage_organization_keys)
181
+
182
+ @can_manage_organization_keys = ActionAgent.permitted?(current_user, :manage_credentials, owned(ProviderKey).new)
183
+ end
184
+
185
+ def probe_api_key(stored, host)
186
+ return params[:api_key].presence if params.key?(:api_key)
187
+ return nil if stored.nil?
188
+
189
+ stored.api_key if ProviderKey.normalize_host(host) == ProviderKey.normalize_host(stored.credential)
190
+ end
191
+
192
+ # The users who last saved +records+, by id, read in one query.
193
+ def setters_for(records)
194
+ ids = records.filter_map(&:set_by_id).uniq
195
+ user_class = ProviderKey.owner_class_for(:user)
196
+ return {} if ids.empty? || user_class.nil?
197
+
198
+ user_class.where(id: ids).index_by(&:id)
199
+ end
200
+
201
+ # The source the caller's own runs use for +provider+, or nil for a
202
+ # connection credential, which no run generates on, and when the
203
+ # caller's credentials cannot be resolved.
204
+ def effective_source(provider)
205
+ return nil if ProviderKey::CONNECTION_PROVIDERS.include?(provider)
206
+
207
+ ProviderCredentials.resolve(owner: current_owner, actor: current_user, provider: provider).source
208
+ rescue ProviderCredentials::Unresolved
209
+ nil
210
+ end
211
+
69
212
  # The host app's default (config/active_agent.yml, e.g. OLLAMA_HOST)
70
213
  # that applies when the owner has not configured their own.
71
214
  def platform_host(provider)
@@ -77,11 +220,13 @@ module ActionAgent
77
220
  nil
78
221
  end
79
222
 
80
- def serialize(provider, record)
223
+ def serialize(provider, record, setters)
81
224
  host_based = ProviderKey::HOST_PROVIDERS.include?(provider)
225
+ setter = record&.set_by_id && setters[record.set_by_id]
82
226
 
83
227
  {
84
228
  provider: provider,
229
+ scope: requested_scope,
85
230
  host_based: host_based,
86
231
  # "key", "host", or "connection" (Settings -> Integrations rather
87
232
  # than Provider API Keys).
@@ -95,9 +240,16 @@ module ActionAgent
95
240
  # from an earlier version: never used, and the UI asks for an API
96
241
  # key in its place.
97
242
  needs_replacing: record.present? && record.needs_replacing?,
243
+ editable: editable?(provider, record),
244
+ effective_source: effective_source(provider),
245
+ set_by: setter && { id: setter.id, name: display_name(setter) },
98
246
  updated_at: record&.updated_at&.iso8601
99
247
  }
100
248
  end
249
+
250
+ def display_name(user)
251
+ user.try(:display_name) || user.try(:name) || user.try(:email_address) || user.try(:email)
252
+ end
101
253
  end
102
254
  end
103
255
  end
@@ -7,11 +7,12 @@ module ActionAgent
7
7
  #
8
8
  # Hosted providers get a curated list of current models (kept here, server
9
9
  # side, so the UI can't drift stale). Ollama is queried live from the
10
- # account's configured host (Settings -> Provider API Keys, falling back to
11
- # the platform config) so locally pulled models appear; OpenRouter is
12
- # queried from its public catalog, and Anthropic from its Models API with
13
- # the account's key. Live lookups fall back to the curated list on any
14
- # failure.
10
+ # caller's effective host (see ProviderCredentials: their personal key,
11
+ # the host resolver's, the account's, falling back to the platform
12
+ # config) so locally pulled models appear; OpenRouter is queried from its
13
+ # public catalog, and Anthropic from its Models API with the caller's
14
+ # effective key. Live lookups fall back to the curated list on any
15
+ # failure, and when the caller's credentials cannot be resolved.
15
16
  #
16
17
  # When the host app loads RubyLLM, the chat models its registry lists for
17
18
  # the provider that take and return text follow: RubyLLM's bundled
@@ -55,16 +56,36 @@ module ActionAgent
55
56
 
56
57
  private
57
58
 
58
- # The owner's configured Ollama endpoint, else the host app's default
59
+ # The caller's effective Ollama endpoint, else the host app's default
59
60
  # from config/active_agent.yml.
60
61
  def ollama_host
61
- key = owner_provider_key("ollama")
62
- host = key&.credential.presence || ActiveAgent.configuration[:ollama]&.dig(:host)
63
- host.presence
62
+ resolution = credentials("ollama")
63
+ return nil if resolution.nil?
64
+
65
+ options = resolution.options
66
+ host = options[:host].presence || options[:base_url].presence || options[:uri_base].presence
67
+ (host || ActiveAgent.configuration[:ollama]&.dig(:host)).presence
68
+ end
69
+
70
+ # The caller's effective credentials for +provider+, or nil when they
71
+ # cannot be resolved.
72
+ def credentials(provider)
73
+ @credentials ||= {}
74
+ return @credentials[provider] if @credentials.key?(provider)
75
+
76
+ @credentials[provider] = begin
77
+ resolution = ProviderCredentials.resolve(owner: current_owner, actor: current_user, provider: provider)
78
+ resolution.options = resolution.options.symbolize_keys
79
+ resolution
80
+ rescue ProviderCredentials::Unresolved => e
81
+ Rails.logger.warn("[ProviderModels] #{e.message}")
82
+ nil
83
+ end
64
84
  end
65
85
 
66
- def owner_provider_key(provider)
67
- owned(ProviderKey).find_by(provider: provider)
86
+ def credential_key(provider)
87
+ options = credentials(provider)&.options || {}
88
+ options[:access_token].presence || options[:api_key].presence
68
89
  end
69
90
 
70
91
  # Same probe as Settings -> "Test connection", so the builder's dropdown
@@ -74,7 +95,7 @@ module ActionAgent
74
95
  host = ollama_host
75
96
  return nil unless host
76
97
 
77
- result = OllamaHostProbe.call(host: host, api_key: owner_provider_key("ollama")&.api_key)
98
+ result = OllamaHostProbe.call(host: host, api_key: credential_key("ollama"))
78
99
  unless result.ok
79
100
  Rails.logger.warn("[ProviderModels] ollama lookup failed: #{result.error}")
80
101
  return nil
@@ -83,10 +104,11 @@ module ActionAgent
83
104
  [ result.models, "live" ] if result.models.any?
84
105
  end
85
106
 
86
- # Queries the Anthropic Models API with the account's key (newest first,
87
- # as returned by the API) so new model releases appear without a deploy.
107
+ # Queries the Anthropic Models API with the caller's effective key
108
+ # (newest first, as returned by the API) so new model releases appear
109
+ # without a deploy. The platform's own key is never used here.
88
110
  def live_anthropic_models
89
- key = owner_provider_key("anthropic")&.credential
111
+ key = credential_key("anthropic")
90
112
  return nil if key.blank?
91
113
 
92
114
  data = Rails.cache.fetch("provider_models:anthropic:#{Digest::SHA256.hexdigest(key)}", expires_in: 1.hour) do
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "zlib"
5
+
6
+ module ActionAgent
7
+ module Api
8
+ # POST <mount>/api/session_recordings/:id/events with a recording's
9
+ # ingest token as the bearer token: a recorder outside the dashboard,
10
+ # such as a sandbox's browser, posting a batch of events
11
+ # (RecordingEventIngest).
12
+ #
13
+ # The token authenticates on its own, so this is not a dashboard
14
+ # controller and sees no session, no forgery protection and none of the
15
+ # host's controller concerns. It is accepted only for the recording it
16
+ # was issued for, while SessionRecording#ingest_token_valid? holds, and
17
+ # only for RecordingEvent::CLIENT_KINDS. It reads nothing back. A request
18
+ # without a bearer token is routed to SessionRecordingsController#create_events.
19
+ #
20
+ # A batch may be sent gzipped, with Content-Encoding: gzip, as a sandbox's
21
+ # browser sends it. It is inflated no further than the batch size limit.
22
+ class RecordingEventIngestController < ActionController::API
23
+ wrap_parameters false
24
+
25
+ def create
26
+ recording = SessionRecording.find_by(id: request.path_parameters[:id])
27
+ unless recording&.ingest_token_valid?(bearer_token)
28
+ return render(json: { error: "Invalid or expired recording token" }, status: :unauthorized)
29
+ end
30
+
31
+ body = batch_body
32
+ return if performed?
33
+
34
+ result = RecordingEventIngest.call(recording, body)
35
+ render json: result.body, status: result.status
36
+ end
37
+
38
+ private
39
+
40
+ # The request body, inflated when it was gzipped. Renders the refusal
41
+ # instead when the encoding is unsupported, the gzip is damaged, or it
42
+ # inflates past the batch size limit.
43
+ def batch_body
44
+ encoding = request.headers["Content-Encoding"].to_s.strip.downcase
45
+ return request.raw_post if encoding.empty? || encoding == "identity"
46
+ unless encoding == "gzip"
47
+ return render(json: { error: "Content-Encoding must be gzip or identity" }, status: :unsupported_media_type)
48
+ end
49
+
50
+ limit = RecordingEvent.limits[:batch_bytes]
51
+ inflated = Zlib::GzipReader.new(StringIO.new(request.raw_post)).read(limit + 1).to_s
52
+ if inflated.bytesize > limit
53
+ return render(json: { error: "The batch is over the limit of #{limit} bytes", code: "recording_limit" },
54
+ status: RecordingEventIngest::PAYLOAD_TOO_LARGE)
55
+ end
56
+
57
+ inflated.force_encoding(Encoding::UTF_8)
58
+ rescue Zlib::Error
59
+ render json: { error: "The body is not valid gzip" }, status: :bad_request
60
+ end
61
+
62
+ def bearer_token
63
+ request.authorization.to_s[/\ABearer\s+(.+)\z/i, 1]
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Tickets into a checkout sandbox browser's live view (BrowserLiveTicket):
6
+ #
7
+ # POST /api/sandboxes/:sandbox_id/browser/tickets
8
+ # mode: "view" (the default) to watch, or "control" to also take over
9
+ #
10
+ # Answers 201 with { ticket:, mode:, expires_at:, url: }, where url is
11
+ # the live view's WebSocket and the ticket is its first message.
12
+ #
13
+ # A view ticket needs only access to the sandbox, found the way
14
+ # SandboxBrowsersController finds it. A control ticket also needs
15
+ # ActionAgent.permitted?(user, :take_over_browser, sandbox), and execution
16
+ # to be enabled. A browser that is not running, or that has no live view,
17
+ # answers 409.
18
+ class SandboxBrowserTicketsController < BaseController
19
+ before_action :set_sandbox
20
+
21
+ def create
22
+ mode = params[:mode].presence || "view"
23
+ unless BrowserLiveTicket::MODES.include?(mode)
24
+ return render json: { error: "mode must be one of #{BrowserLiveTicket::MODES.join(', ')}" }, status: :unprocessable_entity
25
+ end
26
+ if mode == "control"
27
+ require_execution_enabled!
28
+ return if performed?
29
+ return unless authorize_action!(:take_over_browser, @sandbox)
30
+ end
31
+ unless @sandbox.browser_running? && @sandbox.browser_live_url.present?
32
+ return render json: { error: "The sandbox's browser has no live view to open", browser: @sandbox.browser_summary },
33
+ status: :conflict
34
+ end
35
+
36
+ issued = BrowserLiveTicket.issue(@sandbox, user: current_user, mode: mode)
37
+ response.headers["Cache-Control"] = "no-store"
38
+ render json: issued.merge(expires_at: issued[:expires_at].iso8601, url: @sandbox.browser_live_url), status: :created
39
+ end
40
+
41
+ private
42
+
43
+ def set_sandbox
44
+ scope = owned(SandboxSession)
45
+ scope = scope.where.not(sandbox_type: "app_runtime").or(scope.where(account_id: current_account.id)) if current_account
46
+ @sandbox = scope.find_by!(session_id: params[:sandbox_id])
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # A checkout sandbox's browser (SandboxBrowser):
6
+ #
7
+ # GET /api/sandboxes/:sandbox_id/browser { browser: }
8
+ # POST /api/sandboxes/:sandbox_id/browser starts it; mode ("headless",
9
+ # the default, or "headed") and capabilities (optional tool groups)
10
+ # DELETE /api/sandboxes/:sandbox_id/browser stops it
11
+ #
12
+ # Found the way SandboxesController finds a checkout: among the caller's
13
+ # sandboxes, within their current account. A response carries the
14
+ # browser's mode, status, server key and live view URL, never its MCP
15
+ # endpoint or token. The live view may be opened from the dashboard
16
+ # origin the browser was started from, and from
17
+ # ActionAgent.browser_live_origins.
18
+ class SandboxBrowsersController < BaseController
19
+ # A browser runs the sandbox app's pages: the same gate as running an
20
+ # agent, and its own plan limit.
21
+ before_action :require_execution_enabled!, only: [ :create ]
22
+ before_action :set_sandbox
23
+ before_action -> { enforce_quota!(:browser_minutes) }, only: [ :create ]
24
+
25
+ def show
26
+ render json: { browser: @sandbox.browser_summary }
27
+ end
28
+
29
+ def create
30
+ SandboxBrowser.start(
31
+ @sandbox,
32
+ mode: params[:mode].presence || "headless",
33
+ capabilities: capability_params,
34
+ recording_url: ->(recording) { events_api_session_recording_url(recording) },
35
+ live_origins: [ request.base_url, *ActionAgent.browser_live_origins ]
36
+ )
37
+ render json: { browser: @sandbox.browser_summary }, status: :created
38
+ rescue SandboxBrowser::Error => e
39
+ render json: { error: e.message, browser: @sandbox.reload.browser_summary }, status: :unprocessable_entity
40
+ end
41
+
42
+ def destroy
43
+ SandboxBrowser.stop(@sandbox)
44
+ render json: { browser: @sandbox.browser_summary }
45
+ rescue SandboxBrowser::Error => e
46
+ render json: { error: e.message, browser: @sandbox.browser_summary }, status: :unprocessable_entity
47
+ end
48
+
49
+ private
50
+
51
+ def set_sandbox
52
+ scope = owned(SandboxSession)
53
+ scope = scope.where.not(sandbox_type: "app_runtime").or(scope.where(account_id: current_account.id)) if current_account
54
+ @sandbox = scope.find_by!(session_id: params[:sandbox_id])
55
+ end
56
+
57
+ def capability_params
58
+ value = params[:capabilities]
59
+ return [] if value.blank?
60
+
61
+ value.is_a?(Array) ? value.map(&:to_s) : [ value.to_s ]
62
+ end
63
+ end
64
+ end
65
+ end