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
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module ActionAgent
7
+ # Used to install, find and check the browser sidecar the :local sandbox
8
+ # backend runs: the @activeagents/browser-sidecar npm package, versioned
9
+ # with this engine, and the Chromium it drives. Both live under the local
10
+ # sandbox root:
11
+ #
12
+ # .browser/
13
+ # node_modules/ the package and its dependencies
14
+ # browsers/ Chromium (PLAYWRIGHT_BROWSERS_PATH)
15
+ #
16
+ # ActionAgent.browser_sidecar_path names a checkout of the package to run
17
+ # instead, for working on the sidecar; its version is not checked.
18
+ #
19
+ # Nothing here downloads anything except #install!: a start with the
20
+ # sidecar or Chromium missing is refused with the command that installs
21
+ # them (#refusal).
22
+ class BrowserSidecar
23
+ PACKAGE = "@activeagents/browser-sidecar"
24
+ ENTRYPOINT = "bin/browser-sidecar.mjs"
25
+ MINIMUM_NODE = Gem::Version.new("20")
26
+ INSTALL_COMMAND = "bin/rails action_agent:browser:install"
27
+ CHECK_TIMEOUT = 30
28
+ INSTALL_TIMEOUT = 900
29
+
30
+ class Error < StandardError; end
31
+
32
+ # One thing a browser start needs. +fix+ says how to get it when it is
33
+ # not +ok+.
34
+ Check = Struct.new(:name, :ok, :detail, :fix, keyword_init: true) do
35
+ def message
36
+ ok ? detail : "#{detail}. #{fix}"
37
+ end
38
+ end
39
+
40
+ class << self
41
+ # @return [Pathname]
42
+ def root
43
+ ActionAgent.local_sandbox_root.join(".browser")
44
+ end
45
+
46
+ # Where Chromium is installed and looked for.
47
+ # @return [Pathname]
48
+ def browsers_path
49
+ root.join("browsers")
50
+ end
51
+
52
+ # The configured checkout, or nil to run the installed package.
53
+ # @return [Pathname, nil]
54
+ def checkout
55
+ path = ActionAgent.browser_sidecar_path.presence
56
+ path && Pathname.new(path.to_s).expand_path
57
+ end
58
+
59
+ # @return [Pathname]
60
+ def package_dir
61
+ checkout || root.join("node_modules", *PACKAGE.split("/"))
62
+ end
63
+
64
+ # The environment the sidecar runs with, on top of a sandbox's
65
+ # sanitized one.
66
+ # @return [Hash{String => String}]
67
+ def environment
68
+ { "PLAYWRIGHT_BROWSERS_PATH" => browsers_path.to_s }
69
+ end
70
+
71
+ # The command line that runs the sidecar with +args+.
72
+ # @return [Array<String>]
73
+ def command(*args)
74
+ [ ActionAgent.node_command.to_s, package_dir.join(ENTRYPOINT).to_s, *args ]
75
+ end
76
+
77
+ # Whether a sidecar reporting +version+ may run: the engine's own
78
+ # version, or any version from a configured checkout.
79
+ def acceptable_version?(version)
80
+ checkout.present? || version == ActionAgent::VERSION
81
+ end
82
+
83
+ # Node, the sidecar and Chromium, each checked. The doctor task prints
84
+ # these; a browser start refuses with the first that is not ok.
85
+ #
86
+ # @return [Array<Check>]
87
+ def checks
88
+ node = node_check
89
+ return [ node ] unless node.ok
90
+
91
+ sidecar, report = sidecar_check
92
+ return [ node, sidecar ] unless report
93
+
94
+ [ node, sidecar, chromium_check(report) ]
95
+ end
96
+
97
+ # Why a browser cannot start on this machine, or nil when it can.
98
+ # @return [String, nil]
99
+ def refusal
100
+ checks.find { |check| !check.ok }&.message
101
+ end
102
+
103
+ # Installs the sidecar (npm ci in a checkout, else the package at the
104
+ # engine's version under #root) and its Chromium, writing their output
105
+ # to +io+.
106
+ #
107
+ # @raise [Error] when a step fails
108
+ def install!(io = $stdout)
109
+ if checkout
110
+ run!(io, [ ActionAgent.npm_command.to_s, "ci" ], chdir: checkout)
111
+ else
112
+ FileUtils.mkdir_p(root)
113
+ manifest = root.join("package.json")
114
+ manifest.write(JSON.pretty_generate({ "private" => true, "description" => "The dashboard's browser sidecar" })) unless manifest.exist?
115
+ run!(io, [ ActionAgent.npm_command.to_s, "install", "--no-audit", "--no-fund", "--save-exact", "#{PACKAGE}@#{ActionAgent::VERSION}" ],
116
+ chdir: root)
117
+ end
118
+ run!(io, command("install-browser"), chdir: checkout || root)
119
+ end
120
+
121
+ private
122
+
123
+ def node_check
124
+ output, status = capture([ ActionAgent.node_command.to_s, "--version" ])
125
+ version = output.to_s[/\Av?(\d+\.\d+\.\d+)/, 1]
126
+ if status&.success? && version && Gem::Version.new(version) >= MINIMUM_NODE
127
+ return Check.new(name: "node", ok: true, detail: "Node.js #{version}")
128
+ end
129
+
130
+ Check.new(
131
+ name: "node", ok: false,
132
+ detail: version ? "Node.js #{version} is older than #{MINIMUM_NODE}" : "Node.js was not found (#{ActionAgent.node_command})",
133
+ fix: "Install Node.js #{MINIMUM_NODE} or later, or set ActionAgent.node_command"
134
+ )
135
+ end
136
+
137
+ # The sidecar check, and what its `check` command reported (nil when
138
+ # it could not run).
139
+ def sidecar_check
140
+ unless package_dir.join(ENTRYPOINT).file?
141
+ return [ Check.new(name: "sidecar", ok: false, detail: "The browser sidecar is not installed (#{package_dir})",
142
+ fix: "Run #{INSTALL_COMMAND}"), nil ]
143
+ end
144
+
145
+ output, status = capture(command("check"))
146
+ report = status&.success? ? parse_report(output) : nil
147
+ unless report
148
+ return [ Check.new(name: "sidecar", ok: false, detail: "The browser sidecar did not run: #{output.to_s.strip.lines.last&.strip}",
149
+ fix: "Run #{INSTALL_COMMAND}"), nil ]
150
+ end
151
+
152
+ version = report["version"].to_s
153
+ unless acceptable_version?(version)
154
+ return [ Check.new(name: "sidecar", ok: false,
155
+ detail: "The browser sidecar is version #{version}, and this dashboard needs #{ActionAgent::VERSION}",
156
+ fix: "Run #{INSTALL_COMMAND}"), report ]
157
+ end
158
+
159
+ [ Check.new(name: "sidecar", ok: true, detail: "Browser sidecar #{version}#{" (#{checkout})" if checkout}"), report ]
160
+ end
161
+
162
+ def chromium_check(report)
163
+ chromium = report["chromium"].is_a?(Hash) ? report["chromium"] : {}
164
+ return Check.new(name: "chromium", ok: true, detail: "Chromium at #{chromium["executable"]}") if chromium["installed"]
165
+
166
+ Check.new(name: "chromium", ok: false, detail: "Chromium is not installed (#{browsers_path})", fix: "Run #{INSTALL_COMMAND}")
167
+ end
168
+
169
+ def parse_report(output)
170
+ report = JSON.parse(output.to_s.lines.last.to_s)
171
+ report.is_a?(Hash) ? report : nil
172
+ rescue JSON::ParserError
173
+ nil
174
+ end
175
+
176
+ def process_environment
177
+ LocalSandboxBackend.sanitized_environment.merge(environment)
178
+ end
179
+
180
+ # Runs +argv+ with output captured, for at most CHECK_TIMEOUT seconds.
181
+ # Returns the output and the exit status, nil when it could not run.
182
+ def capture(argv)
183
+ Open3.popen2e(process_environment, *argv, unsetenv_others: true, pgroup: true) do |stdin, output, waiter|
184
+ stdin.close
185
+ reader = Thread.new { output.read }
186
+ unless waiter.join(CHECK_TIMEOUT)
187
+ Process.kill("KILL", -waiter.pid)
188
+ return [ "timed out after #{CHECK_TIMEOUT}s", nil ]
189
+ end
190
+ [ reader.value, waiter.value ]
191
+ end
192
+ rescue SystemCallError => e
193
+ [ e.message, nil ]
194
+ end
195
+
196
+ def run!(io, argv, chdir:)
197
+ io.puts("$ #{argv.join(' ')}")
198
+ Open3.popen2e(process_environment, *argv, chdir: chdir.to_s, unsetenv_others: true, pgroup: true) do |stdin, output, waiter|
199
+ stdin.close
200
+ reader = Thread.new { output.each_line { |line| io.write(line) } }
201
+ unless waiter.join(INSTALL_TIMEOUT)
202
+ Process.kill("KILL", -waiter.pid)
203
+ raise Error, "#{argv.first} did not finish within #{INSTALL_TIMEOUT}s"
204
+ end
205
+ reader.join
206
+ raise Error, "#{argv.join(' ')} failed (#{waiter.value.exitstatus || waiter.value.termsig})" unless waiter.value.success?
207
+ end
208
+ rescue SystemCallError => e
209
+ raise Error, "Could not run #{argv.first}: #{e.message}"
210
+ end
211
+ end
212
+ end
213
+ end
@@ -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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Keep Codex's native JSONL transcript while adapting its terminal event
5
+ # to CodeSession's shared result contract. A process exit alone is never
6
+ # enough to mark a coding session successful.
7
+ class CodexSessionEvents
8
+ def consume(event)
9
+ return unless event.is_a?(Hash)
10
+
11
+ case event["type"]
12
+ when "thread.started"
13
+ @thread_id = event["thread_id"]
14
+ when "item.completed"
15
+ item = event["item"]
16
+ @answer = item["text"] if item.is_a?(Hash) && item["type"] == "agent_message"
17
+ when "turn.completed"
18
+ return result(false, @answer, event["usage"])
19
+ when "turn.failed", "error"
20
+ error = event["error"]
21
+ message = error.is_a?(Hash) ? error["message"] : event["message"]
22
+ return result(true, message.presence || "Codex reported a failed turn", {})
23
+ end
24
+ nil
25
+ end
26
+
27
+ private
28
+
29
+ def result(failed, answer, usage)
30
+ {
31
+ "type" => "result", "subtype" => failed ? "error_during_execution" : "success",
32
+ "is_error" => failed, "result" => answer, "session_id" => @thread_id,
33
+ "usage" => usage.is_a?(Hash) ? usage.slice("input_tokens", "output_tokens") : {}
34
+ }
35
+ end
36
+ end
37
+ end