actionagent 1.8.1 → 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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +152 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +96 -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
|
-
#
|
|
18
|
-
#
|
|
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
|
-
|
|
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:
|
|
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"),
|
|
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] ||=
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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)
|