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
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # An environment variable a project's sandbox boots with, such as the
5
+ # app's own STRIPE_SECRET_KEY.
6
+ #
7
+ # The value is encrypted at rest. A boot hands it to the steps that run
8
+ # the repository's code (setup, manifest and start, see
9
+ # Project#boot_spec). A secret's value is never rendered back to the
10
+ # client, and every log, error and code-session event of the project's
11
+ # sandboxes is scrubbed of it. A value the setup assistant set is not
12
+ # secret, so it is shown and not scrubbed.
13
+ #
14
+ # A secret has one of three sources:
15
+ #
16
+ # entered a value someone typed in: into the project's secrets
17
+ # form, or as the answer to the setup assistant's
18
+ # request_secret
19
+ # organization_key the owner's stored provider key for +provider+,
20
+ # resolved at boot and never copied here. Taking it
21
+ # needs the consent of whoever set the secret, because
22
+ # the repository's code can read it.
23
+ # setup_assistant a value that is not secret, set by the setup
24
+ # assistant's set_env (see ProjectSetup)
25
+ #
26
+ # And one of three kinds. Only an env secret reaches the sandbox's
27
+ # environment; the other two are read by the engine alone, when the
28
+ # project's browser signs in to the app:
29
+ #
30
+ # env an environment variable a boot sets
31
+ # sign_in a JSON object the explorer's sign_in tool fills a login
32
+ # form with (see #sign_in_credentials)
33
+ # storage_state a Playwright storage state (cookies and localStorage)
34
+ # the project's browser starts with, saved from a browser
35
+ # someone signed in by hand
36
+ class ProjectSecret < ApplicationRecord
37
+ include Ownable
38
+ owned_by :account, :user
39
+
40
+ SOURCES = %w[entered organization_key setup_assistant].freeze
41
+ KINDS = %w[env sign_in storage_state].freeze
42
+ # The sign-in fields a sign_in secret may hold. login_url is a path on
43
+ # the app; the *_field entries are CSS selectors for when the form's
44
+ # fields cannot be found on their own.
45
+ SIGN_IN_FIELDS = %w[login_url login password login_field password_field submit_field].freeze
46
+ MAX_SELECTOR_LENGTH = 200
47
+ # A storage state's values are scrubbed only when they look like a
48
+ # credential: an httpOnly cookie's, or one at least this long. Shorter
49
+ # values are preferences such as "accepted" or "expanded", which ordinary
50
+ # page text also holds.
51
+ STORAGE_SECRET_MIN_LENGTH = 20
52
+ # A storage state is kept in the value column (text), encrypted; its
53
+ # ciphertext must still fit there.
54
+ MAX_STORAGE_STATE_LENGTH = 40_000
55
+ # The variables "Use the organization's key" is offered for, with the
56
+ # stored provider key each one reads.
57
+ ORGANIZATION_KEY_PROVIDERS = {
58
+ "OPENAI_API_KEY" => "openai",
59
+ "ANTHROPIC_API_KEY" => "anthropic",
60
+ "OPENROUTER_API_KEY" => "openrouter"
61
+ }.freeze
62
+ # Live-mode credential prefixes (Stripe's secret and restricted keys).
63
+ LIVE_PREFIXES = %w[sk_live_ rk_live_].freeze
64
+ MAX_VALUE_LENGTH = 10_000
65
+
66
+ belongs_to :project, class_name: "ActionAgent::Project"
67
+
68
+ encrypts :value if ActionAgent.encrypt_credentials
69
+
70
+ validates :name, presence: true, length: { maximum: 255 }, format: { with: SandboxBootSpec::ENV_NAME, message: "must be an environment variable name" }
71
+ validates :name, uniqueness: { scope: :project_id, case_sensitive: true }
72
+ validates :source, inclusion: { in: SOURCES }
73
+ validates :kind, inclusion: { in: KINDS }
74
+ validate :name_allowed
75
+ validate :value_matches_source
76
+ validate :value_matches_kind
77
+ validate :kind_unchanged, on: :update
78
+
79
+ scope :ordered, -> { order(:name) }
80
+ scope :env, -> { where(kind: "env") }
81
+ scope :sign_in, -> { where(kind: "sign_in") }
82
+ scope :storage_state, -> { where(kind: "storage_state") }
83
+
84
+ # Whether +name+ is one the backend sets itself or one that changes how
85
+ # Ruby, Bundler, Node or git load code (SandboxBootSpec::REFUSED_SECRET_NAME).
86
+ def self.refused_name?(name)
87
+ SandboxBootSpec::REFUSED_SECRET_NAME.match?(name.to_s)
88
+ end
89
+
90
+ # What the secrets form warns about for +name+ set to +value+, before it
91
+ # is saved: [{ code:, message: }]. A warning never stops a save.
92
+ #
93
+ # live_credential a live-mode key, where a test key belongs
94
+ # short_value under SecretScrubber::MIN_SECRET_LENGTH characters,
95
+ # so logs cannot mask it
96
+ # rails_master_key RAILS_MASTER_KEY, better a development or test
97
+ # credentials key than production's
98
+ def self.warnings_for(name, value)
99
+ warnings = []
100
+ text = value.to_s
101
+ if LIVE_PREFIXES.any? { |prefix| text.start_with?(prefix) }
102
+ warnings << { code: "live_credential",
103
+ message: "This looks like a live-mode key. A sandbox runs the repository's code with it: use a test-mode key." }
104
+ end
105
+ if text.present? && text.length < SecretScrubber::MIN_SECRET_LENGTH
106
+ warnings << { code: "short_value",
107
+ message: "Values under #{SecretScrubber::MIN_SECRET_LENGTH} characters cannot be masked in logs." }
108
+ end
109
+ if name.to_s == "RAILS_MASTER_KEY"
110
+ warnings << { code: "rails_master_key",
111
+ message: "Prefer the key of development or test credentials over production's." }
112
+ end
113
+ warnings
114
+ end
115
+
116
+ def organization_key?
117
+ source == "organization_key"
118
+ end
119
+
120
+ # Whether the value is not secret: one the setup assistant set.
121
+ def plain?
122
+ source == "setup_assistant"
123
+ end
124
+
125
+ def env?
126
+ kind == "env"
127
+ end
128
+
129
+ def sign_in?
130
+ kind == "sign_in"
131
+ end
132
+
133
+ def storage_state?
134
+ kind == "storage_state"
135
+ end
136
+
137
+ # A sign_in secret's fields: login_url (a path on the app, "/" when
138
+ # unset), login, password and the optional *_field selectors. Empty for
139
+ # any other secret, or a value that is not a JSON object.
140
+ #
141
+ # @return [Hash{String => String}]
142
+ def sign_in_credentials
143
+ return {} unless sign_in?
144
+
145
+ parsed = parse_json_object(value) || {}
146
+ fields = parsed.slice(*SIGN_IN_FIELDS).select { |_name, field| field.is_a?(String) && field.present? }
147
+ fields["login_url"] = "/" if fields["login_url"].blank?
148
+ fields
149
+ end
150
+
151
+ # A storage_state secret's state as Playwright reads it, or nil.
152
+ #
153
+ # @return [Hash, nil]
154
+ def storage_state_value
155
+ storage_state? ? parse_json_object(value) : nil
156
+ end
157
+
158
+ # The values a sandbox's output, a candidate or a recording is scrubbed
159
+ # of for this secret:
160
+ #
161
+ # env its value, the organization's key resolved; none
162
+ # once that key is gone
163
+ # sign_in its value and the password apart. The login is an
164
+ # account name the app shows and mails, so it is kept.
165
+ # storage_state its value, and each cookie and localStorage value
166
+ # that looks like a credential (STORAGE_SECRET_MIN_LENGTH)
167
+ #
168
+ # @return [Array<String>]
169
+ def scrub_parts
170
+ case kind
171
+ when "sign_in"
172
+ [ value, sign_in_credentials["password"] ].compact
173
+ when "storage_state"
174
+ [ value, *storage_state_credentials ].compact.map(&:to_s)
175
+ else
176
+ [ resolved_value ].compact
177
+ end
178
+ rescue ActiveRecord::RecordNotFound
179
+ []
180
+ end
181
+
182
+ # The value a boot sets the variable to: the entered value, or the
183
+ # owner's stored provider key.
184
+ #
185
+ # @raise [ActiveRecord::RecordNotFound] when the organization's key is
186
+ # no longer stored
187
+ def resolved_value
188
+ return value unless organization_key?
189
+
190
+ key = organization_provider_key or
191
+ raise ActiveRecord::RecordNotFound, "#{name} uses the organization's #{provider} key, which is no longer stored: " \
192
+ "add it in Settings, or enter #{name} for this project"
193
+ key.credential
194
+ end
195
+
196
+ def warnings
197
+ return self.class.warnings_for(nil, sign_in_credentials["password"]) if sign_in?
198
+ return [] if storage_state?
199
+
200
+ self.class.warnings_for(name, organization_key? ? nil : value)
201
+ end
202
+
203
+ # The owner's stored provider key for +provider+, or nil. Looked up
204
+ # through ProviderKey's own owner column, which is the project's: both
205
+ # models are owned by the account first. An install that also stores
206
+ # members' personal keys keeps the organization's under scope_key
207
+ # "organization", and only those are read.
208
+ def organization_provider_key
209
+ return nil unless ProviderKey::KEY_PROVIDERS.include?(provider)
210
+
211
+ scope = ProviderKey.where(provider: provider)
212
+ scope = scope.where(scope_key: "organization") if ProviderKey.column_names.include?("scope_key")
213
+ case ProviderKey.owner_association
214
+ when :account then project.account_id && scope.find_by(account_id: project.account_id)
215
+ when :user then project.user_id && scope.find_by(user_id: project.user_id)
216
+ else scope.first
217
+ end
218
+ end
219
+
220
+ # The secret as the Environment tab lists it. Only a value that is not
221
+ # secret (#plain?) is included, so a person can see what the setup
222
+ # assistant set.
223
+ #
224
+ # @param setters [Hash{Integer => Object}] users by id, for set_by
225
+ def as_summary(setters = {})
226
+ setter = set_by_id && setters[set_by_id]
227
+ summary = {
228
+ name: name,
229
+ kind: kind,
230
+ source: source,
231
+ provider: provider,
232
+ set_by: setter && { id: setter.id, name: self.class.display_name(setter) },
233
+ updated_at: updated_at&.iso8601
234
+ }
235
+ summary[:value] = value if plain?
236
+ summary
237
+ end
238
+
239
+ def self.display_name(user)
240
+ user.try(:display_name) || user.try(:name) || user.try(:email_address) || user.try(:email)
241
+ end
242
+
243
+ private
244
+
245
+ def name_allowed
246
+ return if name.blank?
247
+
248
+ if self.class.refused_name?(name)
249
+ errors.add(:name, "#{name} is set by the sandbox or changes how code is loaded, so a project cannot set it")
250
+ elsif env? && [ Project::SIGN_IN_SECRET, Project::STORAGE_STATE_SECRET ].include?(name)
251
+ errors.add(:name, "#{name} keeps the project's sign-in, so an environment variable cannot take it")
252
+ end
253
+ end
254
+
255
+ def value_matches_source
256
+ if organization_key?
257
+ expected = ORGANIZATION_KEY_PROVIDERS[name]
258
+ if expected.nil?
259
+ errors.add(:source, "organization_key is offered only for #{ORGANIZATION_KEY_PROVIDERS.keys.join(", ")}")
260
+ elsif provider != expected
261
+ errors.add(:provider, "must be #{expected} for #{name}")
262
+ end
263
+ errors.add(:base, "Using the organization's key needs consent to the repository's code reading it") if consented_at.nil?
264
+ errors.add(:value, "must be empty when the organization's key is used") if value.present?
265
+ if expected && provider == expected && organization_provider_key.nil?
266
+ errors.add(:base, "The organization has no #{provider} key to use for #{name}")
267
+ end
268
+ elsif value.blank?
269
+ errors.add(:value, "can't be blank")
270
+ elsif value.length > max_value_length || value.include?("\0")
271
+ errors.add(:value, "must be text of at most #{max_value_length} characters")
272
+ end
273
+ end
274
+
275
+ def max_value_length
276
+ storage_state? ? MAX_STORAGE_STATE_LENGTH : MAX_VALUE_LENGTH
277
+ end
278
+
279
+ def value_matches_kind
280
+ return if env? || value.blank?
281
+
282
+ errors.add(:source, "must be entered for a #{kind} secret") if organization_key?
283
+ if sign_in?
284
+ validate_sign_in
285
+ elsif storage_state?
286
+ state = storage_state_value
287
+ unless state && state["cookies"].is_a?(Array) && (state["origins"].nil? || state["origins"].is_a?(Array))
288
+ errors.add(:value, "must be a storage state: a JSON object with a cookies list")
289
+ end
290
+ end
291
+ end
292
+
293
+ def validate_sign_in
294
+ fields = parse_json_object(value)
295
+ return errors.add(:value, "must be a JSON object with login_url, login and password") unless fields
296
+
297
+ errors.add(:value, "needs a password") unless fields["password"].is_a?(String) && fields["password"].present?
298
+ unless fields["login_url"].blank? || SandboxBootSpec.start_url_path?(fields["login_url"].to_s)
299
+ errors.add(:value, "login_url must be a path on the app, such as /users/sign_in")
300
+ end
301
+ %w[login_field password_field submit_field].each do |field|
302
+ selector = fields[field]
303
+ next if selector.nil?
304
+
305
+ unless selector.is_a?(String) && selector.length <= MAX_SELECTOR_LENGTH && !selector.match?(/[\r\n\0]/)
306
+ errors.add(:value, "#{field} must be a CSS selector of at most #{MAX_SELECTOR_LENGTH} characters")
307
+ end
308
+ end
309
+ end
310
+
311
+ def storage_state_credentials
312
+ state = storage_state_value || {}
313
+ cookies = Array(state["cookies"]).filter_map do |cookie|
314
+ next unless cookie.is_a?(Hash)
315
+
316
+ cookie["value"].to_s if cookie["httpOnly"] == true || cookie["value"].to_s.length >= STORAGE_SECRET_MIN_LENGTH
317
+ end
318
+ stored = Array(state["origins"]).flat_map do |origin|
319
+ next [] unless origin.is_a?(Hash)
320
+
321
+ Array(origin["localStorage"]).filter_map do |item|
322
+ item["value"].to_s if item.is_a?(Hash) && item["value"].to_s.length >= STORAGE_SECRET_MIN_LENGTH
323
+ end
324
+ end
325
+ cookies + stored
326
+ end
327
+
328
+ def kind_unchanged
329
+ errors.add(:kind, "cannot change: remove #{name} and set it again") if kind_changed?
330
+ end
331
+
332
+ def parse_json_object(text)
333
+ parsed = JSON.parse(text.to_s)
334
+ parsed.is_a?(Hash) ? parsed : nil
335
+ rescue JSON::ParserError
336
+ nil
337
+ end
338
+ end
339
+ end
@@ -18,6 +18,19 @@ module ActionAgent
18
18
  # need it — a checkout sandbox runs Claude Code sessions with it — through
19
19
  # #runtime_environment. For Claude Code that is an Anthropic API key only
20
20
  # (see CLAUDE_CODE_CREDENTIAL).
21
+ #
22
+ # A key is an organization key or a personal key, as its scope_key says:
23
+ #
24
+ # "organization" the key the owner shares with everyone acting for it
25
+ # "user:<id>" one member's own key for an account, used for that
26
+ # member's runs when ActionAgent.provider_key_scope is
27
+ # :personal_override
28
+ #
29
+ # Personal keys exist only on an install that owns keys per account. They
30
+ # are left out of .for_owner and the dashboard API's `owned`, so a caller
31
+ # that looks a key up by owner and provider reads the organization's.
32
+ # .personal_for is the one way to reach them, and
33
+ # ActionAgent::ProviderCredentials decides when to.
21
34
  class ProviderKey < ApplicationRecord
22
35
  # Providers that authenticate with an API key.
23
36
  KEY_PROVIDERS = %w[openai anthropic openrouter].freeze
@@ -43,6 +56,14 @@ module ActionAgent
43
56
  # a stored one is never handed out (see #needs_replacing?).
44
57
  SUBSCRIPTION_TOKEN_PREFIX = "sk-ant-oat"
45
58
 
59
+ ORGANIZATION_SCOPE = "organization"
60
+ PERSONAL_SCOPE_PREFIX = "user:"
61
+ SCOPE_KEY = /\A(?:organization|user:\d+)\z/
62
+
63
+ # Resolver options that point a provider somewhere other than its public
64
+ # endpoint — a proxy or gateway whose key is meant for that endpoint only.
65
+ ENDPOINT_OPTIONS = %w[uri_base base_url api_base host].freeze
66
+
46
67
  include Ownable
47
68
  owned_by :account, :user
48
69
 
@@ -54,8 +75,13 @@ module ActionAgent
54
75
  before_validation :normalize_host_credential, if: :host_based?
55
76
 
56
77
  validates :provider, presence: true, inclusion: { in: PROVIDERS }
57
- # One credential per provider per owner; which column that means
58
- # depends on the configured mode, so it is checked at validation time.
78
+ validates :scope_key, format: { with: SCOPE_KEY }
79
+ validate :personal_key_allowed, if: :personal?
80
+ # One organization credential per provider per owner, and one personal
81
+ # credential per member, owner and provider. The unique index on
82
+ # (account_id, scope_key, provider) holds the same rule for an
83
+ # account-owned install; which owner column applies depends on the
84
+ # configured mode, so it is also checked at validation time.
59
85
  validate :provider_unique_within_owner
60
86
  validates :credential, presence: true, length: { maximum: 500 }
61
87
  validates :credential, format: { with: %r{\Ahttps?://\S+\z}, message: "must be an http(s):// URL" },
@@ -83,6 +109,58 @@ module ActionAgent
83
109
  end
84
110
  end
85
111
 
112
+ # The organization rows: .for_owner and the dashboard API's `owned` read
113
+ # only these.
114
+ def self.owned_rows
115
+ where(scope_key: ORGANIZATION_SCOPE)
116
+ end
117
+
118
+ # +actor+'s personal keys for +owner+'s account. None when +actor+ is not
119
+ # a saved instance of ActionAgent.user_class, when +owner+ does not
120
+ # resolve to an account, or when keys are not owned per account (a
121
+ # single-user install, or one that owns keys per user), where no
122
+ # personal key can exist.
123
+ #
124
+ # @return [ActiveRecord::Relation]
125
+ def self.personal_for(owner, actor)
126
+ return none unless owner_association == :account
127
+
128
+ user_class = owner_class_for(:user)
129
+ return none unless user_class && actor.is_a?(user_class) && actor.id.present?
130
+
131
+ account = resolve_owner(owner)
132
+ return none if account.nil?
133
+
134
+ unscoped_by_owner.where(account_id: account.id, scope_key: personal_scope_key(actor))
135
+ end
136
+
137
+ # Every key +owner+'s account holds, organization and personal. Only for
138
+ # masking secrets out of output: a credential is read through .for_owner
139
+ # or .personal_for.
140
+ #
141
+ # @return [ActiveRecord::Relation]
142
+ def self.every_scope_for(owner)
143
+ return for_owner(owner) unless owner_association == :account
144
+
145
+ account = resolve_owner(owner)
146
+ account ? unscoped_by_owner.where(account_id: account.id) : none
147
+ end
148
+
149
+ # Whether this install keeps personal keys: ActionAgent.provider_key_scope
150
+ # is :personal_override and keys are owned per account.
151
+ def self.personal_keys_enabled?
152
+ ActionAgent.provider_key_scope == :personal_override && owner_association == :account
153
+ end
154
+
155
+ def self.personal_scope_key(actor)
156
+ "#{PERSONAL_SCOPE_PREFIX}#{actor.id}"
157
+ end
158
+
159
+ def self.unscoped_by_owner
160
+ unscope(where: :scope_key)
161
+ end
162
+ private_class_method :unscoped_by_owner
163
+
86
164
  def self.kind_of_provider(provider)
87
165
  if HOST_PROVIDERS.include?(provider) then "host"
88
166
  elsif CONNECTION_PROVIDERS.include?(provider) then "connection"
@@ -108,10 +186,119 @@ module ActionAgent
108
186
  host
109
187
  end
110
188
 
189
+ class << self
190
+ # The API keys for +owner+, `{ "openai" => "sk-...", ... }` — one entry
191
+ # per KEY_PROVIDERS provider that has one. Host-addressed providers
192
+ # (ollama) are left out: their credential is a URL, not a key.
193
+ #
194
+ # Each key is looked up in the order generation runs use: the host's
195
+ # `ActionAgent.provider_credentials_resolver`, asked with +owner+, and
196
+ # only when it answers nothing for a provider, the owner's saved row.
197
+ # A resolver answer leaves the provider out when it carries no key
198
+ # (`api_key`/`access_token`) — a run would then use the host's own
199
+ # configuration — or when it also sends the provider to another
200
+ # endpoint (ENDPOINT_OPTIONS), since a gateway's key must not reach the
201
+ # public endpoint a RubyLLM config would pair it with.
202
+ #
203
+ # On an install with an owner model, +owner+ must be an instance of the
204
+ # model this install keeps provider keys by — the configured
205
+ # `account_class`, or `user_class` when there is none — because rows
206
+ # are scoped by its id alone, and another model's id would read someone
207
+ # else's keys. Anything else raises ArgumentError. A nil owner reads no
208
+ # saved rows (only the resolver's keys), and an install with no owner
209
+ # model reads every saved row.
210
+ #
211
+ # A saved credential that no longer decrypts (a key rotation the row
212
+ # missed) is skipped with a warning rather than raised, so one stale row
213
+ # does not take every provider down with it. The warning names the
214
+ # error class only, never the value.
215
+ #
216
+ # @param owner [Object, nil] an instance of the owner model, or nil
217
+ # @return [Hash{String => String}]
218
+ # @raise [ArgumentError] when +owner+ is not an instance of the owner model
219
+ def credentials_for(owner)
220
+ owner = owner_record(owner)
221
+ saved = nil
222
+
223
+ KEY_PROVIDERS.sort.each_with_object({}) do |provider, credentials|
224
+ from_host = ActionAgent.provider_credentials(owner, provider)
225
+ credential = if from_host.present?
226
+ key_from(from_host)
227
+ else
228
+ (saved ||= saved_credentials(owner))[provider]
229
+ end
230
+ credentials[provider] = credential if credential.present?
231
+ end
232
+ end
233
+
234
+ # Writes the owner's keys onto `config` through `<provider>_api_key=`
235
+ # writers, the shape a `RubyLLM.context { |config| ... }` block hands
236
+ # out — but any object with those writers will do, so the engine gains
237
+ # no RubyLLM dependency:
238
+ #
239
+ # context = RubyLLM.context { |config| ActionAgent::ProviderKey.apply_to(config, owner: account) }
240
+ #
241
+ # Only providers with a key are written; the rest keep whatever the
242
+ # config already held (for a `RubyLLM.context`, the host's global keys).
243
+ #
244
+ # @param config [Object] anything answering to `openai_api_key=` and friends
245
+ # @param owner [Object, nil] as for #credentials_for
246
+ # @return [Array<String>] the providers written — never the keys, so the
247
+ # return value is safe to log
248
+ def apply_to(config, owner:)
249
+ credentials_for(owner).map do |provider, credential|
250
+ config.public_send(:"#{provider}_api_key=", credential)
251
+ provider
252
+ end
253
+ end
254
+
255
+ private
256
+
257
+ # +owner+ itself — unwrapped from a SimpleDelegator-style decorator —
258
+ # once it is known to be an instance of the owner model.
259
+ def owner_record(owner)
260
+ owner = owner.__getobj__ while defined?(::Delegator) && owner.is_a?(::Delegator)
261
+ association = owner_association
262
+ return owner if owner.nil? || association.nil?
263
+
264
+ class_name = ActionAgent.public_send(Ownable::CLASS_FOR.fetch(association))
265
+ owner_class = class_name.to_s.safe_constantize
266
+ raise ArgumentError, "provider keys are kept per #{association}, but #{class_name} does not load" if owner_class.nil?
267
+ return owner if owner.is_a?(owner_class)
268
+
269
+ raise ArgumentError,
270
+ "this install keeps provider keys per #{association} (#{class_name}), but was given a #{owner.class.name}"
271
+ end
272
+
273
+ def saved_credentials(owner)
274
+ for_owner(owner).where(provider: KEY_PROVIDERS).each_with_object({}) do |key, credentials|
275
+ credentials[key.provider] = key.credential
276
+ rescue StandardError => e
277
+ Rails.logger.warn("[ProviderKey] skipping #{key.provider} credential ##{key.id}: #{e.class.name}")
278
+ end
279
+ end
280
+
281
+ # The key a resolver answer carries, in the order the providers' own
282
+ # options read it (`api_key`, then `access_token`), or nil when it
283
+ # carries none or sends the provider to another endpoint.
284
+ def key_from(options)
285
+ return unless options.respond_to?(:to_hash)
286
+
287
+ options = options.to_hash.stringify_keys
288
+ return if ENDPOINT_OPTIONS.any? { |name| options[name].present? }
289
+
290
+ options["api_key"].presence || options["access_token"].presence
291
+ end
292
+ end
293
+
111
294
  def host_based?
112
295
  HOST_PROVIDERS.include?(provider)
113
296
  end
114
297
 
298
+ def personal?
299
+ scope_key.to_s.start_with?(PERSONAL_SCOPE_PREFIX)
300
+ end
301
+
115
302
  def connection?
116
303
  CONNECTION_PROVIDERS.include?(provider)
117
304
  end
@@ -187,10 +374,20 @@ module ActionAgent
187
374
  self.api_key = api_key.presence&.strip
188
375
  end
189
376
 
377
+ # A personal key needs an account to belong to, and is never a connection
378
+ # credential: those are handed to sandboxes, which every member shares.
379
+ def personal_key_allowed
380
+ if self.class.owner_association != :account || account_id.nil?
381
+ errors.add(:scope_key, "can name a member only when keys are owned per account")
382
+ elsif connection?
383
+ errors.add(:provider, "cannot be a personal key: #{provider} is connected for the whole organization")
384
+ end
385
+ end
386
+
190
387
  def provider_unique_within_owner
191
388
  return if provider.blank?
192
389
 
193
- siblings = self.class.for_owner(owner)
390
+ siblings = personal? ? self.class.where(account_id: account_id, scope_key: scope_key) : self.class.for_owner(owner)
194
391
  siblings = siblings.where.not(id: id) if persisted?
195
392
  errors.add(:provider, "has already been taken") if siblings.exists?(provider: provider)
196
393
  end