actionagent 1.8.1 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
  18. data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
  19. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  20. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  21. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  22. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  23. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  24. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  25. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  26. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  27. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  28. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  29. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  30. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  31. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  32. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  33. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  34. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  35. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  36. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  37. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  38. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  39. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  40. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  41. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  42. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  43. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  44. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  45. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  46. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  47. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  48. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  49. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  50. data/app/jobs/action_agent/code_session_job.rb +26 -2
  51. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  52. data/app/jobs/action_agent/exploration_job.rb +80 -0
  53. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  54. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  55. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  56. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  57. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  58. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  59. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  60. data/app/models/action_agent/agent.rb +39 -26
  61. data/app/models/action_agent/agent_run.rb +134 -23
  62. data/app/models/action_agent/agent_template.rb +35 -1
  63. data/app/models/action_agent/api_key.rb +14 -0
  64. data/app/models/action_agent/catalog_scenario.rb +75 -0
  65. data/app/models/action_agent/code_session.rb +16 -4
  66. data/app/models/action_agent/draft_pull_request.rb +131 -0
  67. data/app/models/action_agent/evaluation.rb +84 -0
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/interaction_feedback.rb +24 -0
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +200 -3
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +297 -14
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  85. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  86. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  87. data/app/services/action_agent/agent_execution_service.rb +450 -72
  88. data/app/services/action_agent/agent_sync.rb +5 -2
  89. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  90. data/app/services/action_agent/agent_toolbox.rb +215 -9
  91. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  92. data/app/services/action_agent/browser_sidecar.rb +213 -0
  93. data/app/services/action_agent/browser_sign_in.rb +163 -0
  94. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  95. data/app/services/action_agent/claude_code_auth.rb +70 -4
  96. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  97. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  98. data/app/services/action_agent/evaluation_fix.rb +101 -0
  99. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  100. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  101. data/app/services/action_agent/evaluation_standing.rb +15 -8
  102. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  103. data/app/services/action_agent/github_client.rb +405 -64
  104. data/app/services/action_agent/live_updates.rb +31 -0
  105. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  106. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  107. data/app/services/action_agent/mcp_client.rb +6 -2
  108. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  109. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  110. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  111. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  112. data/app/services/action_agent/project_boot_fix.rb +309 -0
  113. data/app/services/action_agent/project_capabilities.rb +200 -0
  114. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  115. data/app/services/action_agent/project_preflight.rb +178 -0
  116. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  117. data/app/services/action_agent/project_setup.rb +429 -0
  118. data/app/services/action_agent/provider_credentials.rb +102 -0
  119. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  120. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  121. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  122. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  123. data/app/services/action_agent/sandbox_browser.rb +299 -0
  124. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  125. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  126. data/app/services/action_agent/sandbox_mail.rb +103 -0
  127. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  128. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  129. data/app/services/action_agent/sandbox_patch.rb +253 -0
  130. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  131. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  132. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  133. data/app/services/action_agent/secret_scrubber.rb +33 -4
  134. data/app/services/action_agent/session_index.rb +270 -0
  135. data/app/services/action_agent/session_recording_service.rb +11 -0
  136. data/app/services/action_agent/session_timeline.rb +505 -0
  137. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  138. data/app/views/action_agent/session_player/show.html.erb +19 -0
  139. data/config/routes.rb +155 -1
  140. data/lib/action_agent/claude_login_process.rb +107 -0
  141. data/lib/action_agent/engine.rb +55 -5
  142. data/lib/action_agent/secret_requests.rb +80 -0
  143. data/lib/action_agent/version.rb +1 -1
  144. data/lib/action_agent.rb +507 -22
  145. data/lib/generators/action_agent/install_generator.rb +37 -0
  146. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  147. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  148. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  149. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  150. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  151. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  152. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  153. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  154. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  155. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  156. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  157. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  158. data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
  159. data/lib/tasks/action_agent.rake +33 -0
  160. data/lib/tasks/browser.rake +22 -0
  161. data/lib/tasks/sandbox.rake +1 -1
  162. data/lib/tasks/scenario_catalogs.rake +71 -0
  163. metadata +100 -2
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to sign a project's sandbox browser in to the app with a sign_in
5
+ # ProjectSecret, from the Rails process, so the credentials never reach a
6
+ # model: they are typed into the form by SandboxBrowserDriver and appear in
7
+ # no tool argument, tool result, log line, span or recorded action. The
8
+ # browser's own recording masks what is typed.
9
+ #
10
+ # The form is found on the secret's login_url: the password field, then the
11
+ # login field and the submit button near it, unless the secret names them
12
+ # with CSS selectors. The browser is signed in once the page it shows has
13
+ # no password field. Otherwise the password field is emptied, so a later
14
+ # page snapshot cannot show it.
15
+ class BrowserSignIn
16
+ # The outcome, as the explorer's sign_in tool and the test account step
17
+ # report it:
18
+ #
19
+ # signed_in the browser left the login form
20
+ # failed it still shows a password field, as after a wrong
21
+ # password
22
+ # unsupported the login page has no password field, as with sign-in
23
+ # through another site (OAuth or SSO)
24
+ # error the browser could not be driven
25
+ Result = Struct.new(:status, :message, keyword_init: true) do
26
+ def signed_in?
27
+ status == "signed_in"
28
+ end
29
+
30
+ def to_h
31
+ { status: status, signed_in: signed_in?, message: message }
32
+ end
33
+ end
34
+
35
+ MESSAGES = {
36
+ "signed_in" => "Signed in: the browser left the login form.",
37
+ "failed" => "The browser still shows the login form after submitting it. Check the login and password.",
38
+ "unsupported" => "The login page has no password field, so the app's sign-in (OAuth or SSO only) is not " \
39
+ "supported in the sandbox.",
40
+ "error" => "The sandbox's browser could not be driven to sign in."
41
+ }.freeze
42
+
43
+ PASSWORD_TARGET = '[data-aa-sign-in="password"]'
44
+ LOGIN_TARGET = '[data-aa-sign-in="login"]'
45
+ SUBMIT_TARGET = '[data-aa-sign-in="submit"]'
46
+ LOGIN_CANDIDATES = 'input[type="email"], input[autocomplete="username"], input[autocomplete="email"], ' \
47
+ 'input[name*="email" i], input[name*="login" i], input[name*="user" i], input[type="text"]'
48
+ # How long a sign-in that has not left the login form yet is given before
49
+ # it counts as failed.
50
+ SETTLE_SECONDS = 2
51
+ VISIBLE = "(el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length))"
52
+ CLEAR_PASSWORDS = "() => { document.querySelectorAll('input[type=\"password\"]').forEach((el) => { el.value = ''; }); return true; }"
53
+
54
+ # @param sandbox [SandboxSession] with its browser running
55
+ # @param secret [ProjectSecret] a sign_in secret
56
+ # @return [Result]
57
+ def self.call(sandbox, secret, driver: nil)
58
+ new(sandbox, secret, driver).call
59
+ end
60
+
61
+ def initialize(sandbox, secret, driver)
62
+ @sandbox = sandbox
63
+ @secret = secret
64
+ @driver = driver
65
+ end
66
+
67
+ def call
68
+ credentials = @secret.sign_in_credentials
69
+ return result("failed") if credentials["password"].blank?
70
+
71
+ driver.open(credentials["login_url"])
72
+ marks = driver.evaluate(mark_fields(credentials))
73
+ return result("unsupported") unless marks.is_a?(Hash) && marks["password"] == true
74
+
75
+ login_path = marks["path"]
76
+ driver.type(LOGIN_TARGET, credentials["login"]) if marks["login"] == true && credentials["login"].present?
77
+ if marks["submit"] == true
78
+ driver.type(PASSWORD_TARGET, credentials["password"])
79
+ driver.click(SUBMIT_TARGET)
80
+ else
81
+ driver.type(PASSWORD_TARGET, credentials["password"], submit: true)
82
+ end
83
+
84
+ return result("signed_in") if left_form?(credentials, login_path)
85
+
86
+ driver.evaluate(CLEAR_PASSWORDS)
87
+ result("failed")
88
+ rescue SandboxBrowserDriver::Error => e
89
+ Rails.logger.warn("[ActionAgent] sign-in in sandbox #{@sandbox.session_id} failed: #{e.message}")
90
+ clear_quietly
91
+ result("error")
92
+ end
93
+
94
+ private
95
+
96
+ def driver
97
+ @driver ||= SandboxBrowserDriver.new(@sandbox)
98
+ end
99
+
100
+ def result(status)
101
+ Result.new(status: status, message: MESSAGES.fetch(status))
102
+ end
103
+
104
+ # Whether the browser shows a page without a password field: at once on
105
+ # a page other than +login_path+, or on any page after SETTLE_SECONDS,
106
+ # for an app that redirects late or whose signed-in page shares the
107
+ # login page's path. A failure the app renders on another path, such as
108
+ # the form's POST path, still shows the field.
109
+ def left_form?(credentials, login_path)
110
+ state = page_state(credentials)
111
+ return true if state && state["path"] != login_path && !state["passwordField"]
112
+
113
+ driver.wait(SETTLE_SECONDS)
114
+ state = page_state(credentials)
115
+ state.present? && !state["passwordField"]
116
+ end
117
+
118
+ # { "path", "passwordField" }: the page's path, and whether it shows the
119
+ # secret's password field or any visible one. Nil when the page does not
120
+ # answer.
121
+ def page_state(credentials)
122
+ state = driver.evaluate(<<~JS.squish)
123
+ () => {
124
+ const visible = #{VISIBLE};
125
+ let named = null;
126
+ try { named = #{credentials['password_field'].to_json} ? document.querySelector(#{credentials['password_field'].to_json}) : null; } catch (e) {}
127
+ const field = named || [...document.querySelectorAll('input[type="password"]')].find(visible);
128
+ return { path: location.pathname, passwordField: visible(field) };
129
+ }
130
+ JS
131
+ state.is_a?(Hash) ? state : nil
132
+ end
133
+
134
+ def clear_quietly
135
+ @driver&.evaluate(CLEAR_PASSWORDS)
136
+ rescue SandboxBrowserDriver::Error
137
+ nil
138
+ end
139
+
140
+ # The page function that finds the sign-in form and marks its fields
141
+ # with data-aa-sign-in, answering which it found. Selectors from the
142
+ # secret are passed as JSON string literals.
143
+ def mark_fields(credentials)
144
+ password, login, submit = credentials.values_at("password_field", "login_field", "submit_field").map(&:to_json)
145
+ <<~JS.squish
146
+ () => {
147
+ const visible = #{VISIBLE};
148
+ const pick = (selector) => { try { return selector ? document.querySelector(selector) : null; } catch (e) { return null; } };
149
+ document.querySelectorAll('[data-aa-sign-in]').forEach((el) => el.removeAttribute('data-aa-sign-in'));
150
+ const password = pick(#{password}) || [...document.querySelectorAll('input[type="password"]')].find(visible);
151
+ if (!password) return { password: false, path: location.pathname };
152
+ const scope = password.form || document;
153
+ const login = pick(#{login}) || [...scope.querySelectorAll(#{LOGIN_CANDIDATES.to_json})].find((el) => el !== password && visible(el));
154
+ const submit = pick(#{submit}) || scope.querySelector('button[type="submit"], input[type="submit"]') || scope.querySelector('button:not([type])');
155
+ password.setAttribute('data-aa-sign-in', 'password');
156
+ if (login) login.setAttribute('data-aa-sign-in', 'login');
157
+ if (submit) submit.setAttribute('data-aa-sign-in', 'submit');
158
+ return { password: true, login: !!login, submit: !!submit, path: location.pathname };
159
+ }
160
+ JS
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Masks what a browser tool call typed: the `text` of `browser_type` and
5
+ # `browser_press_sequentially`, the `promptText` of `browser_handle_dialog`
6
+ # and each field `value` of `browser_fill_form`. MCPRecordingMiddleware applies it before an agent's
7
+ # browser action is stored, and SessionTimeline before a tool call is
8
+ # shown.
9
+ #
10
+ # Masking is per call. A value one call typed is masked in that call's
11
+ # arguments and result, but not in another call's result that shows it,
12
+ # such as a later snapshot of the filled field.
13
+ module BrowserToolRedaction
14
+ MASK = "[REDACTED]"
15
+ # The argument that holds what each single-value typing tool typed.
16
+ TYPED_ARGUMENTS = {
17
+ "browser_type" => "text", "browser_press_sequentially" => "text", "browser_handle_dialog" => "promptText"
18
+ }.freeze
19
+ TYPING_TOOLS = [ *TYPED_ARGUMENTS.keys, "browser_fill_form" ].freeze
20
+ # Shorter typed values are masked in the arguments but not searched for
21
+ # in other text, where they would mask unrelated characters.
22
+ MIN_SEARCHED_LENGTH = 3
23
+
24
+ module_function
25
+
26
+ def typing_tool?(tool_name)
27
+ TYPING_TOOLS.include?(tool_name.to_s)
28
+ end
29
+
30
+ # Returns +arguments+ with the typed values masked, in the form given: a
31
+ # Hash, or JSON text. Arguments of a typing tool that cannot be read as a
32
+ # Hash, such as JSON cut off mid-string, are masked whole. Other tools'
33
+ # arguments are returned unchanged.
34
+ def redact_arguments(tool_name, arguments)
35
+ return arguments unless typing_tool?(tool_name)
36
+
37
+ parsed = parse(arguments)
38
+ return MASK unless parsed.is_a?(Hash)
39
+
40
+ typed = TYPED_ARGUMENTS[tool_name.to_s]
41
+ masked =
42
+ if typed
43
+ parsed.key?(typed) ? parsed.merge(typed => MASK) : parsed
44
+ else
45
+ parsed.merge("fields" => Array(parsed["fields"]).map { |field| mask_field(field) })
46
+ end
47
+ arguments.is_a?(String) ? masked.to_json : masked
48
+ end
49
+
50
+ # Returns +text+, such as a tool result, with the values the call typed
51
+ # masked. A typing tool's text is masked whole when its +arguments+
52
+ # cannot be read. Other tools' text is returned unchanged.
53
+ def redact_text(tool_name, text, arguments)
54
+ return text unless typing_tool?(tool_name) && text.is_a?(String)
55
+
56
+ parsed = parse(arguments)
57
+ return MASK unless parsed.is_a?(Hash)
58
+
59
+ typed_values(tool_name, parsed)
60
+ .select { |value| value.length >= MIN_SEARCHED_LENGTH }
61
+ .sort_by { |value| -value.length }
62
+ .reduce(text) { |masked, value| masked.gsub(value, MASK) }
63
+ end
64
+
65
+ # The values a typing tool call typed, from its parsed arguments.
66
+ def typed_values(tool_name, parsed)
67
+ typed = TYPED_ARGUMENTS[tool_name.to_s]
68
+ values = typed ? [ parsed[typed] ] : Array(parsed["fields"]).map { |field| field["value"] if field.is_a?(Hash) }
69
+ values.compact.map(&:to_s).reject(&:empty?)
70
+ end
71
+
72
+ def mask_field(field)
73
+ return field unless field.is_a?(Hash)
74
+
75
+ field = field.deep_stringify_keys
76
+ field.key?("value") ? field.merge("value" => MASK) : field
77
+ end
78
+
79
+ # +arguments+ as a string-keyed Hash: a Hash as given, JSON text parsed.
80
+ # Anything else, or JSON that does not parse, is returned as nil.
81
+ def parse(arguments)
82
+ case arguments
83
+ when Hash then arguments.deep_stringify_keys
84
+ when String
85
+ parsed = JSON.parse(arguments)
86
+ parsed.is_a?(Hash) ? parsed : nil
87
+ end
88
+ rescue JSON::ParserError
89
+ nil
90
+ end
91
+ end
92
+ end
@@ -14,8 +14,8 @@ module ActionAgent
14
14
  # LocalSandboxBackend.claude_login_status). The dashboard never
15
15
  # sees that credential.
16
16
  #
17
- # Anthropic does not let third-party products store or route requests
18
- # through Claude.ai subscription credentials, so there is no third mode:
17
+ # :sandbox_login is the user's login inside the unmodified CLI. Only the
18
+ # CLI holds its credential; the dashboard relays a one-use login code.
19
19
  # https://code.claude.com/docs/en/legal-and-compliance.md
20
20
  module ClaudeCodeAuth
21
21
  module_function
@@ -29,6 +29,12 @@ module ActionAgent
29
29
  mode == "local_login"
30
30
  end
31
31
 
32
+ def sandbox_login?
33
+ mode == "sandbox_login"
34
+ end
35
+
36
+ LOGIN_VERBS = %i[start_claude_login submit_claude_login_code claude_login_status claude_logout].freeze
37
+
32
38
  # Why +orchestrator+'s backend cannot run Claude Code sessions with the
33
39
  # configured authentication, or nil. A machine's own login is the
34
40
  # dashboard user's, so only a backend running sessions as that user, on
@@ -36,6 +42,15 @@ module ActionAgent
36
42
  # find no login or need it copied there, which is what this mode exists
37
43
  # to never do.
38
44
  def backend_refusal(orchestrator)
45
+ if sandbox_login?
46
+ unless LOGIN_VERBS.all? { |verb| orchestrator.supports?(verb) }
47
+ return "The #{orchestrator.backend_name} backend does not implement sandbox Claude login (#{LOGIN_VERBS.join(', ')})"
48
+ end
49
+ unless orchestrator.local? || ActionAgent.claude_code_hosted_login_enabled
50
+ return "Hosted Claude subscription login is disabled; the operator must review the hosting requirements and enable it explicitly"
51
+ end
52
+ return
53
+ end
39
54
  return unless local_login?
40
55
  return if orchestrator.local?
41
56
 
@@ -53,12 +68,15 @@ module ActionAgent
53
68
  # +provider_keys+: booleans and the login's method, never a credential.
54
69
  #
55
70
  # @return [Hash] { mode:, connected:, login: } (login for :local_login only)
56
- def status(provider_keys)
71
+ def status(provider_keys, sandboxes: [], user_id: nil)
57
72
  if local_login?
58
73
  # Only the :local backend uses the login, and no other one needs
59
74
  # this machine's CLI asked about it.
60
75
  login = local_backend? ? LocalSandboxBackend.claude_login_status : LocalSandboxBackend::LOGGED_OUT
61
76
  { mode: mode, connected: login[:logged_in], login: login.slice(:logged_in, :auth_method) }
77
+ elsif sandbox_login?
78
+ sessions = Array(sandboxes).map { |sandbox| sandbox_status(sandbox, user_id: user_id) }
79
+ { mode: mode, connected: api_key_connected?(provider_keys) || sessions.any? { |row| row[:logged_in] }, sandboxes: sessions }
62
80
  else
63
81
  { mode: mode, connected: api_key_connected?(provider_keys) }
64
82
  end
@@ -73,7 +91,15 @@ module ActionAgent
73
91
 
74
92
  # Why a Claude Code session cannot start in +sandbox+ for want of
75
93
  # credentials, or nil.
76
- def credential_refusal(sandbox)
94
+ def credential_refusal(sandbox, user_id: nil)
95
+ if sandbox_login?
96
+ if (refusal = login_owner_refusal(sandbox, user_id: user_id))
97
+ return refusal
98
+ end
99
+ return if credential_mode(sandbox, user_id: user_id)
100
+
101
+ return "Sign in with your own Claude subscription in this sandbox, or connect an Anthropic API key"
102
+ end
77
103
  if local_login?
78
104
  return if LocalSandboxBackend.claude_login_status[:logged_in]
79
105
 
@@ -82,5 +108,45 @@ module ActionAgent
82
108
  "Claude Code is not connected: connect an Anthropic API key in Settings -> Integrations first"
83
109
  end
84
110
  end
111
+
112
+ # Why +user_id+ may run nothing in +sandbox+ under :sandbox_login, or nil.
113
+ # Every session runs as the same OS user beside the signed-in member's
114
+ # CLAUDE_CONFIG_DIR, so while one member's login is there no one else's
115
+ # session, on any runner or credential, may run in that checkout.
116
+ def login_owner_refusal(sandbox, user_id: nil)
117
+ return unless sandbox_login?
118
+ owner = sandbox.try(:claude_login_user_id)
119
+ return if owner.nil? || (user_id.present? && owner == user_id)
120
+
121
+ "Another member is signed in to Claude Code in this sandbox; start your own sandbox"
122
+ end
123
+
124
+ def credential_mode(sandbox, user_id: nil)
125
+ return mode unless sandbox_login?
126
+ return if login_owner_refusal(sandbox, user_id: user_id)
127
+ if user_id.present? && sandbox.claude_login_user_id == user_id
128
+ login = SandboxOrchestrator.new.claude_login_status(sandbox)
129
+ return "sandbox_login" if login[:logged_in] && login[:auth_method] == "claude.ai"
130
+ end
131
+ "api_key" if sandbox.runtime_environment.present?
132
+ rescue LocalSandboxBackend::Error, SandboxOrchestrator::UnsupportedBackendError, SandboxOrchestrator::BackendError
133
+ "api_key" if sandbox.runtime_environment.present?
134
+ end
135
+
136
+ def sandbox_status(sandbox, user_id: nil)
137
+ mine = user_id.present? && sandbox.claude_login_user_id == user_id
138
+ login = if mine && sandbox.ready? && sandbox.active? && !backend_refusal(SandboxOrchestrator.new)
139
+ SandboxOrchestrator.new.claude_login_status(sandbox).slice(:logged_in, :auth_method)
140
+ else
141
+ { logged_in: false, auth_method: nil }
142
+ end
143
+ login = { logged_in: false, auth_method: nil } unless login[:logged_in] && login[:auth_method] == "claude.ai"
144
+ fallback = sandbox.runtime_environment.present? && !login_owner_refusal(sandbox, user_id: user_id) ? "api_key" : nil
145
+ login.merge(session_id: sandbox.session_id, owned_by_you: mine,
146
+ credential_mode: login[:logged_in] ? "sandbox_login" : fallback)
147
+ rescue StandardError
148
+ { session_id: sandbox.session_id, logged_in: false, auth_method: nil, owned_by_you: mine,
149
+ credential_mode: sandbox.runtime_environment.present? && !login_owner_refusal(sandbox, user_id: user_id) ? "api_key" : nil }
150
+ end
85
151
  end
86
152
  end
@@ -90,8 +90,11 @@ module ActionAgent
90
90
  }
91
91
  ].freeze
92
92
 
93
- def initialize(owner:, message: nil, history: [], provider: nil, model: nil, allow_provider_processing: false)
93
+ # +actor+ is the signed-in user, whose effective provider key the
94
+ # assistant generates with (see ProviderCredentials).
95
+ def initialize(owner:, actor: nil, message: nil, history: [], provider: nil, model: nil, allow_provider_processing: false)
94
96
  @owner = owner
97
+ @actor = actor
95
98
  @message = message
96
99
  @history = history
97
100
  @provider = provider
@@ -107,7 +110,7 @@ module ActionAgent
107
110
 
108
111
  def configuration
109
112
  {
110
- providers: DEFAULT_MODELS.map { |id, model| { id: id, configured: provider_configured?(id), default_model: model } },
113
+ providers: DEFAULT_MODELS.map { |id, model| { id: id, configured: configuration_provider_configured?(id), default_model: model } },
111
114
  defaults: { provider: nil, model: nil },
112
115
  processing: { consent_required: true, disclosure: PROCESSING_DISCLOSURE },
113
116
  connections: connections,
@@ -200,17 +203,22 @@ module ActionAgent
200
203
  # sessions are started from that view (see LIMITATIONS).
201
204
  #
202
205
  # Claude Code is connected by ClaudeCodeAuth's rule, as the sandbox
203
- # listing reports it: an API key the owner connected (auth "api_key"), or
204
- # this machine's own login (auth "local_login", with its status).
206
+ # listing reports it: an API key the owner connected (auth "api_key"),
207
+ # this machine's own login (auth "local_login", with its status), or a
208
+ # login inside each checkout sandbox (auth "sandbox_login", with the
209
+ # actor's per-sandbox state under sandboxes).
205
210
  def connections
206
- claude_code = ClaudeCodeAuth.status(ProviderKey.for_owner(@owner))
211
+ claude_code = ClaudeCodeAuth.status(ProviderKey.for_owner(@owner),
212
+ sandboxes: SandboxSession.for_owner(@owner).where(sandbox_type: "app_runtime").recent.limit(20),
213
+ user_id: @actor.respond_to?(:id) ? @actor.id : nil)
207
214
  {
208
215
  github: { supported: true, connected: GithubConnection.for_owner(@owner).exists? },
209
216
  claude_code: {
210
217
  supported: code_sessions_supported?,
211
218
  connected: claude_code[:connected],
212
219
  auth: claude_code[:mode],
213
- login: claude_code[:login]
220
+ login: claude_code[:login],
221
+ sandboxes: claude_code[:sandboxes]
214
222
  }.compact,
215
223
  coi: { supported: false }
216
224
  }
@@ -278,10 +286,16 @@ module ActionAgent
278
286
 
279
287
  def provider_options(provider)
280
288
  @provider_options ||= {}
281
- @provider_options[provider] ||= begin
282
- host = ActionAgent.provider_credentials(@owner, provider)
283
- (host.presence || ProviderKey.for_owner(@owner).find_by(provider: provider)&.generation_options || {}).symbolize_keys
284
- end
289
+ @provider_options[provider] ||=
290
+ ProviderCredentials.resolve(owner: @owner, actor: @actor, provider: provider).options.symbolize_keys
291
+ end
292
+
293
+ # The configuration lists a provider whose credentials cannot be
294
+ # resolved as not configured; a request for it fails in #validate!.
295
+ def configuration_provider_configured?(provider)
296
+ provider_configured?(provider)
297
+ rescue ProviderCredentials::Unresolved
298
+ false
285
299
  end
286
300
 
287
301
  def provider_configured?(provider)