actionagent 1.8.0 → 1.9.0

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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. metadata +100 -2
@@ -7,6 +7,7 @@ module ActionAgent
7
7
 
8
8
  # GET /api/runs/:id
9
9
  def show
10
+ @run.expire_overdue_input_requests!
10
11
  render json: {
11
12
  run: run_json(@run),
12
13
  messages: interaction_messages(@run),
@@ -28,7 +29,7 @@ module ActionAgent
28
29
  def index
29
30
  # Scoped to the caller's own agents: this listed (and counted) every
30
31
  # run in the database regardless of who owned it.
31
- scope = AgentRun.includes(:agent).with_attachments.where(agent: owner_agents).recent
32
+ scope = AgentRun.includes(:agent, :pending_input_requests).with_attachments.where(agent: owner_agents).recent
32
33
 
33
34
  scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
34
35
  scope = scope.where(status: params[:status]) if params[:status].present?
@@ -124,6 +125,7 @@ module ActionAgent
124
125
  output_tokens: run.output_tokens,
125
126
  total_tokens: run.total_tokens,
126
127
  error_message: run.error_message,
128
+ input_requests: run.pending_input_requests.map { |request| InputRequestSerializer.call(request) },
127
129
  trace_id: run.trace_id,
128
130
  attachments: run.attachment_manifest,
129
131
  context_id: run.context_id,
@@ -544,6 +544,7 @@ module ActionAgent
544
544
  action_prompts: [ :name, :prompt, :expose_as_tool ],
545
545
  instruction_sets: [],
546
546
  tools: [],
547
+ approval_required_tools: [],
547
548
  model_config: {},
548
549
  response_format: {}
549
550
  )
@@ -15,8 +15,15 @@ module ActionAgent
15
15
  end
16
16
 
17
17
  # POST /api/api_keys
18
+ #
19
+ # The key records the user who created it, so a call made with an
20
+ # account's key acts as that user rather than as the whole account.
18
21
  def create
19
- api_key = owned(ApiKey).create!(name: params.require(:name))
22
+ api_key = owned(ApiKey).new(name: params.require(:name))
23
+ api_key.user_id = current_user.id if ActionAgent.user_class.present? && current_user.respond_to?(:id)
24
+ return unless authorize_action!(:manage_api_keys, api_key)
25
+
26
+ api_key.save!
20
27
 
21
28
  render json: {
22
29
  api_key: serialize(api_key).merge(token: api_key.token)
@@ -25,7 +32,10 @@ module ActionAgent
25
32
 
26
33
  # DELETE /api/api_keys/:id
27
34
  def destroy
28
- owned(ApiKey).find(params[:id]).destroy!
35
+ api_key = owned(ApiKey).find(params[:id])
36
+ return unless authorize_action!(:manage_api_keys, api_key)
37
+
38
+ api_key.destroy!
29
39
  head :no_content
30
40
  end
31
41
 
@@ -17,7 +17,8 @@ module ActionAgent
17
17
  # the page's CSRF token with every mutating request (frontend
18
18
  # utils/apiFetch.mjs). Endpoints that authenticate with a bearer token
19
19
  # instead — the telemetry ingest endpoint (Api::TracesController), the
20
- # evaluation report collector (Api::EvaluationReportsController) and the
20
+ # evaluation report collector (Api::EvaluationReportsController), a
21
+ # recording's event ingest (Api::RecordingEventIngestController) and the
21
22
  # MCP facade (Api::MCPController) — are exempt.
22
23
  class BaseController < ActionAgent::ApplicationController
23
24
  # Rails 8.2 verifies forgery protection from the browser's Sec-Fetch-Site
@@ -60,9 +61,11 @@ module ActionAgent
60
61
  # nothing owns) come back unfiltered.
61
62
  # An unresolved owner scopes to nothing rather than to
62
63
  # `where(id: nil)`, which would match every unowned row and leak them
63
- # across tenants.
64
+ # across tenants. Only the rows the model's .owned_rows admits are
65
+ # returned, so a personal provider key never comes back from here.
64
66
  def owned(relation)
65
67
  klass = relation.respond_to?(:klass) ? relation.klass : relation
68
+ relation = relation.merge(klass.owned_rows) if klass.respond_to?(:owned_rows)
66
69
 
67
70
  case klass.owner_association
68
71
  when :account then current_account ? relation.where(account_id: current_account.id) : relation.none
@@ -78,12 +81,54 @@ module ActionAgent
78
81
  ActionAgent.agents_for(current_owner)
79
82
  end
80
83
 
84
+ # The requests for input the caller can see: those of the runs of
85
+ # owner_agents, the runs `GET /api/runs/:id` shows.
86
+ def owner_input_requests
87
+ InputRequest.where(subject_type: AgentRun.polymorphic_name, subject_id: AgentRun.where(agent: owner_agents).select(:id))
88
+ end
89
+
81
90
  # Reported traces visible to the caller. Scoped to the tenant in a
82
91
  # multi-tenant install; every trace otherwise.
83
92
  def owned_traces
84
93
  ActionAgent.trace_model.for_account(current_account)
85
94
  end
86
95
 
96
+ # The recordings the caller may open: those the caller owns, plus those
97
+ # made inside a sandbox the caller owns. The second clause is what
98
+ # keeps recordings created before the owner column was written
99
+ # reachable.
100
+ def reachable_recordings
101
+ scope = owned(SessionRecording)
102
+ return scope if SessionRecording.owner_association.nil?
103
+
104
+ scope.or(SessionRecording.where(sandbox_session_id: owned(SandboxSession).select(:id)))
105
+ end
106
+
107
+ # What a session timeline may reach on the caller's behalf.
108
+ def timeline_scope
109
+ SessionTimeline::Scope.new(agents: owner_agents, traces: owned_traces, recordings: reachable_recordings)
110
+ end
111
+
112
+ # Rows read per kind of credential by #owner_credentials.
113
+ OWNER_CREDENTIAL_LIMIT = 100
114
+
115
+ # Returns the credentials the caller owns, for masking with
116
+ # SecretScrubber: provider keys, GitHub tokens, checkout sandboxes'
117
+ # runtime tokens, dashboard API keys (newest first) and the owner's
118
+ # telemetry key. Raises what a lookup raises, such as a credential that
119
+ # cannot be decrypted, so each caller decides whether to go on without
120
+ # the list.
121
+ # @return [Array<String>]
122
+ def owner_credentials
123
+ @owner_credentials ||= [
124
+ *owned(ProviderKey).limit(OWNER_CREDENTIAL_LIMIT).pluck(:credential, :api_key).flatten,
125
+ *owned(GithubConnection).limit(OWNER_CREDENTIAL_LIMIT).pluck(:access_token),
126
+ *owned(SandboxSession).where.not(runtime_mcp_token: nil).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).pluck(:runtime_mcp_token),
127
+ *owned(ApiKey).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).pluck(:token),
128
+ current_owner.try(:telemetry_api_key)
129
+ ].compact
130
+ end
131
+
87
132
  # The caller an agent run executes on behalf of.
88
133
  #
89
134
  # The host's seam first (ActionAgent.agent_actor_resolver), then the
@@ -116,6 +161,22 @@ module ActionAgent
116
161
  render json: { error: "No account" }, status: :unauthorized
117
162
  end
118
163
 
164
+ # Returns whether the signed-in user may perform +action+ (one of
165
+ # ActionAgent::PERMISSION_ACTIONS) on +subject+, per
166
+ # ActionAgent.permission_checker. When not, renders the refusal first,
167
+ # so a caller returns unless this is true.
168
+ def authorize_action!(action, subject)
169
+ return true if ActionAgent.permitted?(current_user, action, subject)
170
+
171
+ permission_denied(action)
172
+ false
173
+ end
174
+
175
+ def permission_denied(action)
176
+ render json: { error: "You do not have permission to do this", code: "forbidden", permission: action },
177
+ status: :forbidden
178
+ end
179
+
119
180
  # Asks the host app whether this owner may do +kind+ (:execution or
120
181
  # :trace_ingest). Unlimited unless the app said otherwise.
121
182
  def enforce_quota!(kind)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ class ClaudeLoginsController < BaseController
6
+ DISCONNECTED = { status: "disconnected", logged_in: false, auth_method: nil }.freeze
7
+ # Where a sign-in stops without a login and without leaving the CLI's
8
+ # config behind. "completed" is not one: a CLI that completed with
9
+ # another kind of account still holds that credential until logout.
10
+ ENDED = %w[expired failed cancelled disconnected].freeze
11
+
12
+ before_action :require_owner!
13
+ before_action :require_execution_enabled!
14
+ before_action :set_sandbox
15
+
16
+ # Polled while the sign-in dialog is open, so it takes no lock: the
17
+ # fix verification holds the sandbox's row while the runtime restarts.
18
+ def show
19
+ return render json: { login: DISCONNECTED } unless mine?
20
+
21
+ login = orchestrator.claude_login_status(@sandbox)
22
+ # A sign-in that ended without a login leaves no credential behind
23
+ # (the supervisor removes the config), so it stops holding the sandbox.
24
+ @sandbox.update_columns(claude_login_user_id: nil) if ended?(login)
25
+ render json: { login: login }
26
+ end
27
+
28
+ def create
29
+ login = nil
30
+ @sandbox.with_lock do
31
+ unless @sandbox.claude_login_user_id.nil? || mine? || ended?(orchestrator.claude_login_status(@sandbox))
32
+ return render json: { error: "This sandbox has another member's login. Start your own sandbox or use the account API key." }, status: :conflict
33
+ end
34
+ return busy if @sandbox.code_sessions.where(status: [ :queued, :running ]).exists?
35
+ login = orchestrator.start_claude_login(@sandbox)
36
+ @sandbox.update!(claude_login_user_id: current_user.id)
37
+ end
38
+ render json: { login: login }, status: :accepted
39
+ end
40
+
41
+ def code
42
+ # Filtered by engine.filter_parameters. Never interpolate this value
43
+ # into an error, an event, a model, or a log line.
44
+ login = nil
45
+ @sandbox.with_lock do
46
+ return forbidden unless mine?
47
+ login = orchestrator.submit_claude_login_code(@sandbox, params[:claude_login_code])
48
+ end
49
+ render json: { login: login }
50
+ end
51
+
52
+ def destroy
53
+ @sandbox.with_lock do
54
+ return forbidden unless mine?
55
+ return busy if @sandbox.code_sessions.where(status: [ :queued, :running ]).exists?
56
+ orchestrator.claude_logout(@sandbox)
57
+ @sandbox.update!(claude_login_user_id: nil)
58
+ end
59
+ render json: { login: DISCONNECTED }
60
+ end
61
+
62
+ rescue_from LocalSandboxBackend::Error, SandboxOrchestrator::UnsupportedBackendError, SandboxOrchestrator::BackendError do
63
+ # Backend errors may include command output; login endpoints expose
64
+ # a fixed message, never raw exception text.
65
+ render json: { error: "Claude sign-in could not complete. Check that Claude Code is installed, then start again." }, status: :unprocessable_entity
66
+ end
67
+
68
+ private
69
+
70
+ def set_sandbox
71
+ scope = owned(SandboxSession)
72
+ scope = scope.where(account_id: current_account.id) if current_account
73
+ @sandbox = scope.find_by!(session_id: params[:sandbox_id])
74
+ unless ClaudeCodeAuth.sandbox_login? && current_user.respond_to?(:id) && current_user.id.present?
75
+ return render json: { error: "Sandbox subscription login requires a signed-in user and :sandbox_login mode" }, status: :unprocessable_entity
76
+ end
77
+ if (refusal = ClaudeCodeAuth.backend_refusal(orchestrator))
78
+ return render json: { error: refusal }, status: :unprocessable_entity
79
+ end
80
+ unless @sandbox.app_runtime? && @sandbox.ready? && @sandbox.active?
81
+ render json: { error: "Sign in after this checkout sandbox is ready" }, status: :unprocessable_entity
82
+ end
83
+ response.headers["Cache-Control"] = "no-store"
84
+ end
85
+
86
+ def mine? = @sandbox.claude_login_user_id == current_user.id
87
+ def ended?(login) = !login[:logged_in] && ENDED.include?(login[:status].to_s)
88
+ def orchestrator = @orchestrator ||= SandboxOrchestrator.new
89
+ def forbidden = render(json: { error: "This login belongs to another user" }, status: :forbidden)
90
+ def busy = render(json: { error: "Wait for the running code session before changing its login" }, status: :conflict)
91
+ end
92
+ end
93
+ end
@@ -12,6 +12,11 @@ module ActionAgent
12
12
  # is execution — gated, and counted against the host app's quota, like
13
13
  # running an agent.
14
14
  class CodeSessionsController < BaseController
15
+ include EvaluationRunStarting
16
+
17
+ # How long an unverified fix holds its checkout (see #busy_session).
18
+ VERIFICATION_HOLD = 1.hour
19
+
15
20
  before_action :require_owner!
16
21
  before_action :require_execution_enabled!, only: [ :create ]
17
22
  before_action :set_sandbox
@@ -29,7 +34,11 @@ module ActionAgent
29
34
 
30
35
  # POST /api/sandboxes/:sandbox_id/code_sessions
31
36
  def create
32
- if (refusal = refusal_for(@sandbox))
37
+ runner = params[:runner].nil? ? "claude_code" : params[:runner]
38
+ unless CodeSession::RUNNERS.include?(runner)
39
+ return render json: { error: "runner must be claude_code or codex" }, status: :unprocessable_entity
40
+ end
41
+ if (refusal = refusal_for(@sandbox, runner: runner))
33
42
  return render json: { error: refusal }, status: :unprocessable_entity
34
43
  end
35
44
 
@@ -41,10 +50,13 @@ module ActionAgent
41
50
  sandbox_session: @sandbox,
42
51
  prompt: string_param(:prompt),
43
52
  model: string_param(:model).presence,
44
- # Owned like the sandbox it runs in.
45
- user_id: @sandbox.try(:user_id),
53
+ runner: runner,
54
+ # Subscription use always belongs to the person who clicked Run.
55
+ user_id: current_user.respond_to?(:id) ? current_user.id : @sandbox.try(:user_id),
46
56
  account_id: @sandbox.try(:account_id)
47
57
  )
58
+ code_session.credential_mode = runner == "claude_code" ? ClaudeCodeAuth.credential_mode(@sandbox, user_id: current_user&.id) : "api_key"
59
+ attach_fix!(code_session) if params[:evaluation_run_id].present?
48
60
  if (error = invalid_request(code_session))
49
61
  return render json: { error: error }, status: :unprocessable_entity
50
62
  end
@@ -57,6 +69,10 @@ module ActionAgent
57
69
  current = nil
58
70
  @sandbox.with_lock do
59
71
  current = busy_session
72
+ if !current && (refusal = refusal_for(@sandbox, runner: runner))
73
+ return render json: { error: refusal }, status: :unprocessable_entity
74
+ end
75
+ code_session.credential_mode = ClaudeCodeAuth.credential_mode(@sandbox, user_id: current_user&.id) if runner == "claude_code"
60
76
  code_session.save! unless current
61
77
  end
62
78
  return render json: busy_body(current), status: :conflict if current
@@ -111,31 +127,74 @@ module ActionAgent
111
127
  end
112
128
 
113
129
  # Why +sandbox+ cannot take a Claude Code session now, or nil.
114
- def refusal_for(sandbox)
115
- return "Claude Code sessions run only in a checkout (app_runtime) sandbox" unless sandbox.app_runtime?
130
+ def refusal_for(sandbox, runner: "claude_code")
131
+ label = runner == "codex" ? "Codex" : "Claude Code"
132
+ return "#{label} sessions run only in a checkout (app_runtime) sandbox" unless sandbox.app_runtime?
116
133
  return "The sandbox has expired; start a new one" if sandbox.ready? && !sandbox.active?
117
134
  return "The sandbox is #{sandbox.status}; wait until it is ready" unless sandbox.ready?
118
135
 
119
136
  orchestrator = SandboxOrchestrator.new
120
- unless orchestrator.supports?(:code_session)
121
- return "The #{orchestrator.backend_name} sandbox backend cannot run Claude Code sessions"
137
+ unless orchestrator.supports_code_runner?(runner)
138
+ return "The #{orchestrator.backend_name} sandbox backend cannot run #{label} sessions"
122
139
  end
123
140
 
124
- ClaudeCodeAuth.backend_refusal(orchestrator) || ClaudeCodeAuth.credential_refusal(sandbox)
141
+ if (refusal = ClaudeCodeAuth.login_owner_refusal(sandbox, user_id: current_user&.id))
142
+ return refusal
143
+ end
144
+ if runner == "codex"
145
+ return "Codex is not connected: connect an OpenAI API key in Settings -> Integrations first" if sandbox.runtime_environment(runner: runner).blank?
146
+ return
147
+ end
148
+ ClaudeCodeAuth.backend_refusal(orchestrator) || ClaudeCodeAuth.credential_refusal(sandbox, user_id: current_user&.id)
125
149
  end
126
150
 
127
151
  # The session of this sandbox that is queued or running, if any: one
128
- # checkout, one Claude Code at a time.
152
+ # checkout, one Claude Code at a time. A fix whose verification has not
153
+ # finished holds the checkout too, so no edit lands under the run that
154
+ # is meant to verify it, but only for VERIFICATION_HOLD: a worker lost
155
+ # mid-run must not lock the sandbox until it expires.
129
156
  def busy_session
130
- @sandbox.code_sessions.where(status: [ :queued, :running ]).first
157
+ @sandbox.code_sessions.where(status: [ :queued, :running ]).first ||
158
+ @sandbox.code_sessions.where.not(evaluation_run_id: nil).where(finished_at: VERIFICATION_HOLD.ago..).recent.detect do |session|
159
+ session.succeeded? && session.diff.present? && session.verification_error.blank? &&
160
+ (session.verification_run_id.nil? || session.verification_run&.status.in?(%w[pending running]))
161
+ end
131
162
  end
132
163
 
133
164
  def busy_body(current)
134
- { error: "A Claude Code session is already #{current.status} in this sandbox", code_session: current.summary }
165
+ label = current.runner == "codex" ? "Codex" : "Claude Code"
166
+ state = current.succeeded? ? "being verified" : current.status
167
+ { error: "A #{label} session is already #{state} in this sandbox", code_session: current.summary }
168
+ end
169
+
170
+ def attach_fix!(session)
171
+ raise EvaluationFix::Invalid, "Evaluation fixes use Claude Code" unless session.runner == "claude_code"
172
+ scope = EvaluationRun.joins(:evaluation).where(evaluation: Evaluation.where(agent: owner_agents))
173
+ run = scope.find(params[:evaluation_run_id])
174
+ project = Project.for_evaluation(run.evaluation)
175
+ unless project && @sandbox.project_id == project.id
176
+ raise EvaluationFix::Invalid, "Choose a checkout of this evaluation's project"
177
+ end
178
+ evaluation_run_sandbox(run.evaluation, @sandbox.session_id)
179
+ raise EvaluationFix::Invalid, "The sandbox backend cannot refresh the app runtime for verification" unless SandboxOrchestrator.new.supports?(:refresh_runtime)
180
+ request_item = params[:fix_item]
181
+ raise EvaluationFix::Invalid, "Choose a fix card" unless request_item.respond_to?(:permit)
182
+ fix = EvaluationFix.new(run, request_item.permit(:kind, :fault, :quote, scenario_keys: [], models: []).to_h)
183
+ previous = @sandbox.code_sessions.find(params[:previous_code_session_id]) if params[:previous_code_session_id].present?
184
+ if previous && (previous.evaluation_run_id != run.id || previous.fix_item != fix.item || !previous.finished?)
185
+ raise EvaluationFix::Invalid, "The previous session must implement this same fix"
186
+ end
187
+ session.assign_attributes(evaluation_run: run, fix_item: fix.item, previous_code_session: previous,
188
+ prompt: fix.prompt(previous: previous))
189
+ end
190
+
191
+ rescue_from EvaluationFix::Invalid do |error|
192
+ render json: { error: error.message }, status: :unprocessable_entity
135
193
  end
136
194
 
137
195
  def invalid_request(code_session)
138
- return "model is not a Claude Code model name" if code_session.model && !code_session.model.match?(MODEL_NAME)
196
+ label = code_session.runner == "codex" ? "Codex" : "Claude Code"
197
+ return "model is not a #{label} model name" if code_session.model && !code_session.model.match?(MODEL_NAME)
139
198
 
140
199
  code_session.errors.full_messages.to_sentence unless code_session.valid?
141
200
  end
@@ -13,12 +13,12 @@ module ActionAgent
13
13
  rescue_from DashboardAssistantService::SetupRequired, with: :setup_required
14
14
 
15
15
  def show
16
- render json: DashboardAssistantService.new(owner: current_owner).configuration
16
+ render json: DashboardAssistantService.new(owner: current_owner, actor: current_user).configuration
17
17
  end
18
18
 
19
19
  def create
20
20
  input = params.to_unsafe_h.symbolize_keys.slice(:message, :history, :provider, :model, :allow_provider_processing)
21
- assistant = DashboardAssistantService.new(owner: current_owner, **input)
21
+ assistant = DashboardAssistantService.new(owner: current_owner, actor: current_user, **input)
22
22
  assistant.validate!
23
23
  record_execution_usage
24
24
  render json: assistant.call
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Opening a pull request from one of the caller's checkout sandboxes
6
+ # (Settings -> Integrations -> Open draft PR): the preview of what would
7
+ # be published, the publish itself, the pull request it opened, and the
8
+ # patch to download where publishing is not available. See
9
+ # DraftPullRequestPublisher for how changes are read, filtered and
10
+ # written.
11
+ #
12
+ # Publishing is an explicit request from a signed-in user: it asks
13
+ # ActionAgent.permission_checker about :publish_pull_request, and
14
+ # DraftPullRequestJob asks again before it writes. Preview, status and
15
+ # patch find the sandbox the way its other endpoints do.
16
+ class DraftPullRequestsController < BaseController
17
+ before_action :require_owner!
18
+ before_action :set_sandbox
19
+
20
+ rescue_from DraftPullRequestPublisher::Refused, with: :refused
21
+
22
+ # GET /api/sandboxes/:sandbox_id/pull_request — the latest pull request
23
+ # opened from this sandbox (its state read again from GitHub at most
24
+ # once a minute), and whether publishing is available.
25
+ def show
26
+ @sandbox.draft_pull_requests.fail_stalled!
27
+ record = @sandbox.draft_pull_requests.recent.first
28
+ DraftPullRequestPublisher.refresh_status!(record) if record && !record.in_progress?
29
+
30
+ render json: { pull_request: record&.summary, publishing: publishing_status }
31
+ end
32
+
33
+ # POST /api/sandboxes/:sandbox_id/pull_request/preview { allowlist: [patterns] }
34
+ def preview
35
+ render json: { preview: publisher.preview(allowlist: allowlist_param), publishing: publishing_status }
36
+ end
37
+
38
+ # POST /api/sandboxes/:sandbox_id/pull_request
39
+ #
40
+ # { title:, body:, branch:, files: [{ path:, digest: }], allowlist: }
41
+ # publishes the files as a new branch and opens a draft pull
42
+ # request; each digest is the one the preview reported
43
+ # { update: true, files:, message:, allowlist: }
44
+ # publishes the files as a new commit on the branch of the latest
45
+ # pull request, with +message+ as the commit's message; the pull
46
+ # request's title and description stay as they are
47
+ # { open: true }
48
+ # opens a draft pull request for the latest branch that was
49
+ # published without one
50
+ # { regular: true }
51
+ # opens a regular pull request for that branch instead
52
+ #
53
+ # Answers 202 with the queued record, which GET reports on.
54
+ def create
55
+ return open_branch(regular: true) if boolean_param(:regular)
56
+ return open_branch(regular: false) if boolean_param(:open)
57
+ return update_latest if boolean_param(:update)
58
+
59
+ record = @sandbox.draft_pull_requests.new(
60
+ operation: "create", repository: @sandbox.repository, branch: string_param(:branch).to_s.strip,
61
+ title: string_param(:title).to_s.strip, body: string_param(:body)
62
+ )
63
+ if params[:code_session_id].present?
64
+ session = @sandbox.code_sessions.find(params[:code_session_id])
65
+ unless session.evaluation_run_id && session.verification_run&.complete?
66
+ raise DraftPullRequestPublisher::Refused.new("Wait for this fix's verification before publishing", code: "not_live")
67
+ end
68
+ room = DraftPullRequest::MAX_BODY_CHARACTERS - (record.body.present? ? record.body.length + 2 : 0)
69
+ section = EvaluationFixComparison.new(session).pull_request_body(mount: "#{request.base_url}#{request.script_name}", limit: room)
70
+ record.body = [ record.body.presence, section.presence ].compact.join("\n\n")
71
+ end
72
+ assign_owner(record)
73
+ return unless authorize_action!(:publish_pull_request, record)
74
+
75
+ branch_refusal = DraftPullRequestPublisher.branch_refusal(record.branch)
76
+ raise DraftPullRequestPublisher::Refused.new(branch_refusal, code: "invalid_branch") if branch_refusal
77
+
78
+ queue(record, allowlist: allowlist_param)
79
+ end
80
+
81
+ # GET /api/sandboxes/:sandbox_id/pull_request/patch?paths[]=…&title=…
82
+ # The chosen files (every publishable one when none are named) as a
83
+ # patch for `git am` or `git apply`.
84
+ def patch
85
+ paths = params[:paths]
86
+ paths = nil unless paths.is_a?(Array) && paths.all? { |path| path.is_a?(String) }
87
+ data = publisher.patch(paths: paths, title: string_param(:title), body: string_param(:body))
88
+
89
+ send_data data, type: "text/x-diff", disposition: "attachment",
90
+ filename: "#{@sandbox.repository.tr('/', '-')}-#{@sandbox.session_id.first(8)}.patch"
91
+ end
92
+
93
+ private
94
+
95
+ # A checkout runs on its account's GitHub access, so in a multi-tenant
96
+ # install it is found within the caller's current account, as
97
+ # CodeSessionsController finds it.
98
+ def set_sandbox
99
+ scope = owned(SandboxSession)
100
+ scope = scope.where(account_id: current_account.id) if current_account
101
+ @sandbox = scope.find_by!(session_id: params[:sandbox_id])
102
+ end
103
+
104
+ def publisher
105
+ @publisher ||= DraftPullRequestPublisher.new(@sandbox, user: current_user)
106
+ end
107
+
108
+ # A new commit goes only onto the branch of a pull request that is open
109
+ # on GitHub: a branch without one is opened first (see #open_branch).
110
+ def update_latest
111
+ record = @sandbox.draft_pull_requests.recent.where.not(head_commit: nil).first
112
+ if record.nil?
113
+ return render json: { error: "Nothing was published from this sandbox yet", code: "nothing_published" }, status: :unprocessable_entity
114
+ end
115
+ unless record.opened?
116
+ return render json: { error: "The branch #{record.branch} has no pull request yet. Open one for it first", code: "not_opened" },
117
+ status: :unprocessable_entity
118
+ end
119
+ if record.state.in?(%w[closed merged])
120
+ return render json: { error: "The pull request is #{record.state}. Open a new one instead", code: "pull_request_closed" },
121
+ status: :unprocessable_entity
122
+ end
123
+
124
+ record.assign_attributes(operation: "update",
125
+ commit_message: string_param(:message).to_s.strip.presence || DraftPullRequest::DEFAULT_UPDATE_MESSAGE)
126
+ assign_owner(record)
127
+ return unless authorize_action!(:publish_pull_request, record)
128
+
129
+ queue(record, allowlist: allowlist_param)
130
+ end
131
+
132
+ # Opens a pull request for the branch of the latest publish, which was
133
+ # pushed without one: a draft, or with +regular+ a regular one.
134
+ def open_branch(regular:)
135
+ record = @sandbox.draft_pull_requests.recent.first
136
+ unless record&.branch_only?
137
+ return render json: { error: "No branch of this sandbox is waiting to be opened as a pull request", code: "nothing_published" },
138
+ status: :unprocessable_entity
139
+ end
140
+
141
+ record.assign_attributes(operation: regular ? "open_regular" : "open_draft")
142
+ assign_owner(record)
143
+ return unless authorize_action!(:publish_pull_request, record)
144
+
145
+ queue(record, files: false)
146
+ end
147
+
148
+ # Checks the request against the sandbox as it is now, then queues the
149
+ # publish: one at a time per sandbox, under its row lock. A record that
150
+ # is itself queued or publishing is busy too.
151
+ def queue(record, allowlist: nil, files: true)
152
+ refusal = publisher.read_refusal
153
+ raise refusal if refusal
154
+
155
+ credential = publisher.credential
156
+ unless credential.available?
157
+ return render json: { error: credential.refusal, code: "no_credential", patch_available: true }, status: :unprocessable_entity
158
+ end
159
+
160
+ if files
161
+ selection = selection_param
162
+ changes = publisher.changes(allowlist: allowlist, paths: selection.keys)
163
+ chosen = publisher.select!(changes, selection)
164
+ record.base_commit = changes.base_commit
165
+ record.files = chosen.map { |file| { "path" => file.path, "status" => file.status, "mode" => file.mode, "digest" => file.digest } }
166
+ end
167
+ record.assign_attributes(status: "queued", credential_kind: credential.kind, error_code: nil, error_message: nil)
168
+ return render json: { error: record.errors.full_messages.to_sentence }, status: :unprocessable_entity unless record.valid?
169
+
170
+ busy = nil
171
+ @sandbox.with_lock do
172
+ @sandbox.draft_pull_requests.fail_stalled!
173
+ busy = @sandbox.draft_pull_requests.where(status: %w[queued publishing]).first
174
+ record.save! unless busy
175
+ end
176
+ if busy
177
+ return render json: { error: "A publish from this sandbox is already #{busy.status}", pull_request: busy.summary },
178
+ status: :conflict
179
+ end
180
+
181
+ DraftPullRequestJob.perform_later(record.id)
182
+ render json: { pull_request: record.summary }, status: :accepted
183
+ end
184
+
185
+ # The user who publishes, and the owner: the sandbox's account where
186
+ # the account owns records.
187
+ def assign_owner(record)
188
+ record.user_id = current_user.id if ActionAgent.user_class.present? && current_user.respond_to?(:id)
189
+ record.account_id = @sandbox.account_id if @sandbox.has_attribute?(:account_id) && @sandbox.account_id
190
+ end
191
+
192
+ def publishing_status
193
+ refusal = publisher.read_refusal
194
+ credential = refusal ? nil : publisher.credential
195
+ {
196
+ supported: refusal&.code != "unsupported",
197
+ available: refusal.nil? && credential.available?,
198
+ refusal: refusal&.message || credential&.refusal,
199
+ refusal_code: refusal&.code || (credential&.available? == false ? "no_credential" : nil),
200
+ credential: credential&.kind,
201
+ # A patch needs only the live checkout.
202
+ patch_available: refusal.nil?
203
+ }
204
+ end
205
+
206
+ # [{ path:, digest: }] as { path => digest }.
207
+ def selection_param
208
+ files = params[:files]
209
+ files = files.map { |file| file.respond_to?(:permit) ? file.permit(:path, :digest).to_h : file } if files.is_a?(Array)
210
+ unless files.is_a?(Array) && files.all? { |file| file.is_a?(Hash) && file["path"].is_a?(String) && file["digest"].is_a?(String) }
211
+ raise DraftPullRequestPublisher::Refused.new("files must be a list of { path, digest } from the preview", code: "nothing_selected")
212
+ end
213
+
214
+ files.to_h { |file| [ file["path"], file["digest"] ] }
215
+ end
216
+
217
+ def allowlist_param
218
+ allowlist = params[:allowlist]
219
+ return nil if allowlist.nil?
220
+ unless allowlist.is_a?(Array) && allowlist.all? { |pattern| pattern.is_a?(String) && pattern.present? }
221
+ raise DraftPullRequestPublisher::Refused.new("allowlist must be a list of path patterns", code: "not_publishable")
222
+ end
223
+
224
+ allowlist
225
+ end
226
+
227
+ def string_param(name)
228
+ value = params[name]
229
+ value.is_a?(String) ? value : nil
230
+ end
231
+
232
+ def boolean_param(name)
233
+ ActiveModel::Type::Boolean.new.cast(params[name]) == true
234
+ end
235
+
236
+ def refused(error)
237
+ status = error.code == "changed_since_preview" ? :conflict : :unprocessable_entity
238
+ render json: { error: error.message, code: error.code, patch_available: patch_offered?(error) }, status: status
239
+ end
240
+
241
+ # A patch of the same files fails the same way when they were too
242
+ # many or too large.
243
+ def patch_offered?(error)
244
+ !error.code.in?(%w[unsupported not_live unreadable too_large]) && publisher.read_refusal.nil?
245
+ end
246
+ end
247
+ end
248
+ end