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