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
|
@@ -2,21 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
module ActionAgent
|
|
4
4
|
module Api
|
|
5
|
-
#
|
|
6
|
-
# API keys are write-only: responses
|
|
7
|
-
# Ollama's credential is a host URL
|
|
8
|
-
# optional API key (remote servers) is
|
|
9
|
-
# Code's connection API key is stored here
|
|
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
|
-
|
|
25
|
+
SCOPES = %w[organization personal].freeze
|
|
12
26
|
|
|
13
|
-
|
|
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 =
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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 =
|
|
51
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
11
|
-
# the
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
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:
|
|
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
|
|
87
|
-
# as returned by the API) so new model releases appear
|
|
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 =
|
|
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
|