actionagent 1.7.2 → 1.8.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 (69) 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 +59 -54
  4. data/app/controllers/action_agent/api/agents_controller.rb +19 -3
  5. data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
  6. data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
  7. data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
  8. data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
  9. data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
  13. data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
  15. data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
  16. data/app/jobs/action_agent/code_session_job.rb +171 -0
  17. data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
  18. data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
  19. data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
  20. data/app/models/action_agent/agent.rb +49 -6
  21. data/app/models/action_agent/agent_run.rb +20 -1
  22. data/app/models/action_agent/code_session.rb +145 -0
  23. data/app/models/action_agent/evaluation.rb +64 -0
  24. data/app/models/action_agent/evaluation_run.rb +166 -54
  25. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  26. data/app/models/action_agent/github_connection.rb +75 -0
  27. data/app/models/action_agent/model_pricing.rb +214 -34
  28. data/app/models/action_agent/provider_key.rb +148 -10
  29. data/app/models/action_agent/sandbox_session.rb +194 -17
  30. data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
  31. data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
  32. data/app/services/action_agent/agent_execution_service.rb +4 -2
  33. data/app/services/action_agent/agent_scorecard.rb +50 -21
  34. data/app/services/action_agent/agent_tool_roster.rb +20 -9
  35. data/app/services/action_agent/claude_code_auth.rb +86 -0
  36. data/app/services/action_agent/codex_session_events.rb +37 -0
  37. data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
  38. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  39. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  40. data/app/services/action_agent/evaluation_runner_service.rb +25 -5
  41. data/app/services/action_agent/evaluation_standing.rb +145 -0
  42. data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
  43. data/app/services/action_agent/github_client.rb +111 -0
  44. data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
  45. data/app/services/action_agent/local_sandbox_databases.rb +257 -0
  46. data/app/services/action_agent/mcp_client.rb +5 -1
  47. data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
  48. data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
  49. data/app/services/action_agent/ollama_host_probe.rb +75 -0
  50. data/app/services/action_agent/payload_bounds.rb +36 -0
  51. data/app/services/action_agent/sandbox_manifest.rb +67 -0
  52. data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
  53. data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
  54. data/app/services/action_agent/secret_scrubber.rb +37 -0
  55. data/app/services/action_agent/tool_discovery.rb +19 -5
  56. data/config/routes.rb +23 -4
  57. data/lib/action_agent/engine.rb +1 -0
  58. data/lib/action_agent/version.rb +1 -1
  59. data/lib/action_agent.rb +153 -3
  60. data/lib/generators/action_agent/install_generator.rb +35 -5
  61. data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
  62. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  63. data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
  64. data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
  66. data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
  67. data/lib/tasks/claude_code.rake +16 -0
  68. data/lib/tasks/sandbox.rake +26 -0
  69. metadata +28 -2
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # The owner's GitHub connection (Settings -> Integrations): the OAuth
6
+ # web flow that creates it, the repositories it makes available, and
7
+ # disconnecting it. The access token is write-only; responses carry the
8
+ # GitHub login and the chosen repositories, never the token.
9
+ #
10
+ # #connect and #callback are browser navigations rather than fetches, so
11
+ # they answer with redirects back into the dashboard's Settings view.
12
+ class GithubConnectionsController < BaseController
13
+ STATE_SESSION_KEY = :action_agent_github_oauth_state
14
+
15
+ before_action :require_owner!
16
+ before_action :require_github_oauth!, only: [ :connect, :callback ]
17
+ before_action :set_connection, only: [ :repositories, :update, :destroy ]
18
+
19
+ # Later handlers win, so the subclass is registered last.
20
+ rescue_from GithubClient::Error, with: :github_unavailable
21
+ rescue_from GithubClient::Unauthorized, with: :github_unauthorized
22
+
23
+ # GET /api/github_connection
24
+ def show
25
+ connection = owned(GithubConnection).first
26
+
27
+ render json: {
28
+ configured: ActionAgent.github_oauth_configured?,
29
+ connected: connection.present?,
30
+ connection: connection&.as_summary
31
+ }
32
+ end
33
+
34
+ # GET /api/github_connection/repositories — what the token reaches,
35
+ # each marked with whether the workspace has it selected.
36
+ def repositories
37
+ selected = @connection.repository_names.map(&:downcase).to_set
38
+
39
+ render json: {
40
+ repositories: @connection.client.repositories.map do |repo|
41
+ repo.merge("selected" => selected.include?(repo["full_name"].downcase))
42
+ end
43
+ }
44
+ end
45
+
46
+ # PATCH /api/github_connection { repositories: ["owner/name", ...] }
47
+ #
48
+ # Only names GitHub lists for this token are kept: the selection is
49
+ # what a checkout sandbox trusts, so it is never taken from the client.
50
+ def update
51
+ # Not params.require: an empty list (clear the selection) is valid.
52
+ requested = params[:repositories]
53
+ unless requested.is_a?(Array) && requested.all? { |name| name.is_a?(String) }
54
+ return render json: { error: "repositories must be a list of owner/name strings" }, status: :bad_request
55
+ end
56
+
57
+ available = @connection.client.repositories.index_by { |repo| repo["full_name"].downcase }
58
+ unknown = requested.reject { |name| available.key?(name.downcase) }
59
+ if unknown.any?
60
+ return render json: { error: "Not reachable with this GitHub connection: #{unknown.join(', ')}" },
61
+ status: :unprocessable_entity
62
+ end
63
+
64
+ @connection.update!(repositories: requested.map { |name| available.fetch(name.downcase) }.uniq { |repo| repo["id"] })
65
+ render json: { connected: true, connection: @connection.as_summary }
66
+ end
67
+
68
+ # DELETE /api/github_connection
69
+ def destroy
70
+ @connection.destroy!
71
+ head :no_content
72
+ end
73
+
74
+ # GET /api/github_connection/connect — starts the OAuth web flow.
75
+ def connect
76
+ state = SecureRandom.urlsafe_base64(32)
77
+ session[STATE_SESSION_KEY] = state
78
+
79
+ redirect_to GithubClient.authorize_url(redirect_uri: callback_url, state: state), allow_other_host: true
80
+ end
81
+
82
+ # GET /api/github_connection/callback?code=...&state=...
83
+ def callback
84
+ # Single use: read and cleared before anything else can fail.
85
+ expected = session.delete(STATE_SESSION_KEY)
86
+ return redirect_to_settings(github: "denied") if params[:error].present?
87
+
88
+ unless expected.present? && params[:state].is_a?(String) &&
89
+ ActiveSupport::SecurityUtils.secure_compare(expected, params[:state])
90
+ return redirect_to_settings(github: "invalid_state")
91
+ end
92
+ return redirect_to_settings(github: "missing_code") unless params[:code].is_a?(String) && params[:code].present?
93
+
94
+ grant = GithubClient.exchange_code(code: params[:code], redirect_uri: callback_url)
95
+ user = GithubClient.new(grant[:access_token]).user
96
+
97
+ # Built through the owner scope, like a provider key, so the new
98
+ # record carries whichever owner column this install uses.
99
+ connection = owned(GithubConnection).first || owned(GithubConnection).new
100
+ # A different GitHub account starts with nothing selected: the old
101
+ # selection was checked against the other account's access.
102
+ connection.repositories = [] if connection.persisted? && connection.github_user_id != user["id"]
103
+ connection.update!(
104
+ access_token: grant[:access_token],
105
+ scopes: grant[:scope],
106
+ github_user_id: user["id"],
107
+ login: user["login"],
108
+ avatar_url: user["avatar_url"]
109
+ )
110
+
111
+ redirect_to_settings(github: "connected")
112
+ rescue GithubClient::Error => e
113
+ Rails.logger.warn("[ActionAgent] GitHub OAuth callback failed: #{e.message}")
114
+ redirect_to_settings(github: "error")
115
+ end
116
+
117
+ private
118
+
119
+ def set_connection
120
+ @connection = owned(GithubConnection).first!
121
+ end
122
+
123
+ def require_github_oauth!
124
+ return if ActionAgent.github_oauth_configured?
125
+
126
+ redirect_to_settings(github: "not_configured")
127
+ end
128
+
129
+ def callback_url
130
+ "#{request.base_url}#{request.script_name}/api/github_connection/callback"
131
+ end
132
+
133
+ def redirect_to_settings(**query)
134
+ redirect_to "#{request.script_name}/settings?#{{ tab: 'integrations' }.merge(query).to_query}"
135
+ end
136
+
137
+ def github_unauthorized
138
+ render json: { error: "GitHub rejected the stored token. Reconnect GitHub.", reconnect_required: true },
139
+ status: :unprocessable_entity
140
+ end
141
+
142
+ def github_unavailable(exception)
143
+ render json: { error: exception.message }, status: :bad_gateway
144
+ end
145
+ end
146
+ end
147
+ end
@@ -11,7 +11,9 @@ module ActionAgent
11
11
  # schema tools (ActiveAgent::SchemaTools, the classes the dashboard
12
12
  # discovers) is callable directly — find_<records>, count_<records>,
13
13
  # get_<record> — as this key's caller, so a client reads the host's
14
- # records under the same scope an agent run would (#439).
14
+ # records under the same scope an agent run would (#439). The
15
+ # dashboard's own evaluation and telemetry tools (MCPDashboardTools)
16
+ # are offered beside them.
15
17
  # - resources/list & resources/read: each agent is an agent://<slug>
16
18
  # resource whose content is its live scorecard (config + stats + memory
17
19
  # summary from the solid_agent datasets).
@@ -30,6 +32,8 @@ module ActionAgent
30
32
  skip_forgery_protection
31
33
  before_action :authenticate_api_key!, except: [ :unsupported ]
32
34
 
35
+ include MCPDashboardTools
36
+
33
37
  PROTOCOL_VERSION = "2025-03-26"
34
38
  JSONRPC_METHOD_NOT_FOUND = -32601
35
39
  JSONRPC_INVALID_PARAMS = -32602
@@ -105,6 +109,21 @@ module ActionAgent
105
109
  @owner = api_key.owner
106
110
  end
107
111
 
112
+ # The key's owner stands in for the signed-in owner, so the dashboard's
113
+ # own scopes (owned, owner_agents, owned_traces, RunSandbox) read what
114
+ # the JSON API would read for that owner.
115
+ def current_owner
116
+ @owner
117
+ end
118
+
119
+ # The key's user when it records one. Otherwise the owner, unless the
120
+ # owner is an account: a tenant is never a user.
121
+ def current_user
122
+ return @api_key.user if @api_key.respond_to?(:user) && @api_key.user
123
+
124
+ ActionAgent.multi_tenant? ? nil : @owner
125
+ end
126
+
108
127
  # The agents this key can reach. A key belongs to whoever owns it, and
109
128
  # a single-user install has no owner, so the key reaches every agent
110
129
  # the dashboard holds.
@@ -149,12 +168,23 @@ module ActionAgent
149
168
  protocolVersion: PROTOCOL_VERSION,
150
169
  capabilities: { tools: {}, resources: {} },
151
170
  serverInfo: { name: "activeagents", version: "1.0" },
152
- instructions: "Each run_<slug> tool runs one of this account's agents; every other tool reads the host " \
153
- "application's records directly, as the caller this key authenticates. Each agent://<slug> " \
154
- "resource returns the agent's live scorecard."
171
+ instructions: initialize_instructions
155
172
  }
156
173
  end
157
174
 
175
+ def initialize_instructions
176
+ text = "Each run_<slug> tool runs one of this account's agents; each find_, count_ and get_ tool reads the " \
177
+ "host application's records directly, as the caller this key authenticates. Each agent://<slug> " \
178
+ "resource returns the agent's live scorecard."
179
+ return text unless ActionAgent.mcp_dashboard_tools?
180
+
181
+ "#{text} The evaluations_, evaluation_runs_ and traces_ tools work on this account's evaluations and " \
182
+ "telemetry: edit the agent in your own checkout, start a run with evaluations_run (pass sandbox_id to run " \
183
+ "against a checkout sandbox), poll evaluation_runs_get for its status, results and fix items, compare runs " \
184
+ "with evaluation_runs_compare, and read a failing result's trace with traces_get (traces_search finds " \
185
+ "recent failures)."
186
+ end
187
+
158
188
  MESSAGE_INPUT_SCHEMA = {
159
189
  type: "object",
160
190
  properties: {
@@ -182,7 +212,7 @@ module ActionAgent
182
212
  agent_tools
183
213
  end
184
214
 
185
- { tools: tools + schema_tools_list }
215
+ { tools: dashboard_tools_list + tools + schema_tools_list }
186
216
  end
187
217
 
188
218
  # The host's schema tools, offered as the same tool definitions an
@@ -206,6 +236,9 @@ module ActionAgent
206
236
 
207
237
  def tools_call
208
238
  name = params.dig(:params, :name).to_s
239
+ # The three families' names are disjoint (see MCPDashboardTools), so
240
+ # this order never decides between two tools.
241
+ return dashboard_tool_call(name) if dashboard_tool?(name)
209
242
  return schema_tool_call(name) if ActionAgent.mcp_schema_tools? && ActionAgent.schema_tool_class_for(name)
210
243
 
211
244
  slug, action = name.delete_prefix("run_").split("__", 2)
@@ -9,6 +9,8 @@ module ActionAgent
9
9
  # and solid_agent records (ToolDiscovery), servers an agent declares in
10
10
  # its configuration, and the default catalog (MCPCatalog). An install
11
11
  # therefore sees both what it is already using and what it could turn on.
12
+ # The caller's live checkout sandbox runtimes are listed with the catalog,
13
+ # so the one started from Settings -> Integrations shows up here too.
12
14
  class MCPServersController < BaseController
13
15
  before_action :require_owner!
14
16
  # Launching provisions a sandbox and runs a server in it, so it answers
@@ -108,8 +110,15 @@ module ActionAgent
108
110
 
109
111
  private
110
112
 
113
+ # Runtimes are the caller's own: a listing carries no token, but which
114
+ # repositories another owner has running is still theirs to know.
111
115
  def discovery
112
- ToolDiscovery.new(traces: owned_traces, agents: owner_agents, hours: window_hours)
116
+ ToolDiscovery.new(
117
+ traces: owned_traces,
118
+ agents: owner_agents,
119
+ hours: window_hours,
120
+ runtimes: SandboxSession.runtime_server_listings(owned(SandboxSession))
121
+ )
113
122
  end
114
123
 
115
124
  # The catalog names this dashboard's own MCP endpoint as "<mount>/mcp",
@@ -4,7 +4,9 @@ module ActionAgent
4
4
  module Api
5
5
  # Per-account LLM provider credentials (Settings -> Provider API Keys).
6
6
  # API keys are write-only: responses carry a masked hint, never the key.
7
- # Ollama's credential is a host URL and is echoed back in full.
7
+ # Ollama's credential is a host URL and is echoed back in full; its
8
+ # optional API key (remote servers) is masked like the others. Claude
9
+ # Code's connection API key is stored here too, write-only like a key.
8
10
  class ProviderKeysController < BaseController
9
11
  before_action :require_owner!
10
12
 
@@ -20,16 +22,42 @@ module ActionAgent
20
22
  end
21
23
 
22
24
  # POST /api/provider_keys — upserts the credential for a provider.
25
+ # Host-based providers may also carry an optional api_key; omitting it
26
+ # keeps the stored one, sending an empty string clears it.
23
27
  def create
24
28
  provider = params.require(:provider)
25
29
  credential = params.require(:credential)
26
30
 
27
31
  record = owned(ProviderKey).find_or_initialize_by(provider: provider)
28
- record.update!(credential: credential)
32
+ attributes = { credential: credential }
33
+ attributes[:api_key] = params[:api_key].presence if params.key?(:api_key) && record.host_based?
34
+ record.update!(**attributes)
29
35
 
30
36
  render json: { provider_key: serialize(provider, record) }, status: :created
31
37
  end
32
38
 
39
+ # POST /api/provider_keys/test — checks that a host-based provider is
40
+ # reachable and lists the models it serves. Tests the submitted host
41
+ # (and api_key) when given, so a URL can be checked before saving;
42
+ # otherwise the stored credential, else the host app's default. Never
43
+ # persists anything.
44
+ def test
45
+ provider = params.require(:provider)
46
+ unless ProviderKey::HOST_PROVIDERS.include?(provider)
47
+ return render json: { error: "#{provider} is not a host-based provider" }, status: :unprocessable_entity
48
+ end
49
+
50
+ stored = owned(ProviderKey).find_by(provider: provider)
51
+ host = params[:credential].presence || stored&.credential || platform_host(provider)
52
+ api_key = params.key?(:api_key) ? params[:api_key].presence : stored&.api_key
53
+
54
+ if host.blank?
55
+ return render json: { ok: false, host: nil, models: [], latency_ms: nil, error: "No host configured" }
56
+ end
57
+
58
+ render json: OllamaHostProbe.call(host: host, api_key: api_key).to_h
59
+ end
60
+
33
61
  # DELETE /api/provider_keys/:provider
34
62
  def destroy
35
63
  owned(ProviderKey).find_by!(provider: params[:provider]).destroy!
@@ -38,12 +66,35 @@ module ActionAgent
38
66
 
39
67
  private
40
68
 
69
+ # The host app's default (config/active_agent.yml, e.g. OLLAMA_HOST)
70
+ # that applies when the owner has not configured their own.
71
+ def platform_host(provider)
72
+ return nil unless ProviderKey::HOST_PROVIDERS.include?(provider)
73
+
74
+ config = ActiveAgent.configuration[provider.to_sym]
75
+ config.respond_to?(:[]) ? config[:host].presence : nil
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
41
80
  def serialize(provider, record)
81
+ host_based = ProviderKey::HOST_PROVIDERS.include?(provider)
82
+
42
83
  {
43
84
  provider: provider,
44
- host_based: ProviderKey::HOST_PROVIDERS.include?(provider),
85
+ host_based: host_based,
86
+ # "key", "host", or "connection" (Settings -> Integrations rather
87
+ # than Provider API Keys).
88
+ kind: ProviderKey.kind_of_provider(provider),
45
89
  configured: record.present?,
46
90
  hint: record&.display_hint,
91
+ api_key_configured: record&.api_key? || false,
92
+ api_key_hint: record&.api_key_hint,
93
+ platform_default: host_based && record.nil? ? platform_host(provider) : nil,
94
+ # A Claude Code connection still holding a Claude subscription token
95
+ # from an earlier version: never used, and the UI asks for an API
96
+ # key in its place.
97
+ needs_replacing: record.present? && record.needs_replacing?,
47
98
  updated_at: record&.updated_at&.iso8601
48
99
  }
49
100
  end
@@ -67,16 +67,20 @@ module ActionAgent
67
67
  owned(ProviderKey).find_by(provider: provider)
68
68
  end
69
69
 
70
+ # Same probe as Settings -> "Test connection", so the builder's dropdown
71
+ # and the settings page agree on what the host serves (including the
72
+ # optional Bearer key for remote servers).
70
73
  def live_ollama_models
71
74
  host = ollama_host
72
75
  return nil unless host
73
76
 
74
- data = fetch_json(URI.join("#{host.chomp('/')}/", "models"))
75
- ids = Array(data&.dig("data")).filter_map { |model| model["id"] }
76
- [ ids.sort, "live" ] if ids.any?
77
- rescue StandardError => e
78
- Rails.logger.warn("[ProviderModels] ollama lookup failed: #{e.message}")
79
- nil
77
+ result = OllamaHostProbe.call(host: host, api_key: owner_provider_key("ollama")&.api_key)
78
+ unless result.ok
79
+ Rails.logger.warn("[ProviderModels] ollama lookup failed: #{result.error}")
80
+ return nil
81
+ end
82
+
83
+ [ result.models, "live" ] if result.models.any?
80
84
  end
81
85
 
82
86
  # Queries the Anthropic Models API with the account's key (newest first,
@@ -12,6 +12,9 @@ module ActionAgent
12
12
  # development, so it bypassed that safeguard too.
13
13
 
14
14
  before_action :require_execution_enabled!, only: [ :run, :compare ]
15
+ # A checkout runs the owner's code (setup, server): the same gates as
16
+ # running an agent, like MCPServersController#launch.
17
+ before_action :gate_checkout!, only: [ :create ]
15
18
  before_action :enforce_execution_quota!, only: [ :compare ]
16
19
  before_action :set_sandbox, only: [ :show, :run, :destroy ]
17
20
 
@@ -83,16 +86,34 @@ module ActionAgent
83
86
  end
84
87
 
85
88
  # GET /api/sandboxes
86
- # List available sandbox types and sample tasks
89
+ # List available sandbox types and sample tasks, and the caller's own
90
+ # sandboxes (?sandbox_type= narrows them), with what the Settings ->
91
+ # Integrations view needs to offer Claude Code sessions in a checkout.
87
92
  def index
88
93
  render json: {
89
94
  sandbox_types: SandboxSession::SANDBOX_TYPES,
90
95
  free_tier_limits: SandboxSession::FREE_TIER_LIMITS,
91
96
  templates: free_tier_templates,
92
- sample_tasks: sample_tasks
97
+ sample_tasks: sample_tasks,
98
+ sandboxes: listed_sandboxes.map(&:summary),
99
+ code_sessions_supported: code_sessions_supported?,
100
+ codex_sessions_supported: codex_sessions_supported?,
101
+ codex_connected: owned(ProviderKey).where(provider: "codex").any? { |key| key.runtime_environment.present? },
102
+ **claude_code_status
93
103
  }
94
104
  end
95
105
 
106
+ # Refuses a checkout the owner may not start. Usage is recorded by
107
+ # #create once the sandbox saved: a request refused for its own
108
+ # content (a repository that is not selected, say) runs nothing, so it
109
+ # must not spend a plan run.
110
+ def gate_checkout!
111
+ return unless checkout_requested?
112
+
113
+ require_execution_enabled!
114
+ enforce_execution_quota! unless performed?
115
+ end
116
+
96
117
  # POST /api/sandboxes
97
118
  # Create a new sandbox session, owned by whoever opened it.
98
119
  def create
@@ -100,11 +121,17 @@ module ActionAgent
100
121
  # Guarded: the association only exists when the host app configured a
101
122
  # user model, and a single-user install configures none.
102
123
  @sandbox.user = current_user if @sandbox.respond_to?(:user=)
124
+ # A checkout is validated against the owner's GitHub connection, which
125
+ # an account-owned install finds through the account.
126
+ @sandbox.account_id = current_account.id if current_account && @sandbox.has_attribute?(:account_id)
103
127
  @sandbox.agent_template = AgentTemplate.find_by(slug: params[:template_slug]) if params[:template_slug]
104
128
 
105
129
  if @sandbox.save
106
130
  @sandbox.provision!
107
131
  @sandbox.reload # Reload to get updated status after provisioning
132
+ # Counted once the checkout exists, as MCPServersController#launch
133
+ # counts a launched server.
134
+ record_execution_usage if checkout_requested?
108
135
  render json: { sandbox: @sandbox.summary }, status: :created
109
136
  else
110
137
  render json: { errors: @sandbox.errors.full_messages }, status: :unprocessable_entity
@@ -162,20 +189,92 @@ module ActionAgent
162
189
  end
163
190
 
164
191
  # DELETE /api/sandboxes/:session_id
165
- # End sandbox session
192
+ # End sandbox session. Expiring it enqueues SandboxCleanupJob, which
193
+ # terminates whatever the backend runs for it (a checkout's processes
194
+ # included); one still provisioning is released by
195
+ # SandboxProvisionJob when its backend returns.
166
196
  def destroy
167
197
  @sandbox.expire!
168
- render json: { deleted: true }
198
+ render json: { deleted: true, sandbox: @sandbox.summary }
169
199
  end
170
200
 
171
201
  private
172
202
 
173
203
  def set_sandbox
174
- @sandbox = owned(SandboxSession).find_by!(session_id: params[:id])
204
+ @sandbox = account_scoped(owned(SandboxSession)).find_by!(session_id: params[:id])
205
+ end
206
+
207
+ def checkout_requested?
208
+ params[:sandbox_type].to_s == "app_runtime"
209
+ end
210
+
211
+ # A checkout runs on its account's GitHub token and Claude Code
212
+ # credential, so in a multi-tenant install it is listed, shown and
213
+ # stopped only within the caller's current account, as
214
+ # CodeSessionsController finds it: listing another account's checkout
215
+ # as Ready offered a Claude Code panel that then answered 404. Other
216
+ # sandbox types are the caller's own wherever they were opened.
217
+ def account_scoped(scope)
218
+ return scope unless current_account
219
+
220
+ scope.where.not(sandbox_type: "app_runtime").or(scope.where(account_id: current_account.id))
221
+ end
222
+
223
+ # Whether the configured backend can run Claude Code sessions. A
224
+ # backend that cannot even be loaded (a misspelled class in
225
+ # ActionAgent.sandbox_backends, or a class file requiring an SDK the
226
+ # host doesn't bundle, which raises LoadError) cannot, and must not
227
+ # take the rest of this listing down with it.
228
+ #
229
+ # Nor can one whose Claude Code authentication does not work there
230
+ # (ActionAgent.claude_code_auth = :local_login needs :local).
231
+ def code_sessions_supported?
232
+ orchestrator = SandboxOrchestrator.new
233
+ orchestrator.supports?(:code_session) && ClaudeCodeAuth.backend_refusal(orchestrator).nil?
234
+ rescue StandardError, LoadError => e
235
+ Rails.logger.warn("[ActionAgent] sandbox backend unavailable: #{e.message}")
236
+ false
237
+ end
238
+
239
+ def codex_sessions_supported?
240
+ SandboxOrchestrator.new.supports_code_runner?("codex")
241
+ rescue StandardError, LoadError
242
+ false
243
+ end
244
+
245
+ # Whether the caller's Claude Code can run sessions, by
246
+ # ClaudeCodeAuth's rule: an API key they connected, or this machine's
247
+ # own login. Never a credential.
248
+ #
249
+ # claude_code_auth "api_key" | "local_login"
250
+ # claude_code_connected Boolean
251
+ # claude_code_login { logged_in:, auth_method: } (local_login only)
252
+ #
253
+ # The key is found through its own owner column, as
254
+ # SandboxSession#runtime_environment finds the credential it hands a
255
+ # checkout: a provider key is account-owned before user-owned.
256
+ def claude_code_status
257
+ status = ClaudeCodeAuth.status(owned(ProviderKey))
258
+ {
259
+ claude_code_auth: status[:mode],
260
+ claude_code_connected: status[:connected],
261
+ claude_code_login: status[:login]
262
+ }.compact
263
+ end
264
+
265
+ # The caller's sandboxes that have not expired, newest first. A failed
266
+ # one stays listed so its error can be read; one past its expiry but
267
+ # not reaped yet stays listed so it can still be stopped.
268
+ def listed_sandboxes
269
+ scope = account_scoped(owned(SandboxSession)).where.not(status: :expired).recent.limit(20)
270
+ type = params[:sandbox_type]
271
+ type.is_a?(String) && type.present? ? scope.by_type(type) : scope
175
272
  end
176
273
 
274
+ # An app_runtime sandbox also names the checkout: one of the owner's
275
+ # selected GitHub repositories and, optionally, a ref.
177
276
  def sandbox_params
178
- params.permit(:sandbox_type)
277
+ params.permit(:sandbox_type, :repository, :repository_ref)
179
278
  end
180
279
 
181
280
  def free_tier_templates
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Starting an evaluation run on the caller's behalf, shared by the
6
+ # evaluations API (POST /api/evaluations/:id/run) and the MCP facade's
7
+ # `evaluations_run` tool so both check a run the same way.
8
+ #
9
+ # A scenario suite accepts a selection that narrows the run:
10
+ # - scenario_ids: the scenarios to replay, by id
11
+ # - keys: the scenarios to replay, by key
12
+ # - group: one scenario group
13
+ # - models: the candidate models, as an array or a comma-separated string
14
+ # - sandbox_id: a checkout sandbox of the caller's that every replay reaches (see RunSandbox)
15
+ module EvaluationRunStarting
16
+ extend ActiveSupport::Concern
17
+
18
+ include RunSandbox
19
+
20
+ private
21
+
22
+ # Starts a run of +evaluation+ and returns it. A scenario suite replays
23
+ # through the provider once per scenario and model, so it runs in the
24
+ # background and the run comes back pending. A generation-sampling
25
+ # evaluation scores recorded data and finishes inline.
26
+ #
27
+ # A failure is recorded on the run rather than raised, so the caller
28
+ # always gets a run to report.
29
+ #
30
+ # @return [EvaluationRun]
31
+ def start_evaluation_run(evaluation, selection)
32
+ return evaluation.run_later!(**selection) if evaluation.scenario_suite?
33
+
34
+ evaluation.run!
35
+ rescue StandardError => e
36
+ Rails.logger.warn(
37
+ "[ActionAgent] evaluation #{evaluation.id} run failed: #{e.class}: #{e.message}"
38
+ )
39
+ # EvaluationRunnerService records the failure on the run before
40
+ # re-raising. A failure before the run record existed is recorded here.
41
+ evaluation.evaluation_runs.recent.first ||
42
+ evaluation.evaluation_runs.create!(status: :failed, error_message: e.message, completed_at: Time.current)
43
+ end
44
+
45
+ # Returns the selection +source+ (params or tool arguments) asks for,
46
+ # without the sandbox, which evaluation_run_sandbox checks separately.
47
+ def evaluation_run_selection(source)
48
+ models = source[:models]
49
+ models = models.to_s.split(",") unless models.is_a?(Array)
50
+
51
+ {
52
+ scenario_ids: Array(source[:scenario_ids]).map(&:to_s).reject(&:blank?),
53
+ keys: Array(source[:keys]).map(&:to_s).reject(&:blank?),
54
+ group: source[:group].to_s.presence,
55
+ models: models.map(&:to_s).map(&:strip).reject(&:blank?)
56
+ }.compact_blank
57
+ end
58
+
59
+ # Returns the checked sandbox +sandbox_id+ names for a run of
60
+ # +evaluation+, or nil when none was asked for. Raises RunSandbox::Refused.
61
+ #
62
+ # Only a scenario suite's own replay executes the agent: a sampling run
63
+ # scores recorded generations, and a host adapter replays in the host's
64
+ # runtime, where no dashboard dispatcher runs.
65
+ #
66
+ # @return [SandboxSession, nil]
67
+ def evaluation_run_sandbox(evaluation, sandbox_id)
68
+ return nil if sandbox_id.blank?
69
+
70
+ unless evaluation.scenario_suite?
71
+ raise RunSandbox::Refused, "Only a scenario evaluation runs the agent; this one scores recorded generations, " \
72
+ "so it cannot run against a sandbox"
73
+ end
74
+ if scenario_evaluation_adapter?(evaluation)
75
+ raise RunSandbox::Refused, "This install replays scenarios through its own adapter, which cannot reach a " \
76
+ "dashboard sandbox"
77
+ end
78
+
79
+ run_sandbox_for(evaluation.agent, sandbox_id)
80
+ end
81
+
82
+ # Whether a run of +evaluation+ cannot replay its scenarios: its agent
83
+ # is observed (read-only), and no host adapter replays it instead.
84
+ def unexecutable_scenario_run?(evaluation)
85
+ evaluation.agent.observed? && !scenario_evaluation_adapter?(evaluation)
86
+ end
87
+
88
+ def scenario_evaluation_adapter?(evaluation)
89
+ ActionAgent.scenario_evaluation_adapter_resolver&.call(evaluation).respond_to?(:call)
90
+ end
91
+ end
92
+ end
93
+ end