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.
- 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 +71 -12
- 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 +58 -7
- 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 +148 -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 +627 -20
- 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 +39 -10
- 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 +72 -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 +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- 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/model_pricing.rb +214 -34
- 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 +207 -4
- 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 +300 -16
- 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/evaluation_serializer.rb +41 -10
- 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_scorecard.rb +50 -21
- 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/codex_session_events.rb +37 -0
- 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_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- 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 +1263 -102
- 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 +290 -23
- 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 +121 -17
- 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 +153 -2
- 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 +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -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 +100 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "io/wait"
|
|
4
5
|
require "net/http"
|
|
5
6
|
require "open3"
|
|
@@ -21,10 +22,12 @@ module ActionAgent
|
|
|
21
22
|
# state.json { pid, port, started_at, code_sessions: { "<id>" => pid } },
|
|
22
23
|
# plus the commit checked out, the boot step running
|
|
23
24
|
# (step_pid), when each recorded process started, cancels
|
|
24
|
-
# sent and cancels that found nothing to stop yet,
|
|
25
|
-
# whether a terminate is under way
|
|
25
|
+
# sent and cancels that found nothing to stop yet,
|
|
26
|
+
# whether a terminate is under way, and the boot's steps
|
|
27
|
+
# and how each went (boot; see #boot_status)
|
|
26
28
|
# state.lock what changes to state.json are serialized on
|
|
27
|
-
# logs/ checkout, setup, manifest, server and claude-<id> logs
|
|
29
|
+
# logs/ checkout, setup, manifest, server and claude-<id> logs,
|
|
30
|
+
# and with a boot spec preflight and one per spec step
|
|
28
31
|
# claude/ CLAUDE_CONFIG_DIR for Claude Code sessions (with
|
|
29
32
|
# ActionAgent.claude_code_auth = :api_key; with
|
|
30
33
|
# :local_login they use the user's own configuration)
|
|
@@ -36,7 +39,8 @@ module ActionAgent
|
|
|
36
39
|
# it is only ever changed under an exclusive lock, and replaced whole (see
|
|
37
40
|
# #update_state) so a crash never leaves it half written.
|
|
38
41
|
#
|
|
39
|
-
# How a checkout boots is up to its .activeagents/sandbox.yml (see Config)
|
|
42
|
+
# How a checkout boots is up to its .activeagents/sandbox.yml (see Config),
|
|
43
|
+
# or to the boot spec the engine hands create_sandbox (see SandboxBootSpec).
|
|
40
44
|
# Every process starts from a sanitized copy of the dashboard's environment
|
|
41
45
|
# (see .sanitized_environment), so a checkout never sees the dashboard's
|
|
42
46
|
# database or secrets. The GitHub token reaches only the fetch, and the
|
|
@@ -44,6 +48,7 @@ module ActionAgent
|
|
|
44
48
|
# ActionAgent.claude_code_auth = :local_login no credential is passed at
|
|
45
49
|
# all: Claude Code runs on the machine's own login.
|
|
46
50
|
class LocalSandboxBackend
|
|
51
|
+
include LocalSandboxClaudeLogin
|
|
47
52
|
class Error < RuntimeError; end
|
|
48
53
|
|
|
49
54
|
HANDLE_PREFIX = "local-"
|
|
@@ -65,6 +70,19 @@ module ActionAgent
|
|
|
65
70
|
STOP_GRACE = 10
|
|
66
71
|
LOG_TAIL_LINES = 20
|
|
67
72
|
LOG_TAIL_BYTES = 64 * 1024
|
|
73
|
+
# A boot step's output is scrubbed a line at a time on its way to the
|
|
74
|
+
# log; a longer line is cut into pieces of this size.
|
|
75
|
+
LOG_LINE_BYTES = 64 * 1024
|
|
76
|
+
# #boot_log pages.
|
|
77
|
+
LOG_PAGE_BYTES = 64 * 1024
|
|
78
|
+
MAX_LOG_PAGE_BYTES = 1024 * 1024
|
|
79
|
+
LOG_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
|
|
80
|
+
# Listing the checkout's Rake tasks for a step's if_task.
|
|
81
|
+
TASK_LIST_TIMEOUT = 120
|
|
82
|
+
TASK_LIST_BYTES = 1024 * 1024
|
|
83
|
+
# How long one GET of a boot spec's start_url may take: a development
|
|
84
|
+
# server compiles on its first request.
|
|
85
|
+
START_URL_READ_TIMEOUT = 60
|
|
68
86
|
|
|
69
87
|
# Bounds on the git commands run in a checkout (rev-parse after the
|
|
70
88
|
# fetch, add and diff after a session) and on the diff a Claude Code
|
|
@@ -72,6 +90,22 @@ module ActionAgent
|
|
|
72
90
|
# truncation notice still shows.
|
|
73
91
|
GIT_TIMEOUT = 60
|
|
74
92
|
MAX_DIFF_BYTES = 1_000_000
|
|
93
|
+
# Bounds on what #changed_files lists and #read_file returns.
|
|
94
|
+
MAX_LISTING_BYTES = 4 * 1024 * 1024
|
|
95
|
+
MAX_CHANGED_FILES = 5_000
|
|
96
|
+
MAX_READ_BYTES = 1024 * 1024
|
|
97
|
+
# The verified trees one backend keeps, by object id (see #tree_entries).
|
|
98
|
+
MAX_CACHED_TREES = 256
|
|
99
|
+
# The environment of the git commands that read a checkout's changes.
|
|
100
|
+
# They take no lock on the index, read every path as a literal name
|
|
101
|
+
# rather than a pathspec, and ignore replace refs, which the checkout
|
|
102
|
+
# could have added to make its commit read as something else.
|
|
103
|
+
READ_ONLY_GIT_ENVIRONMENT = {
|
|
104
|
+
"GIT_OPTIONAL_LOCKS" => "0",
|
|
105
|
+
"GIT_LITERAL_PATHSPECS" => "1",
|
|
106
|
+
"GIT_NO_REPLACE_OBJECTS" => "1",
|
|
107
|
+
"GIT_TERMINAL_PROMPT" => "0"
|
|
108
|
+
}.freeze
|
|
75
109
|
# How long a cancel that found no Claude Code process is remembered. The
|
|
76
110
|
# session it names starts within seconds of being marked running, or
|
|
77
111
|
# never; this only keeps old ones from piling up in state.json.
|
|
@@ -141,7 +175,7 @@ module ActionAgent
|
|
|
141
175
|
# ANTHROPIC_BASE_URL; a session inheriting those joins the developer's
|
|
142
176
|
# session, and a base URL redirects the owner's credential. A session
|
|
143
177
|
# gets exactly the Claude Code variables the backend sets.
|
|
144
|
-
\A(?:ANTHROPIC|CLAUDE|OPENAI|OPEN_AI|OPENROUTER|OPEN_ROUTER|OLLAMA)(?:_|\z) | \ACLAUDECODE\z
|
|
178
|
+
\A(?:ANTHROPIC|CLAUDE|CODEX|OPENAI|OPEN_AI|OPENROUTER|OPEN_ROUTER|OLLAMA)(?:_|\z) | \ACLAUDECODE\z
|
|
145
179
|
/x
|
|
146
180
|
SECRET_VARIABLE = /
|
|
147
181
|
SECRET | TOKEN | PASSWORD | PASSWD | PASSPHRASE | API_KEY | APIKEY | PRIVATE_KEY | CREDENTIAL | ACCESS_KEY |
|
|
@@ -255,6 +289,21 @@ module ActionAgent
|
|
|
255
289
|
end
|
|
256
290
|
end
|
|
257
291
|
|
|
292
|
+
# What a boot runs once the checkout is there: the steps of the
|
|
293
|
+
# checkout's sandbox.yml (mode "config") or of a boot spec (mode "spec"),
|
|
294
|
+
# then manifest, then start. +label+ is what a failure message calls the
|
|
295
|
+
# step, +log+ the file under logs/ its output goes to, and +skip+ why it
|
|
296
|
+
# will not run, when that is known before the boot starts.
|
|
297
|
+
BootStep = Struct.new(:name, :command, :timeout, :log, :label, :if_task, :skip, keyword_init: true)
|
|
298
|
+
BootPlan = Struct.new(:mode, :spec, :steps, :manifest, :start, :env, :start_url, :keep_on_failure, :offset,
|
|
299
|
+
keyword_init: true) do
|
|
300
|
+
def entries
|
|
301
|
+
[ *steps, manifest, start ]
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
# What a step's block returns when the step had nothing to do.
|
|
305
|
+
Skip = Struct.new(:reason)
|
|
306
|
+
|
|
258
307
|
class << self
|
|
259
308
|
# The environment every sandbox process starts from: the dashboard's
|
|
260
309
|
# own, as it was before Bundler set it up, minus the dashboard's
|
|
@@ -362,16 +411,18 @@ module ActionAgent
|
|
|
362
411
|
end
|
|
363
412
|
end
|
|
364
413
|
|
|
365
|
-
# Clones the session's checkout, boots it as sandbox.yml says
|
|
366
|
-
# until its MCP
|
|
414
|
+
# Clones the session's checkout, boots it as sandbox.yml says (or as
|
|
415
|
+
# +boot_config+ says, see SandboxBootSpec), and waits until its MCP
|
|
416
|
+
# facade answers.
|
|
367
417
|
#
|
|
368
418
|
# @return [Hash] the handle (container_name), url, mcp_url and mcp_token
|
|
369
|
-
def create_sandbox(session, instance_tier: nil)
|
|
419
|
+
def create_sandbox(session, instance_tier: nil, boot_config: nil)
|
|
370
420
|
ensure_enabled!
|
|
371
421
|
unless session.app_runtime?
|
|
372
422
|
raise Error, "The local sandbox backend only boots app_runtime checkouts, not #{session.sandbox_type} sandboxes"
|
|
373
423
|
end
|
|
374
424
|
|
|
425
|
+
boot_spec = boot_spec!(boot_config)
|
|
375
426
|
spec = session.checkout_spec
|
|
376
427
|
raise Error, "Sandbox #{session.session_id} has no checkout to boot (is GitHub still connected?)" if spec.blank?
|
|
377
428
|
|
|
@@ -382,7 +433,105 @@ module ActionAgent
|
|
|
382
433
|
raise Error, "Sandbox #{session_id} still has processes from an earlier boot that could not be stopped; " \
|
|
383
434
|
"see the dashboard log"
|
|
384
435
|
end
|
|
385
|
-
boot(session_id, spec, [ spec[:token], *session.runtime_environment.values ])
|
|
436
|
+
boot(session_id, spec, [ spec[:token], *session.runtime_environment.values, *boot_spec&.secret_values ], boot_spec)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Continues a boot that failed and kept its workspace (a boot spec with
|
|
440
|
+
# keep_on_failure): re-runs it from the step named +from+, or from the
|
|
441
|
+
# step that failed when +from+ is nil, on the same checkout and
|
|
442
|
+
# databases. Nothing is cloned again and no earlier step runs again.
|
|
443
|
+
# +boot_config+ replaces the spec the boot started with, for new env or
|
|
444
|
+
# secrets; without one the recorded spec is used, which holds no secret
|
|
445
|
+
# values, so a boot whose spec had secrets needs it passed again.
|
|
446
|
+
#
|
|
447
|
+
# @return [Hash] what #create_sandbox returns
|
|
448
|
+
def resume_boot(session, from:, boot_config: nil)
|
|
449
|
+
ensure_enabled!
|
|
450
|
+
session_id = session_id!(session.session_id)
|
|
451
|
+
workspace = workspace_for(session_id)
|
|
452
|
+
boot_state = read_state(workspace)["boot"]
|
|
453
|
+
unless boot_state.is_a?(Hash) && boot_state["kept"] == true
|
|
454
|
+
raise Error, "Sandbox #{session_id} has no failed boot kept to resume: start it again"
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
spec = boot_spec!(boot_config) || boot_spec!(boot_state["spec"])
|
|
458
|
+
raise Error, "Sandbox #{session_id} was not booted from a boot spec, so it cannot be resumed" if spec.nil?
|
|
459
|
+
if spec.missing_secrets.any?
|
|
460
|
+
raise Error, "Resuming sandbox #{session_id} needs its boot spec again: the values of " \
|
|
461
|
+
"#{spec.missing_secrets.join(", ")} are never kept"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
plan = spec_plan(spec, Array(boot_state["locked_gems"]).to_set)
|
|
465
|
+
start_at = resume_position(plan, from.presence || boot_state["failed_step"])
|
|
466
|
+
unless claim_kept_boot(workspace)
|
|
467
|
+
raise Error, "Sandbox #{session_id} has no failed boot kept to resume: it is being resumed, or was stopped"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
checkout = begin
|
|
471
|
+
session.checkout_spec
|
|
472
|
+
rescue StandardError
|
|
473
|
+
nil
|
|
474
|
+
end
|
|
475
|
+
secrets = [ checkout&.dig(:token), *session.runtime_environment.values, *spec.secret_values ]
|
|
476
|
+
resume(workspace, plan, start_at, secrets)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# How the sandbox's boot went, step by step (see
|
|
480
|
+
# SandboxOrchestrator#boot_status); nil when it has no workspace.
|
|
481
|
+
def boot_status(session)
|
|
482
|
+
session_id = session_id!(session.session_id)
|
|
483
|
+
boot_state = read_state(workspace_for(session_id))["boot"]
|
|
484
|
+
return nil unless boot_state.is_a?(Hash) && boot_state["steps"].is_a?(Array)
|
|
485
|
+
|
|
486
|
+
secrets = session_secrets(session)
|
|
487
|
+
{
|
|
488
|
+
mode: boot_state["mode"],
|
|
489
|
+
kind: boot_state["kind"],
|
|
490
|
+
failed_step: boot_state["failed_step"],
|
|
491
|
+
kept: boot_state["kept"] == true,
|
|
492
|
+
resumable_steps: resumable_step_names(boot_state),
|
|
493
|
+
steps: boot_state["steps"].filter_map do |step|
|
|
494
|
+
next unless step.is_a?(Hash)
|
|
495
|
+
|
|
496
|
+
{
|
|
497
|
+
name: step["name"], status: step["status"], started_at: step["started_at"], finished_at: step["finished_at"],
|
|
498
|
+
duration_ms: step["duration_ms"] || running_for(step["started_at"]),
|
|
499
|
+
detail: step["detail"] && SecretScrubber.scrub(step["detail"].to_s, secrets)
|
|
500
|
+
}
|
|
501
|
+
end
|
|
502
|
+
}
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# One page of a boot step's log (see SandboxOrchestrator#boot_log).
|
|
506
|
+
def boot_log(session, step:, offset: 0, limit: LOG_PAGE_BYTES, secrets: [])
|
|
507
|
+
session_id = session_id!(session.session_id)
|
|
508
|
+
workspace = workspace_for(session_id)
|
|
509
|
+
boot_state = read_state(workspace)["boot"]
|
|
510
|
+
steps = boot_state.is_a?(Hash) && boot_state["steps"].is_a?(Array) ? boot_state["steps"] : []
|
|
511
|
+
entry = steps.find { |candidate| candidate.is_a?(Hash) && candidate["name"] == step.to_s }
|
|
512
|
+
return nil unless entry && LOG_NAME.match?(entry["log"].to_s)
|
|
513
|
+
|
|
514
|
+
path = log_path(workspace, entry["log"])
|
|
515
|
+
return nil unless path.file?
|
|
516
|
+
|
|
517
|
+
size = path.size
|
|
518
|
+
offset = Integer(offset, exception: false).to_i.clamp(0, size)
|
|
519
|
+
limit = Integer(limit, exception: false).to_i.clamp(1, MAX_LOG_PAGE_BYTES)
|
|
520
|
+
data = File.open(path, "rb") do |file|
|
|
521
|
+
file.seek(offset)
|
|
522
|
+
file.read(limit).to_s
|
|
523
|
+
end
|
|
524
|
+
# Ends on a line break when the page stops short of the end, so a
|
|
525
|
+
# value the scrubber masks is never split across two pages.
|
|
526
|
+
if offset + data.bytesize < size && (newline = data.rindex("\n"))
|
|
527
|
+
data = data.byteslice(0, newline + 1)
|
|
528
|
+
end
|
|
529
|
+
next_offset = offset + data.bytesize
|
|
530
|
+
|
|
531
|
+
{
|
|
532
|
+
step: entry["name"], offset: offset, next_offset: next_offset, size: size, eof: next_offset >= size,
|
|
533
|
+
text: SecretScrubber.scrub(data.force_encoding(Encoding::UTF_8).scrub, session_secrets(session) + Array(secrets))
|
|
534
|
+
}
|
|
386
535
|
end
|
|
387
536
|
|
|
388
537
|
# A sandbox's handle follows from its session id, so one whose boot was
|
|
@@ -436,7 +585,50 @@ module ActionAgent
|
|
|
436
585
|
0
|
|
437
586
|
end
|
|
438
587
|
|
|
439
|
-
|
|
588
|
+
def code_runners
|
|
589
|
+
%w[claude_code codex]
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Restart only the app runtime; keep the checkout, databases and login.
|
|
593
|
+
# The fresh manifest and readiness probe prove the changed source has
|
|
594
|
+
# loaded before the dashboard queues verification. The restart gets the
|
|
595
|
+
# time a boot gets. If the app does not come back, the sandbox is failed
|
|
596
|
+
# rather than left ready with nothing listening.
|
|
597
|
+
def refresh_runtime(sandbox)
|
|
598
|
+
stopped = false
|
|
599
|
+
workspace, app = checkout_workspace!(sandbox)
|
|
600
|
+
state = read_state(workspace)
|
|
601
|
+
raise Error, "The sandbox is stopping" if state["terminating"]
|
|
602
|
+
spec = sandbox.project&.boot_spec(sandbox)
|
|
603
|
+
plan = if state.dig("boot", "mode") == "spec" && spec
|
|
604
|
+
spec_plan(spec, Array(state.dig("boot", "locked_gems")).to_set)
|
|
605
|
+
else
|
|
606
|
+
config_plan(Config.load(app), secrets: spec&.step_environment || {})
|
|
607
|
+
end
|
|
608
|
+
env = boot_environment(workspace, (state["database_env"] || {}).transform_values(&:to_s), plan)
|
|
609
|
+
port = state.fetch("port")
|
|
610
|
+
env["PORT"] = port.to_s
|
|
611
|
+
secrets = sandbox_secrets(sandbox, {})
|
|
612
|
+
reset_boot(ActionAgent.local_sandbox_boot_timeout)
|
|
613
|
+
deadline = deadline_after(@boot_timeout)
|
|
614
|
+
pid = state["pid"]
|
|
615
|
+
stop_groups([ pid ]) if pid && group_identity(pid, sandbox.session_id, state) == :ours
|
|
616
|
+
stopped = true
|
|
617
|
+
manifest = run_manifest!(workspace, plan.manifest, env, deadline, secrets)
|
|
618
|
+
server_pid, waiter = start_server(workspace, plan.start.command, env, port)
|
|
619
|
+
wait_until_ready!(workspace, port, manifest, server_pid, waiter, deadline, secrets, step: plan.start, boot_deadline: deadline)
|
|
620
|
+
sandbox.update!(runtime_mcp_url: "http://127.0.0.1:#{port}#{manifest['mcp_path']}", runtime_mcp_token: manifest["mcp_token"])
|
|
621
|
+
true
|
|
622
|
+
rescue StandardError => e
|
|
623
|
+
stop_groups([ server_pid ]) if server_pid
|
|
624
|
+
if stopped
|
|
625
|
+
message = SecretScrubber.scrub("The app did not restart after the change: #{e.message}", secrets || [])
|
|
626
|
+
sandbox.update!(status: :failed, error_message: message.truncate(1000))
|
|
627
|
+
end
|
|
628
|
+
raise
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Runs a coding agent headless in the sandbox's checkout, yielding each
|
|
440
632
|
# stream-json event (a Hash, already scrubbed of the sandbox's secrets)
|
|
441
633
|
# as it arrives.
|
|
442
634
|
#
|
|
@@ -448,9 +640,18 @@ module ActionAgent
|
|
|
448
640
|
app = workspace.join("app")
|
|
449
641
|
raise Error, "Sandbox #{session_id} has no local checkout: start the sandbox again" unless app.directory?
|
|
450
642
|
|
|
451
|
-
|
|
643
|
+
runner = code_session.try(:runner) || "claude_code"
|
|
644
|
+
raise Error, "Unsupported code runner: #{runner}" unless code_runners.include?(runner)
|
|
645
|
+
|
|
646
|
+
subscription = runner == "claude_code" && code_session.try(:credential_mode) == "sandbox_login"
|
|
647
|
+
if subscription
|
|
648
|
+
unless sandbox.claude_login_user_id.present? && sandbox.claude_login_user_id == code_session.user_id && claude_login_status(sandbox)[:logged_in]
|
|
649
|
+
raise Error, "Sign in to this sandbox with your own Claude subscription before starting a session"
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
credentials = subscription ? {} : session_credentials(sandbox, runner: runner)
|
|
452
653
|
secrets = sandbox_secrets(sandbox, credentials)
|
|
453
|
-
argv = claude_argv(code_session)
|
|
654
|
+
argv = runner == "codex" ? codex_argv(code_session) : claude_argv(code_session)
|
|
454
655
|
database_env = read_state(workspace)["database_env"]
|
|
455
656
|
env = self.class.sanitized_environment
|
|
456
657
|
.merge(database_env.is_a?(Hash) ? database_env.transform_values(&:to_s) : {})
|
|
@@ -466,12 +667,20 @@ module ActionAgent
|
|
|
466
667
|
# own, in the workspace. With the machine's own login it must use the
|
|
467
668
|
# user's: that is where `claude /login` left the credentials (HOME,
|
|
468
669
|
# which the sanitized environment keeps, or the keychain).
|
|
469
|
-
|
|
470
|
-
env
|
|
471
|
-
|
|
670
|
+
if subscription
|
|
671
|
+
env = env.merge(sandbox_login_environment(workspace))
|
|
672
|
+
forbidden = env.keys.grep(/\A(?:ANTHROPIC_|CLAUDE_CODE_OAUTH_TOKEN\z)/)
|
|
673
|
+
raise Error, "Subscription sessions cannot contain Anthropic credential environment variables" if forbidden.any?
|
|
674
|
+
elsif runner == "codex"
|
|
675
|
+
env["CODEX_HOME"] = workspace.join("codex").to_s
|
|
676
|
+
FileUtils.mkdir_p(workspace.join("codex"), mode: 0o700)
|
|
677
|
+
elsif !ClaudeCodeAuth.local_login?
|
|
678
|
+
config = workspace.join(ClaudeCodeAuth.sandbox_login? ? "claude-api" : "claude")
|
|
679
|
+
env["CLAUDE_CONFIG_DIR"] = config.to_s
|
|
680
|
+
FileUtils.mkdir_p(config, mode: 0o700)
|
|
472
681
|
end
|
|
473
682
|
|
|
474
|
-
run_claude(workspace, code_session, argv, env, secrets, &on_event)
|
|
683
|
+
run_claude(workspace, code_session, argv, env, secrets, runner: runner, &on_event)
|
|
475
684
|
end
|
|
476
685
|
|
|
477
686
|
# Stops a running Claude Code session: SIGTERM to its process group. The
|
|
@@ -512,8 +721,177 @@ module ActionAgent
|
|
|
512
721
|
true
|
|
513
722
|
end
|
|
514
723
|
|
|
724
|
+
# The paths the sandbox's checkout changed since it was fetched: what
|
|
725
|
+
# `git diff` reports against the commit checked out, and the untracked
|
|
726
|
+
# files the repository does not ignore. Git runs as #capture_diff runs
|
|
727
|
+
# it, with no credential in its environment, and not at all when the
|
|
728
|
+
# checkout's git config defines filter drivers. It does not look inside
|
|
729
|
+
# submodules, whose own configuration could define filters, so a
|
|
730
|
+
# submodule is listed only when the commit it points at changed. Each
|
|
731
|
+
# path's mode and size come from lstat of the working tree, so a symlink
|
|
732
|
+
# is reported as one (120000) and a nested repository as a submodule
|
|
733
|
+
# (160000). Sockets, pipes and devices, which git does not track, are
|
|
734
|
+
# left out.
|
|
735
|
+
#
|
|
736
|
+
# @return [Hash] { base_commit:, files: [{ path:, status:, mode:, base_mode:, size: }] },
|
|
737
|
+
# as SandboxOrchestrator#changed_files describes
|
|
738
|
+
# @raise [Error] when the checkout is gone, recorded no commit, defines
|
|
739
|
+
# filter drivers, or changed more than MAX_CHANGED_FILES paths
|
|
740
|
+
def changed_files(sandbox)
|
|
741
|
+
workspace, app = checkout_workspace!(sandbox)
|
|
742
|
+
base = recorded_checkout_commit!(workspace)
|
|
743
|
+
env = read_only_git_environment
|
|
744
|
+
git = git_command(app)
|
|
745
|
+
if filter_drivers?(app, env, git)
|
|
746
|
+
raise Error, "The checkout's git config defines filter drivers, which would run commands, so its changes are not read"
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
diff = git_listing!(env, [ *git, "diff", "--raw", "-z", "--no-renames", "--no-abbrev", "--no-ext-diff", "--no-textconv",
|
|
750
|
+
"--ignore-submodules=dirty", base, "--" ], app)
|
|
751
|
+
untracked = git_listing!(env, [ *git, "ls-files", "-z", "--others", "--exclude-standard" ], app)
|
|
752
|
+
|
|
753
|
+
# ":<base mode> <mode> <base object> <object> <status>" per path.
|
|
754
|
+
base_modes = {}
|
|
755
|
+
listed = []
|
|
756
|
+
diff.each_slice(2) do |header, path|
|
|
757
|
+
next if path.nil?
|
|
758
|
+
|
|
759
|
+
fields = header.split(" ")
|
|
760
|
+
base_modes[path] = fields.first.delete_prefix(":") unless fields.last == "A"
|
|
761
|
+
listed << path
|
|
762
|
+
end
|
|
763
|
+
# A nested repository is listed as its directory, with a trailing slash.
|
|
764
|
+
listed.concat(untracked.map { |path| path.delete_suffix("/") })
|
|
765
|
+
listed.uniq!
|
|
766
|
+
raise Error, "The checkout changed more than #{MAX_CHANGED_FILES} files" if listed.size > MAX_CHANGED_FILES
|
|
767
|
+
|
|
768
|
+
files = listed.filter_map do |path|
|
|
769
|
+
next if SandboxCredentialPaths.protected?(path)
|
|
770
|
+
next unless checkout_path?(path)
|
|
771
|
+
|
|
772
|
+
base_mode = base_modes[path]
|
|
773
|
+
stat = working_tree_stat(app, path)
|
|
774
|
+
next { path: path, status: "deleted", mode: nil, base_mode: base_mode, size: nil } if stat.nil? && base_mode
|
|
775
|
+
next if stat.nil?
|
|
776
|
+
|
|
777
|
+
mode = mode_for(stat) or next
|
|
778
|
+
{
|
|
779
|
+
path: path, status: base_mode ? "modified" : "added", mode: mode, base_mode: base_mode,
|
|
780
|
+
size: stat.directory? ? nil : stat.size
|
|
781
|
+
}
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
{ base_commit: base, files: files.sort_by { |file| file[:path] } }
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# +path+'s content in the sandbox's checkout, or with +base+ in the
|
|
788
|
+
# commit the checkout was fetched at. Nil when nothing is there.
|
|
789
|
+
#
|
|
790
|
+
# The working tree is read with lstat, one path component at a time, and
|
|
791
|
+
# never through a symlink: a symlink reads as its target path, and a path
|
|
792
|
+
# whose parent is a symlink or a file reads as nothing. The commit is
|
|
793
|
+
# read one object at a time with `git cat-file`, which runs no filters,
|
|
794
|
+
# and each object must hash to its id (see #read_committed_file).
|
|
795
|
+
#
|
|
796
|
+
# @raise [Error] for a directory, a submodule, a special file, more than
|
|
797
|
+
# MAX_READ_BYTES, or an object of the commit that does not hash to its
|
|
798
|
+
# id
|
|
799
|
+
# @return [String, nil] binary-encoded bytes
|
|
800
|
+
def read_file(sandbox, path, base: false)
|
|
801
|
+
raise Error, "Claude configuration is never read through the checkout API" if SandboxCredentialPaths.protected?(path)
|
|
802
|
+
workspace, app = checkout_workspace!(sandbox)
|
|
803
|
+
raise Error, "#{path.inspect} is not a path inside the checkout" unless checkout_path?(path)
|
|
804
|
+
|
|
805
|
+
base ? read_committed_file(workspace, app, path) : read_working_file(app, path)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# The modes #start_browser runs a browser in on this machine: headless
|
|
809
|
+
# always, and headed where there is a display to show a window on.
|
|
810
|
+
#
|
|
811
|
+
# @return [Array<Symbol>]
|
|
812
|
+
def browser_modes
|
|
813
|
+
display = RUBY_PLATFORM.include?("darwin") || ENV["DISPLAY"].present? || ENV["WAYLAND_DISPLAY"].present?
|
|
814
|
+
display ? %i[headless headed] : %i[headless]
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# Starts the browser sidecar (BrowserSidecar) for +sandbox+, pointed at
|
|
818
|
+
# its app as sandbox.browser_launch says, and waits until it listens. It
|
|
819
|
+
# runs in a process group of its own, recorded in state.json beside the
|
|
820
|
+
# app's, so terminate stops it with the sandbox. Its configuration,
|
|
821
|
+
# tokens included, goes in on stdin. Any browser the sandbox already had
|
|
822
|
+
# is stopped first.
|
|
823
|
+
#
|
|
824
|
+
# @return [Hash] { mcp_url:, mcp_token: }, and live_url: when the launch
|
|
825
|
+
# asked for a live view
|
|
826
|
+
def start_browser(sandbox, mode: :headless)
|
|
827
|
+
ensure_enabled!
|
|
828
|
+
session_id = session_id!(sandbox.session_id)
|
|
829
|
+
workspace = workspace_for(session_id)
|
|
830
|
+
raise Error, "Sandbox #{session_id} has no local checkout: start the sandbox again" unless workspace.join("app").directory?
|
|
831
|
+
unless browser_modes.include?(mode.to_sym)
|
|
832
|
+
raise Error, "This machine has no display to show a browser window on; start the browser headless"
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
launch = sandbox.browser_launch
|
|
836
|
+
raise Error, "Sandbox #{session_id} was given no browser settings" unless launch.is_a?(Hash) && launch[:token].present?
|
|
837
|
+
|
|
838
|
+
refusal = BrowserSidecar.refusal
|
|
839
|
+
raise Error, refusal if refusal
|
|
840
|
+
raise Error, "The sandbox's earlier browser could not be stopped" unless stop_browser(sandbox)
|
|
841
|
+
|
|
842
|
+
run_browser(workspace, session_id, mode.to_s, launch)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Stops +sandbox+'s browser sidecar and removes its directory. True, also
|
|
846
|
+
# when none was running; false when its process group is still alive and
|
|
847
|
+
# could not be stopped, or not told apart from an unrelated process, in
|
|
848
|
+
# which case state.json keeps it for terminate.
|
|
849
|
+
def stop_browser(sandbox)
|
|
850
|
+
session_id = session_id!(sandbox.session_id)
|
|
851
|
+
workspace = workspace_for(session_id)
|
|
852
|
+
state = read_state(workspace)
|
|
853
|
+
browser = state["browser"]
|
|
854
|
+
return true unless browser.is_a?(Hash)
|
|
855
|
+
|
|
856
|
+
pid = browser["pid"]
|
|
857
|
+
identity = signalable?(pid) ? group_identity(pid, session_id, state) : :stranger
|
|
858
|
+
stop_groups([ pid ]) if identity == :ours
|
|
859
|
+
return false if identity != :stranger && group_alive?(pid)
|
|
860
|
+
|
|
861
|
+
forget_browser(workspace, browser)
|
|
862
|
+
true
|
|
863
|
+
end
|
|
864
|
+
|
|
515
865
|
private
|
|
516
866
|
|
|
867
|
+
def boot_spec!(value)
|
|
868
|
+
SandboxBootSpec.wrap(value)
|
|
869
|
+
rescue SandboxBootSpec::Invalid => e
|
|
870
|
+
raise Error, "Sandbox boot spec is invalid: #{e.message}"
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# The secrets a sandbox's stored output is scrubbed of when it is read
|
|
874
|
+
# back: its checkout token, the credentials its sessions get, and the
|
|
875
|
+
# secrets of the project it was booted for.
|
|
876
|
+
def session_secrets(session)
|
|
877
|
+
token = begin
|
|
878
|
+
session.checkout_spec&.dig(:token)
|
|
879
|
+
rescue StandardError
|
|
880
|
+
nil
|
|
881
|
+
end
|
|
882
|
+
[ token, *session.runtime_environment.values, *Array(session.try(:project_scrub_values)) ].compact.map(&:to_s)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# Milliseconds since +started_at+ (an ISO 8601 time), for a step still
|
|
886
|
+
# running; nil without one.
|
|
887
|
+
def running_for(started_at)
|
|
888
|
+
return nil unless started_at.is_a?(String)
|
|
889
|
+
|
|
890
|
+
((Time.current - Time.iso8601(started_at)) * 1000).round
|
|
891
|
+
rescue ArgumentError
|
|
892
|
+
nil
|
|
893
|
+
end
|
|
894
|
+
|
|
517
895
|
def ensure_enabled!
|
|
518
896
|
return if ActionAgent.local_sandboxes_enabled?
|
|
519
897
|
|
|
@@ -521,28 +899,314 @@ module ActionAgent
|
|
|
521
899
|
"set ActionAgent.local_sandboxes_enabled = true to allow them"
|
|
522
900
|
end
|
|
523
901
|
|
|
902
|
+
# --- Reading changes ----------------------------------------------------
|
|
903
|
+
|
|
904
|
+
# The workspace and checkout of +sandbox+, which must exist.
|
|
905
|
+
def checkout_workspace!(sandbox)
|
|
906
|
+
ensure_enabled!
|
|
907
|
+
workspace = workspace_for(session_id!(sandbox.session_id))
|
|
908
|
+
app = workspace.join("app")
|
|
909
|
+
raise Error, "Sandbox #{sandbox.session_id} has no local checkout" unless app.directory?
|
|
910
|
+
|
|
911
|
+
[ workspace, app ]
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def recorded_checkout_commit!(workspace)
|
|
915
|
+
commit = read_state(workspace)["checkout_commit"]
|
|
916
|
+
return commit if commit.is_a?(String) && COMMIT_ID.match?(commit)
|
|
917
|
+
|
|
918
|
+
raise Error, "The sandbox did not record the commit it checked out"
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def read_only_git_environment
|
|
922
|
+
self.class.sanitized_environment.merge(READ_ONLY_GIT_ENVIRONMENT)
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# A filter driver in the checkout's git config (which a session could
|
|
926
|
+
# have written) runs its command whenever git reads the working tree, as
|
|
927
|
+
# the dashboard's user.
|
|
928
|
+
def filter_drivers?(app, env, git)
|
|
929
|
+
drivers, = capture(env, [ *git, "config", "--local", "--includes", "--name-only", "--get-regexp", "^filter\\." ],
|
|
930
|
+
chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
|
|
931
|
+
drivers.to_s.strip.present?
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
# The NUL-separated fields +argv+ prints.
|
|
935
|
+
def git_listing!(env, argv, app)
|
|
936
|
+
output, status = capture(env, argv, chdir: app, limit: MAX_LISTING_BYTES, timeout: GIT_TIMEOUT)
|
|
937
|
+
raise Error, "The checkout's changes are too many to list" if output.bytesize >= MAX_LISTING_BYTES
|
|
938
|
+
raise Error, "git could not list the checkout's changes (#{status ? describe(status) : 'timed out'})" unless status&.success?
|
|
939
|
+
|
|
940
|
+
output.force_encoding(Encoding::UTF_8).split("\0").reject(&:empty?)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# Relative, inside the checkout, and outside its .git directory.
|
|
944
|
+
def checkout_path?(path)
|
|
945
|
+
return false unless path.is_a?(String) && path.valid_encoding? && path.present? && !path.include?("\0")
|
|
946
|
+
return false if path.start_with?("/")
|
|
947
|
+
|
|
948
|
+
parts = path.split("/")
|
|
949
|
+
parts.none? { |part| part.empty? || part == "." || part == ".." } && !parts.first.casecmp?(".git")
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# lstat of +path+ under +app+, or nil when nothing is there, which
|
|
953
|
+
# includes a path one of whose parents is a symlink or a file.
|
|
954
|
+
def working_tree_stat(app, path)
|
|
955
|
+
*parents, name = path.split("/")
|
|
956
|
+
directory = app
|
|
957
|
+
parents.each do |part|
|
|
958
|
+
directory = directory.join(part)
|
|
959
|
+
return nil unless File.lstat(directory).directory?
|
|
960
|
+
end
|
|
961
|
+
File.lstat(directory.join(name))
|
|
962
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
963
|
+
nil
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# git's mode for what +stat+ describes, or nil for a socket, a pipe or a
|
|
967
|
+
# device.
|
|
968
|
+
def mode_for(stat)
|
|
969
|
+
if stat.symlink? then "120000"
|
|
970
|
+
elsif stat.directory? then "160000"
|
|
971
|
+
elsif stat.file? then stat.mode.anybits?(0o111) ? "100755" : "100644"
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def read_working_file(app, path)
|
|
976
|
+
stat = working_tree_stat(app, path)
|
|
977
|
+
return nil if stat.nil?
|
|
978
|
+
|
|
979
|
+
full = app.join(path)
|
|
980
|
+
return File.readlink(full).b if stat.symlink?
|
|
981
|
+
raise Error, "#{path} is a submodule or a directory, not a file" if stat.directory?
|
|
982
|
+
raise Error, "#{path} is not a regular file" unless stat.file?
|
|
983
|
+
raise Error, "#{path} is larger than #{MAX_READ_BYTES} bytes" if stat.size > MAX_READ_BYTES
|
|
984
|
+
|
|
985
|
+
File.open(full, File::RDONLY | File::NOFOLLOW) do |file|
|
|
986
|
+
# Whatever replaced the file since the lstat was not what was listed.
|
|
987
|
+
opened = file.stat
|
|
988
|
+
raise Error, "#{path} changed while it was read" unless opened.file? && opened.ino == stat.ino && opened.dev == stat.dev
|
|
989
|
+
raise Error, "#{path} is not inside the checkout" unless File.realpath(full).start_with?("#{File.realpath(app)}/")
|
|
990
|
+
|
|
991
|
+
content = file.read(MAX_READ_BYTES + 1) || String.new
|
|
992
|
+
raise Error, "#{path} is larger than #{MAX_READ_BYTES} bytes" if content.bytesize > MAX_READ_BYTES
|
|
993
|
+
|
|
994
|
+
content.b
|
|
995
|
+
end
|
|
996
|
+
rescue Errno::ELOOP
|
|
997
|
+
raise Error, "#{path} changed while it was read"
|
|
998
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
999
|
+
nil
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
# +path+ in the checkout commit, walked from the commit through each
|
|
1003
|
+
# tree to the blob. Git does not check that an object it reads hashes to
|
|
1004
|
+
# its id, and the checkout's object store is the sandbox's to rewrite, so
|
|
1005
|
+
# every object is checked here: the content is the commit's, not
|
|
1006
|
+
# whatever the sandbox wrote in its place.
|
|
1007
|
+
def read_committed_file(workspace, app, path)
|
|
1008
|
+
commit = recorded_checkout_commit!(workspace)
|
|
1009
|
+
commit_object = read_object(app, commit, "commit", limit: MAX_LISTING_BYTES)
|
|
1010
|
+
tree = commit_object[/\Atree (\h+)\n/, 1] or raise Error, "The checked-out commit #{commit} names no tree"
|
|
1011
|
+
|
|
1012
|
+
*parents, name = path.split("/")
|
|
1013
|
+
parents.each do |part|
|
|
1014
|
+
entry = tree_entries(app, tree)[part]
|
|
1015
|
+
# Not there, or a file or submodule where a directory would be.
|
|
1016
|
+
return nil unless entry && entry[:mode] == "40000"
|
|
1017
|
+
|
|
1018
|
+
tree = entry[:id]
|
|
1019
|
+
end
|
|
1020
|
+
entry = tree_entries(app, tree)[name]
|
|
1021
|
+
return nil if entry.nil?
|
|
1022
|
+
raise Error, "#{path} is a submodule in the checked-out commit" if entry[:mode] == "160000"
|
|
1023
|
+
raise Error, "#{path} is a directory in the checked-out commit" if entry[:mode] == "40000"
|
|
1024
|
+
|
|
1025
|
+
read_object(app, entry[:id], "blob", limit: MAX_READ_BYTES, label: path)
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# The entries of tree +id+, { name => { mode:, id: } }. Kept by id once
|
|
1029
|
+
# checked, since a checked object never changes.
|
|
1030
|
+
def tree_entries(app, id)
|
|
1031
|
+
@verified_trees ||= {}
|
|
1032
|
+
@verified_trees.fetch(id) do
|
|
1033
|
+
@verified_trees.clear if @verified_trees.size >= MAX_CACHED_TREES
|
|
1034
|
+
@verified_trees[id] = parse_tree(read_object(app, id, "tree", limit: MAX_LISTING_BYTES), id)
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
# A raw tree object: "<mode> <name>\0<id as bytes>" per entry.
|
|
1039
|
+
def parse_tree(raw, id)
|
|
1040
|
+
id_bytes = id.length / 2
|
|
1041
|
+
entries = {}
|
|
1042
|
+
offset = 0
|
|
1043
|
+
while offset < raw.bytesize
|
|
1044
|
+
space = raw.index(" ".b, offset)
|
|
1045
|
+
nul = space && raw.index("\0".b, space)
|
|
1046
|
+
raise Error, "The checkout's tree #{id} could not be read" unless nul && nul + 1 + id_bytes <= raw.bytesize
|
|
1047
|
+
|
|
1048
|
+
name = raw.byteslice(space + 1, nul - space - 1).force_encoding(Encoding::UTF_8)
|
|
1049
|
+
entries[name] = { mode: raw.byteslice(offset, space - offset), id: raw.byteslice(nul + 1, id_bytes).unpack1("H*") }
|
|
1050
|
+
offset = nul + 1 + id_bytes
|
|
1051
|
+
end
|
|
1052
|
+
entries
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# The content of object +id+ as a +type+ ("commit", "tree" or "blob"),
|
|
1056
|
+
# refused unless it hashes to +id+. +label+ names it in errors.
|
|
1057
|
+
def read_object(app, id, type, limit:, label: nil)
|
|
1058
|
+
label ||= "The checkout's #{type} #{id}"
|
|
1059
|
+
raise Error, "#{label} has no valid object id" unless id.is_a?(String) && COMMIT_ID.match?(id)
|
|
1060
|
+
|
|
1061
|
+
content, status = capture(read_only_git_environment, [ *git_command(app), "cat-file", type, id ],
|
|
1062
|
+
chdir: app, limit: limit + 1, timeout: GIT_TIMEOUT)
|
|
1063
|
+
raise Error, "#{label} is larger than #{limit} bytes" if content.bytesize > limit
|
|
1064
|
+
raise Error, "git could not read #{label} from the checked-out commit" unless status&.success?
|
|
1065
|
+
|
|
1066
|
+
digest = id.length == 64 ? Digest::SHA256 : Digest::SHA1
|
|
1067
|
+
unless digest.hexdigest("#{type} #{content.bytesize}\0".b + content) == id
|
|
1068
|
+
raise Error, "#{label} in the checkout does not match the checked-out commit, so it is not read"
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
content.b
|
|
1072
|
+
end
|
|
1073
|
+
|
|
524
1074
|
# --- Boot -------------------------------------------------------------
|
|
525
1075
|
|
|
526
|
-
def boot(session_id,
|
|
1076
|
+
def boot(session_id, checkout, secrets, boot_spec = nil)
|
|
527
1077
|
workspace = workspace_for(session_id)
|
|
528
|
-
|
|
1078
|
+
started = monotonic
|
|
1079
|
+
# A spec only for checkouts without the engine may not apply, which is
|
|
1080
|
+
# known once the checkout is there: until then, and when it does not,
|
|
1081
|
+
# the boot has the configured limit.
|
|
1082
|
+
reset_boot(boot_spec && !boot_spec.without_engine_only? ? spec_boot_timeout(boot_spec) : ActionAgent.local_sandbox_boot_timeout)
|
|
1083
|
+
deadline = deadline_after(@boot_timeout)
|
|
529
1084
|
booted = false
|
|
530
|
-
|
|
1085
|
+
keep = false
|
|
531
1086
|
|
|
532
1087
|
prepare_workspace(workspace)
|
|
533
|
-
|
|
1088
|
+
update_state(workspace) do |state|
|
|
1089
|
+
state["boot"] = { "steps" => [ { "name" => "checkout", "log" => "checkout", "status" => "pending" } ], "kept" => false }
|
|
1090
|
+
end
|
|
1091
|
+
boot_step(workspace, 0) { checkout!(workspace, checkout, secrets, deadline) }
|
|
534
1092
|
|
|
535
1093
|
app = workspace.join("app")
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1094
|
+
plan = plan_boot(workspace, app, checkout, boot_spec)
|
|
1095
|
+
if plan.spec && spec_boot_timeout(plan.spec) != @boot_timeout
|
|
1096
|
+
@boot_timeout = spec_boot_timeout(plan.spec)
|
|
1097
|
+
deadline = started + @boot_timeout
|
|
1098
|
+
end
|
|
1099
|
+
databases = assign_databases(workspace, app, plan.env, checkout)
|
|
1100
|
+
# Only from here on does a kept workspace hold all a resume needs.
|
|
1101
|
+
keep = plan.keep_on_failure
|
|
1102
|
+
result = run_plan!(workspace, plan, boot_environment(workspace, databases, plan), 0, deadline, secrets)
|
|
1103
|
+
booted = true
|
|
1104
|
+
result
|
|
1105
|
+
rescue Error
|
|
1106
|
+
raise
|
|
1107
|
+
rescue StandardError => e
|
|
1108
|
+
raise Error, SecretScrubber.scrub("Sandbox boot failed: #{e.class.name}: #{e.message}", secrets)
|
|
1109
|
+
ensure
|
|
1110
|
+
finish_failed_boot(workspace, keep) unless booted
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
# Takes a kept boot for one resume, under the state.json lock, so two
|
|
1114
|
+
# resumes never run in one workspace. False when there is none to take,
|
|
1115
|
+
# including while a terminate is removing the workspace.
|
|
1116
|
+
def claim_kept_boot(workspace)
|
|
1117
|
+
claimed = false
|
|
1118
|
+
update_state(workspace, create: false) do |state|
|
|
1119
|
+
boot_state = state["boot"]
|
|
1120
|
+
next if state["terminating"]
|
|
1121
|
+
next unless boot_state.is_a?(Hash) && boot_state["kept"] == true
|
|
1122
|
+
|
|
1123
|
+
boot_state["kept"] = false
|
|
1124
|
+
claimed = true
|
|
1125
|
+
end
|
|
1126
|
+
claimed
|
|
1127
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
1128
|
+
false
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
# Runs a kept boot's +plan+ again from its entry +start_at+.
|
|
1132
|
+
def resume(workspace, plan, start_at, secrets)
|
|
1133
|
+
reset_boot(spec_boot_timeout(plan.spec))
|
|
1134
|
+
deadline = deadline_after(@boot_timeout)
|
|
1135
|
+
booted = false
|
|
1136
|
+
keep = plan.keep_on_failure
|
|
1137
|
+
|
|
1138
|
+
database_env = read_state(workspace)["database_env"]
|
|
1139
|
+
databases = database_env.is_a?(Hash) ? database_env.transform_values(&:to_s) : {}
|
|
1140
|
+
record_resumed_plan(workspace, plan, start_at)
|
|
1141
|
+
result = run_plan!(workspace, plan, boot_environment(workspace, databases, plan), start_at, deadline, secrets)
|
|
1142
|
+
booted = true
|
|
1143
|
+
result
|
|
1144
|
+
rescue Error
|
|
1145
|
+
raise
|
|
1146
|
+
rescue StandardError => e
|
|
1147
|
+
raise Error, SecretScrubber.scrub("Sandbox boot failed: #{e.class.name}: #{e.message}", secrets)
|
|
1148
|
+
ensure
|
|
1149
|
+
finish_failed_boot(workspace, keep) unless booted
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
# The limit on a boot from +spec+. A bootstrap does more than the app's
|
|
1153
|
+
# own boot from its sandbox.yml, so it never gets less time than
|
|
1154
|
+
# `local_sandbox_boot_timeout` gives that boot.
|
|
1155
|
+
def spec_boot_timeout(spec)
|
|
1156
|
+
spec.kind == "bootstrap" ? [ spec.timeout, ActionAgent.local_sandbox_boot_timeout ].max : spec.timeout
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
# What a boot remembers while it runs, which a backend instance holds for
|
|
1160
|
+
# one call: the limit on the whole boot, the server it started and the
|
|
1161
|
+
# checkout's Rake tasks once listed.
|
|
1162
|
+
def reset_boot(timeout)
|
|
1163
|
+
@boot_timeout = timeout
|
|
1164
|
+
@server_pid = nil
|
|
1165
|
+
@app_tasks = nil
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# A failed boot leaves nothing running. Unless it is kept, it leaves
|
|
1169
|
+
# nothing on disk either: no handle was reported, so nothing would ever
|
|
1170
|
+
# reap it. The error carries the failing step's log tail.
|
|
1171
|
+
def finish_failed_boot(workspace, keep)
|
|
1172
|
+
stop_groups([ @server_pid ].compact)
|
|
1173
|
+
if keep && workspace.directory?
|
|
1174
|
+
update_state(workspace, create: false) { |state| state_hash(state, "boot")["kept"] = true }
|
|
1175
|
+
else
|
|
1176
|
+
# A setup that got as far as db:prepare created the databases.
|
|
1177
|
+
drop_databases(workspace)
|
|
1178
|
+
remove_workspace(workspace)
|
|
1179
|
+
end
|
|
1180
|
+
rescue SystemCallError => e
|
|
1181
|
+
Rails.logger.warn("[ActionAgent] sandbox #{workspace.basename}: could not finish a failed boot: #{e.message}")
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
def boot_environment(workspace, databases, plan)
|
|
1185
|
+
self.class.sanitized_environment.merge(databases).merge(plan.env).merge(
|
|
539
1186
|
# Merged after the file's env, so a checkout cannot move them.
|
|
540
1187
|
SandboxManifest::PATH_ENV => workspace.join("runtime.json").to_s,
|
|
541
|
-
SESSION_ID_ENV =>
|
|
1188
|
+
SESSION_ID_ENV => workspace.basename.to_s,
|
|
1189
|
+
SandboxMail::DIRECTORY_ENV => SandboxMail::DIRECTORY
|
|
542
1190
|
)
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# Runs +plan+'s entries from +start_at+ on, records each in state.json as
|
|
1194
|
+
# it goes, and returns what #create_sandbox does.
|
|
1195
|
+
def run_plan!(workspace, plan, env, start_at, deadline, secrets)
|
|
1196
|
+
app = workspace.join("app")
|
|
1197
|
+
plan.steps.each_with_index do |step, index|
|
|
1198
|
+
next if index < start_at
|
|
1199
|
+
|
|
1200
|
+
boot_step(workspace, plan.offset + index) do
|
|
1201
|
+
reason = step.skip || missing_task(workspace, step, env, deadline, secrets)
|
|
1202
|
+
if reason
|
|
1203
|
+
File.open(log_path(workspace, step.log), "a") { |file| file.puts("# skipped: #{reason}") }
|
|
1204
|
+
next Skip.new(reason)
|
|
1205
|
+
end
|
|
543
1206
|
|
|
544
|
-
|
|
545
|
-
|
|
1207
|
+
run_step!(workspace, step.label, step.command, env: env, chdir: app, deadline: deadline, secrets: secrets,
|
|
1208
|
+
timeout: step.timeout, log: step.log)
|
|
1209
|
+
end
|
|
546
1210
|
end
|
|
547
1211
|
|
|
548
1212
|
# Picked after setup, which can take minutes, so that the port is
|
|
@@ -551,33 +1215,305 @@ module ActionAgent
|
|
|
551
1215
|
# of the server's own process group.
|
|
552
1216
|
port = free_port
|
|
553
1217
|
env = env.merge("PORT" => port.to_s)
|
|
554
|
-
|
|
1218
|
+
manifest_index = plan.steps.size
|
|
1219
|
+
manifest = if start_at > manifest_index
|
|
1220
|
+
read_manifest!(workspace, plan.manifest, secrets)
|
|
1221
|
+
else
|
|
1222
|
+
boot_step(workspace, plan.offset + manifest_index) { run_manifest!(workspace, plan.manifest, env, deadline, secrets) }
|
|
1223
|
+
end
|
|
555
1224
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
1225
|
+
boot_step(workspace, plan.offset + manifest_index + 1) do
|
|
1226
|
+
server_pid, waiter = start_server(workspace, plan.start.command, env, port)
|
|
1227
|
+
start_deadline = step_deadline(plan.start, deadline)
|
|
1228
|
+
wait_until_ready!(workspace, port, manifest, server_pid, waiter, start_deadline, secrets, step: plan.start, boot_deadline: deadline)
|
|
1229
|
+
probe_start_url!(workspace, port, plan.start_url, waiter, start_deadline, secrets, step: plan.start, boot_deadline: deadline) if plan.start_url
|
|
1230
|
+
end
|
|
559
1231
|
|
|
560
1232
|
{
|
|
561
|
-
container_name: "#{HANDLE_PREFIX}#{
|
|
1233
|
+
container_name: "#{HANDLE_PREFIX}#{workspace.basename}",
|
|
562
1234
|
url: "http://127.0.0.1:#{port}",
|
|
563
1235
|
container_ip: "127.0.0.1",
|
|
564
1236
|
mcp_url: "http://127.0.0.1:#{port}#{manifest["mcp_path"]}",
|
|
565
1237
|
mcp_token: manifest["mcp_token"],
|
|
1238
|
+
app_models: manifest["models"],
|
|
566
1239
|
created_at: Time.current
|
|
567
1240
|
}
|
|
568
|
-
|
|
569
|
-
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
# Records the boot's entry at +position+ in state.json as running, then
|
|
1244
|
+
# as succeeded, skipped (the block returned a Skip) or failed, and names
|
|
1245
|
+
# a failed one as the boot's failed_step. Returns the block's value.
|
|
1246
|
+
def boot_step(workspace, position)
|
|
1247
|
+
update_boot_step(workspace, position, "status" => "running", "started_at" => Time.current.iso8601(3),
|
|
1248
|
+
"finished_at" => nil, "duration_ms" => nil, "detail" => nil)
|
|
1249
|
+
started = monotonic
|
|
1250
|
+
outcome = nil
|
|
1251
|
+
finished = false
|
|
1252
|
+
error = nil
|
|
1253
|
+
begin
|
|
1254
|
+
outcome = yield
|
|
1255
|
+
finished = true
|
|
1256
|
+
rescue StandardError => e
|
|
1257
|
+
error = e
|
|
1258
|
+
raise
|
|
1259
|
+
ensure
|
|
1260
|
+
result =
|
|
1261
|
+
if !finished then { "status" => "failed", "detail" => error.is_a?(Error) ? error.message.lines.first.to_s.strip : nil }
|
|
1262
|
+
elsif outcome.is_a?(Skip) then { "status" => "skipped", "detail" => outcome.reason }
|
|
1263
|
+
else { "status" => "succeeded" }
|
|
1264
|
+
end
|
|
1265
|
+
update_boot_step(workspace, position,
|
|
1266
|
+
result.merge("finished_at" => Time.current.iso8601(3), "duration_ms" => ((monotonic - started) * 1000).round))
|
|
1267
|
+
end
|
|
1268
|
+
outcome
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
def update_boot_step(workspace, position, attributes)
|
|
1272
|
+
update_state(workspace, create: false) do |state|
|
|
1273
|
+
boot_state = state_hash(state, "boot")
|
|
1274
|
+
step = Array(boot_state["steps"])[position]
|
|
1275
|
+
next unless step.is_a?(Hash)
|
|
1276
|
+
|
|
1277
|
+
step.merge!(attributes)
|
|
1278
|
+
boot_state["failed_step"] = step["name"] if attributes["status"] == "failed"
|
|
1279
|
+
end
|
|
1280
|
+
rescue SystemCallError
|
|
1281
|
+
# The workspace is gone: nothing left to record it in.
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
# Decides how the checkout boots, records the plan in state.json, and
|
|
1285
|
+
# returns it. A boot spec that applies runs its preflight here, before
|
|
1286
|
+
# any of the checkout's own commands.
|
|
1287
|
+
def plan_boot(workspace, app, checkout, boot_spec)
|
|
1288
|
+
facts = nil
|
|
1289
|
+
if boot_spec && spec_applies?(boot_spec, app)
|
|
1290
|
+
if boot_spec.preflight?
|
|
1291
|
+
position = append_boot_steps(workspace, [ { "name" => "preflight", "log" => "preflight", "status" => "pending" } ])
|
|
1292
|
+
facts = boot_step(workspace, position) { preflight!(workspace, app, checkout) }
|
|
1293
|
+
else
|
|
1294
|
+
facts = lock_facts(app.join("Gemfile.lock")) if app.join("Gemfile.lock").file?
|
|
1295
|
+
end
|
|
1296
|
+
plan = spec_plan(boot_spec, (facts&.dig("gems") || {}).keys.to_set)
|
|
1297
|
+
else
|
|
1298
|
+
plan = config_plan(Config.load(app), secrets: boot_spec&.step_environment || {}, steps: boot_spec&.always_steps || [])
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
update_state(workspace) do |state|
|
|
1302
|
+
boot_state = state_hash(state, "boot")
|
|
1303
|
+
boot_state["mode"] = plan.mode
|
|
1304
|
+
if plan.spec
|
|
1305
|
+
boot_state["kind"] = plan.spec.kind
|
|
1306
|
+
boot_state["spec"] = plan.spec.redacted
|
|
1307
|
+
boot_state["locked_gems"] = (facts&.dig("gems") || {}).keys.sort
|
|
1308
|
+
boot_state["lock"] = facts&.except("gems")
|
|
1309
|
+
end
|
|
1310
|
+
steps = boot_state["steps"] = Array(boot_state["steps"])
|
|
1311
|
+
plan.offset = boot_state["plan_offset"] = steps.size
|
|
1312
|
+
steps.concat(plan.entries.map { |step| { "name" => step.name, "log" => step.log, "status" => "pending" } })
|
|
1313
|
+
end
|
|
1314
|
+
plan
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
# Appends +entries+ to the boot's steps; returns the first one's position.
|
|
1318
|
+
def append_boot_steps(workspace, entries)
|
|
1319
|
+
position = nil
|
|
1320
|
+
update_state(workspace) do |state|
|
|
1321
|
+
steps = state_hash(state, "boot")["steps"] = Array(state_hash(state, "boot")["steps"])
|
|
1322
|
+
position = steps.size
|
|
1323
|
+
steps.concat(entries)
|
|
1324
|
+
end
|
|
1325
|
+
position
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
# Rewrites the boot's steps for a resume from +start_at+: the entries
|
|
1329
|
+
# before it keep how they went, the rest are pending again.
|
|
1330
|
+
def record_resumed_plan(workspace, plan, start_at)
|
|
1331
|
+
update_state(workspace, create: false) do |state|
|
|
1332
|
+
boot_state = state_hash(state, "boot")
|
|
1333
|
+
steps = Array(boot_state["steps"])
|
|
1334
|
+
offset = Integer(boot_state["plan_offset"], exception: false) || steps.size
|
|
1335
|
+
earlier = steps.drop(offset).select { |step| step.is_a?(Hash) }.index_by { |step| step["name"] }
|
|
1336
|
+
resumed_from = plan.entries[start_at].name
|
|
1337
|
+
entries = plan.entries.each_with_index.map do |step, index|
|
|
1338
|
+
if index < start_at
|
|
1339
|
+
earlier[step.name] || { "name" => step.name, "log" => step.log, "status" => "skipped", "detail" => "not run: resumed from #{resumed_from}" }
|
|
1340
|
+
else
|
|
1341
|
+
{ "name" => step.name, "log" => step.log, "status" => "pending" }
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
plan.offset = offset
|
|
1345
|
+
boot_state.merge!("steps" => steps.first(offset) + entries, "failed_step" => nil, "kept" => false,
|
|
1346
|
+
"spec" => plan.spec.redacted, "plan_offset" => offset)
|
|
1347
|
+
end
|
|
1348
|
+
end
|
|
1349
|
+
|
|
1350
|
+
def resume_position(plan, name)
|
|
1351
|
+
index = plan.entries.index { |step| step.name == name.to_s }
|
|
1352
|
+
return index if index
|
|
1353
|
+
|
|
1354
|
+
raise Error, "#{name.inspect} is not a step this boot can resume from (#{plan.entries.map(&:name).join(", ")})"
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
# The steps #resume_position accepts: a spec boot's own, which follow
|
|
1358
|
+
# the checkout and preflight steps. None for a boot from the checkout's
|
|
1359
|
+
# sandbox.yml, which cannot be resumed.
|
|
1360
|
+
def resumable_step_names(boot_state)
|
|
1361
|
+
offset = Integer(boot_state["plan_offset"], exception: false)
|
|
1362
|
+
return [] unless boot_state["mode"] == "spec" && offset
|
|
1363
|
+
|
|
1364
|
+
boot_state["steps"].drop(offset).filter_map { |step| step["name"] if step.is_a?(Hash) }
|
|
1365
|
+
end
|
|
1366
|
+
|
|
1367
|
+
# +secrets+ are the env and secrets of a boot spec that does not apply to
|
|
1368
|
+
# the checkout, and +steps+ its `always` steps: it boots as its
|
|
1369
|
+
# sandbox.yml says, with them added to that file's env, and the steps
|
|
1370
|
+
# run after that file's setup.
|
|
1371
|
+
def config_plan(config, secrets: {}, steps: [])
|
|
1372
|
+
extra = steps.map do |entry|
|
|
1373
|
+
BootStep.new(name: entry["name"], command: entry["command"], timeout: entry["timeout"], log: entry["name"], label: entry["name"],
|
|
1374
|
+
if_task: entry["if_task"])
|
|
1375
|
+
end
|
|
1376
|
+
BootPlan.new(
|
|
1377
|
+
mode: "config", spec: nil,
|
|
1378
|
+
steps: config.setup.map { |command| BootStep.new(name: "setup", command: command, log: "setup", label: "setup") } + extra,
|
|
1379
|
+
manifest: BootStep.new(name: "manifest", command: config.manifest, log: "manifest", label: "manifest"),
|
|
1380
|
+
start: BootStep.new(name: "start", command: config.start, log: "server", label: "server"),
|
|
1381
|
+
env: config.env.merge(secrets), start_url: nil, keep_on_failure: false
|
|
1382
|
+
)
|
|
1383
|
+
end
|
|
1384
|
+
|
|
1385
|
+
# +locked+ holds the names of the gems the checkout's Gemfile.lock locked
|
|
1386
|
+
# as it was checked out, before any step changed it.
|
|
1387
|
+
def spec_plan(spec, locked)
|
|
1388
|
+
steps = spec.steps.map do |entry|
|
|
1389
|
+
gem = entry["unless_locked"]
|
|
1390
|
+
BootStep.new(
|
|
1391
|
+
name: entry["name"], command: entry["command"], timeout: entry["timeout"], log: entry["name"], label: entry["name"],
|
|
1392
|
+
if_task: entry["if_task"], skip: (gem && locked.include?(gem) ? "the checkout already locks #{gem}" : nil)
|
|
1393
|
+
)
|
|
1394
|
+
end
|
|
1395
|
+
BootPlan.new(
|
|
1396
|
+
mode: "spec", spec: spec, steps: steps,
|
|
1397
|
+
manifest: BootStep.new(name: "manifest", command: spec.manifest["command"], timeout: spec.manifest["timeout"],
|
|
1398
|
+
log: "manifest", label: "manifest"),
|
|
1399
|
+
start: BootStep.new(name: "start", command: spec.start["command"], timeout: spec.start["timeout"], log: "server",
|
|
1400
|
+
label: "start"),
|
|
1401
|
+
env: spec.step_environment, start_url: spec.start_url, keep_on_failure: spec.keep_on_failure?
|
|
1402
|
+
)
|
|
1403
|
+
end
|
|
1404
|
+
|
|
1405
|
+
# Whether +spec+ boots the checkout in +app+. One for checkouts without
|
|
1406
|
+
# the engine leaves alone a checkout that bundles it, one that says how
|
|
1407
|
+
# it serves its manifest itself, and anything without a Gemfile.lock.
|
|
1408
|
+
def spec_applies?(spec, app)
|
|
1409
|
+
return true unless spec.without_engine_only?
|
|
1410
|
+
|
|
1411
|
+
lock = app.join("Gemfile.lock")
|
|
1412
|
+
return false unless lock.file?
|
|
1413
|
+
return false if lock.read.match?(/^ {4}actionagent \(/)
|
|
1414
|
+
|
|
1415
|
+
file = app.join(Config::PATH)
|
|
1416
|
+
data = file.file? ? YAML.safe_load(file.read, aliases: false) : nil
|
|
1417
|
+
!(data.is_a?(Hash) && data.key?("manifest"))
|
|
1418
|
+
rescue Psych::Exception
|
|
1419
|
+
# Malformed: booting as configured reports what is wrong with it.
|
|
1420
|
+
false
|
|
1421
|
+
end
|
|
1422
|
+
|
|
1423
|
+
# Refuses, before any of the checkout's commands runs, a checkout the
|
|
1424
|
+
# engine cannot be installed into. Returns what the checkout's
|
|
1425
|
+
# Gemfile.lock locks (see #lock_facts).
|
|
1426
|
+
def preflight!(workspace, app, checkout)
|
|
1427
|
+
repository = checkout[:repository].presence || "The checkout"
|
|
1428
|
+
log = log_path(workspace, "preflight")
|
|
1429
|
+
lock = app.join("Gemfile.lock")
|
|
1430
|
+
refuse = ->(problem) { fail_step!("preflight", problem, log, []) }
|
|
1431
|
+
|
|
1432
|
+
refuse.call("#{repository} has no Gemfile.lock at its root: a bootstrap boot needs a bundled Rails app") unless lock.file?
|
|
1433
|
+
facts = lock_facts(lock)
|
|
1434
|
+
ruby, ruby_source = facts["ruby"] ? [ facts["ruby"], "Gemfile.lock" ] : [ ruby_version_file(app), ".ruby-version" ]
|
|
1435
|
+
railties = facts.dig("gems", "railties")
|
|
1436
|
+
File.open(log, "a") do |file|
|
|
1437
|
+
file.puts("Ruby #{ruby || "unpinned"}, railties #{railties || "not locked"}, " \
|
|
1438
|
+
"activeagent #{facts.dig("gems", "activeagent") || "not locked"}, actionagent #{facts.dig("gems", "actionagent") || "not locked"}")
|
|
1439
|
+
end
|
|
1440
|
+
|
|
1441
|
+
if ruby && version_below?(ruby, SandboxBootSpec::MINIMUM_RUBY)
|
|
1442
|
+
refuse.call("#{repository} needs Ruby #{ruby} (#{ruby_source}); the engine needs Ruby #{SandboxBootSpec::MINIMUM_RUBY} or later")
|
|
1443
|
+
end
|
|
1444
|
+
refuse.call("#{repository}'s Gemfile.lock locks no railties: a bootstrap boot needs a Rails app") if railties.nil?
|
|
1445
|
+
if version_below?(railties, SandboxBootSpec::MINIMUM_RAILTIES)
|
|
1446
|
+
refuse.call("#{repository} locks railties #{railties}; the engine needs Rails #{SandboxBootSpec::MINIMUM_RAILTIES} or later")
|
|
1447
|
+
end
|
|
1448
|
+
unless app.join("config", "application.rb").file?
|
|
1449
|
+
refuse.call("#{repository} has no config/application.rb at its root: a bootstrap boot needs the Rails app at the repository root")
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
facts.merge("ruby" => ruby, "railties" => railties,
|
|
1453
|
+
"activeagent" => facts.dig("gems", "activeagent"), "actionagent" => facts.dig("gems", "actionagent"))
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
# What a Gemfile.lock locks: { "ruby" => "3.3.6" or nil, "gems" => { name => version } }.
|
|
1457
|
+
# Read in this process with Bundler's parser, which only parses.
|
|
1458
|
+
def lock_facts(path)
|
|
1459
|
+
parser = ::Bundler::LockfileParser.new(path.read)
|
|
1460
|
+
gems = parser.specs.each_with_object({}) { |spec, all| all[spec.name] ||= spec.version.to_s }
|
|
1461
|
+
{ "ruby" => parser.ruby_version.to_s[/\d+\.\d+(?:\.\d+)?/], "gems" => gems }
|
|
570
1462
|
rescue StandardError => e
|
|
571
|
-
raise Error,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1463
|
+
raise Error, "Sandbox preflight failed: the checkout's Gemfile.lock could not be read (#{e.message.lines.first.to_s.strip.truncate(200)})"
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1466
|
+
def ruby_version_file(app)
|
|
1467
|
+
file = app.join(".ruby-version")
|
|
1468
|
+
file.file? ? file.read.lines.first.to_s.strip.delete_prefix("ruby-")[/\A\d+\.\d+(?:\.\d+)?/] : nil
|
|
1469
|
+
end
|
|
1470
|
+
|
|
1471
|
+
def version_below?(version, minimum)
|
|
1472
|
+
Gem::Version.correct?(version) && Gem::Version.new(version) < minimum
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
# Why +step+ is skipped because the checkout defines no Rake task of its
|
|
1476
|
+
# if_task's name; nil when it has none, or the task exists.
|
|
1477
|
+
def missing_task(workspace, step, env, deadline, secrets)
|
|
1478
|
+
return nil unless step.if_task
|
|
1479
|
+
|
|
1480
|
+
app_tasks(workspace, step, env, deadline, secrets).include?(step.if_task) ? nil : "the app defines no #{step.if_task} task"
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1483
|
+
# The checkout's Rake tasks, as `bin/rails -T -A` lists them, once per
|
|
1484
|
+
# boot: the first step that needs them runs it, after the steps before
|
|
1485
|
+
# it installed the bundle.
|
|
1486
|
+
def app_tasks(workspace, step, env, deadline, secrets)
|
|
1487
|
+
return @app_tasks if @app_tasks
|
|
1488
|
+
|
|
1489
|
+
log = log_path(workspace, step.log)
|
|
1490
|
+
File.open(log, "a") { |file| file.puts("$ bin/rails -T -A") }
|
|
1491
|
+
timeout = [ time_left(step_deadline(step, deadline)) || TASK_LIST_TIMEOUT, TASK_LIST_TIMEOUT ].min
|
|
1492
|
+
output, status = capture(env, [ "bin/rails", "-T", "-A" ], chdir: workspace.join("app"), limit: TASK_LIST_BYTES,
|
|
1493
|
+
timeout: [ timeout, 1 ].max, err: [ :child, :out ])
|
|
1494
|
+
unless status&.success?
|
|
1495
|
+
File.open(log, "a") { |file| file.write(SecretScrubber.scrub(output.force_encoding(Encoding::UTF_8).scrub.last(LOG_TAIL_BYTES), secrets)) }
|
|
1496
|
+
fail_step!(step.label, "could not list the app's Rake tasks: `bin/rails -T -A` #{status ? "exited with #{describe(status)}" : "did not finish"}",
|
|
1497
|
+
log, secrets)
|
|
1498
|
+
end
|
|
1499
|
+
|
|
1500
|
+
@app_tasks = output.force_encoding(Encoding::UTF_8).scrub.lines.filter_map { |line| line[/\A\S+\s+([^\s\[#]+)/, 1] }.to_set
|
|
1501
|
+
rescue SystemCallError => e
|
|
1502
|
+
fail_step!(step.label, "could not list the app's Rake tasks: #{e.message}", log, secrets)
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# When +step+ must be done by: its own timeout or the boot's, whichever
|
|
1506
|
+
# comes first.
|
|
1507
|
+
def step_deadline(step, deadline)
|
|
1508
|
+
step.timeout ? [ deadline, deadline_after(step.timeout) ].min : deadline
|
|
1509
|
+
end
|
|
1510
|
+
|
|
1511
|
+
# How a timeout message names the limit that ran out.
|
|
1512
|
+
def limit_reached(step, step_deadline, deadline)
|
|
1513
|
+
if step&.timeout && step_deadline < deadline
|
|
1514
|
+
"within its #{step.timeout}s timeout"
|
|
1515
|
+
else
|
|
1516
|
+
"within the boot timeout (#{@boot_timeout}s)"
|
|
581
1517
|
end
|
|
582
1518
|
end
|
|
583
1519
|
|
|
@@ -586,9 +1522,9 @@ module ActionAgent
|
|
|
586
1522
|
# or after a restart, drops what is recorded there. Claude Code sessions
|
|
587
1523
|
# get them too, so a `bin/rails db:migrate` a session runs lands in the
|
|
588
1524
|
# sandbox's database rather than the developer's.
|
|
589
|
-
def assign_databases(workspace, app,
|
|
1525
|
+
def assign_databases(workspace, app, overrides, spec)
|
|
590
1526
|
plan = LocalSandboxDatabases.plan(
|
|
591
|
-
app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides:
|
|
1527
|
+
app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides: overrides,
|
|
592
1528
|
fallback_name: spec[:repository].to_s.split("/").last
|
|
593
1529
|
)
|
|
594
1530
|
if plan.notes.any?
|
|
@@ -622,11 +1558,17 @@ module ActionAgent
|
|
|
622
1558
|
app = workspace.join("app")
|
|
623
1559
|
return unless app.join("bin", "rails").file?
|
|
624
1560
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1561
|
+
boot_state = state["boot"]
|
|
1562
|
+
file_env =
|
|
1563
|
+
if boot_state.is_a?(Hash) && boot_state["mode"] == "spec"
|
|
1564
|
+
boot_state.dig("spec", "env").is_a?(Hash) ? boot_state.dig("spec", "env") : {}
|
|
1565
|
+
else
|
|
1566
|
+
begin
|
|
1567
|
+
Config.load(app).env
|
|
1568
|
+
rescue Error
|
|
1569
|
+
{}
|
|
1570
|
+
end
|
|
1571
|
+
end
|
|
630
1572
|
env = self.class.sanitized_environment.merge(file_env).merge(database_env.transform_values(&:to_s)).merge(
|
|
631
1573
|
SESSION_ID_ENV => workspace.basename.to_s,
|
|
632
1574
|
"RAILS_ENV" => state.fetch("database_rails_env"),
|
|
@@ -682,13 +1624,22 @@ module ActionAgent
|
|
|
682
1624
|
update_state(workspace) { |state| state["checkout_commit"] = commit } if status&.success? && COMMIT_ID.match?(commit)
|
|
683
1625
|
end
|
|
684
1626
|
|
|
685
|
-
def run_manifest!(workspace,
|
|
1627
|
+
def run_manifest!(workspace, step, env, deadline, secrets)
|
|
686
1628
|
path = workspace.join("runtime.json")
|
|
687
1629
|
FileUtils.rm_f(path)
|
|
688
|
-
run_step!(workspace,
|
|
1630
|
+
run_step!(workspace, step.label, step.command, env: env, chdir: workspace.join("app"), deadline: deadline, secrets: secrets,
|
|
1631
|
+
timeout: step.timeout, log: step.log)
|
|
1632
|
+
|
|
1633
|
+
log = log_path(workspace, step.log)
|
|
1634
|
+
fail_step!(step.label, "`#{step.command}` wrote nothing to $#{SandboxManifest::PATH_ENV}", log, secrets) unless path.file?
|
|
689
1635
|
|
|
690
|
-
|
|
691
|
-
|
|
1636
|
+
read_manifest!(workspace, step, secrets)
|
|
1637
|
+
end
|
|
1638
|
+
|
|
1639
|
+
def read_manifest!(workspace, step, secrets)
|
|
1640
|
+
path = workspace.join("runtime.json")
|
|
1641
|
+
log = log_path(workspace, step.log)
|
|
1642
|
+
fail_step!(step.label, "there is no manifest to resume from: resume from manifest", log, secrets) unless path.file?
|
|
692
1643
|
|
|
693
1644
|
# Written by the checkout's command, with its umask: the MCP token in
|
|
694
1645
|
# it is for the dashboard alone.
|
|
@@ -697,48 +1648,78 @@ module ActionAgent
|
|
|
697
1648
|
URI.parse("http://127.0.0.1#{manifest["mcp_path"]}")
|
|
698
1649
|
manifest
|
|
699
1650
|
rescue SandboxManifest::Error, URI::InvalidURIError => e
|
|
700
|
-
fail_step!(
|
|
1651
|
+
fail_step!(step.label, e.message, log, secrets)
|
|
701
1652
|
end
|
|
702
1653
|
|
|
703
1654
|
# Runs one boot command to completion in its own process group, with its
|
|
704
|
-
# output in logs/<
|
|
705
|
-
#
|
|
706
|
-
#
|
|
1655
|
+
# output in logs/<log>.log, scrubbed of +secrets+ a line at a time on the
|
|
1656
|
+
# way there. Anything it left running in that group is stopped too: a
|
|
1657
|
+
# setup command is not a way to start services the sandbox never records
|
|
1658
|
+
# (that is what `start` is for). +step+ names it in a failure, and
|
|
1659
|
+
# +timeout+ bounds it within +deadline+.
|
|
707
1660
|
#
|
|
708
|
-
# The group is stopped however the wait ends:
|
|
1661
|
+
# The group is stopped however the wait ends: a deadline, or any
|
|
709
1662
|
# exception at all (a worker shutting down raises into this thread with
|
|
710
1663
|
# Thread#raise, which is no StandardError). While it runs its pid is in
|
|
711
1664
|
# state.json as step_pid, so a terminate after this process itself died
|
|
712
1665
|
# (a crashed dashboard, a killed worker) stops a hung `bundle install`
|
|
713
1666
|
# too; nothing else would, the deadline having died with this process.
|
|
714
|
-
def run_step!(workspace, step, command, env:, chdir:, deadline:, secrets:, label: command)
|
|
715
|
-
log = log_path(workspace,
|
|
716
|
-
File.open(log, "a") { |file| file.puts("$ #{label}") }
|
|
1667
|
+
def run_step!(workspace, step, command, env:, chdir:, deadline:, secrets:, label: command, timeout: nil, log: step)
|
|
1668
|
+
log = log_path(workspace, log)
|
|
1669
|
+
File.open(log, "a") { |file| file.puts("$ #{SecretScrubber.scrub(label, secrets)}") }
|
|
1670
|
+
until_time = timeout ? [ deadline, deadline_after(timeout) ].min : deadline
|
|
717
1671
|
|
|
718
1672
|
pid = nil
|
|
719
1673
|
waiter = nil
|
|
720
1674
|
finished = false
|
|
1675
|
+
reader, writer = IO.pipe
|
|
1676
|
+
copier = nil
|
|
721
1677
|
# Deferred until the group is recorded or stopped, so an interrupt
|
|
722
1678
|
# never lands between the spawn and the ensure that stops it.
|
|
723
1679
|
Thread.handle_interrupt(Object => :never) do
|
|
724
|
-
|
|
1680
|
+
begin
|
|
1681
|
+
pid = spawn_group(env, "sh", "-c", command, chdir: chdir, in: File::NULL, out: writer, err: [ :child, :out ])
|
|
1682
|
+
ensure
|
|
1683
|
+
writer.close
|
|
1684
|
+
end
|
|
725
1685
|
begin
|
|
726
1686
|
Thread.handle_interrupt(Object => :immediate) do
|
|
727
1687
|
record_step(workspace, pid)
|
|
1688
|
+
# Started once the step is recorded: until then the group's
|
|
1689
|
+
# output waits in the pipe.
|
|
1690
|
+
copier = background { copy_output(reader, log, secrets) }
|
|
728
1691
|
waiter = Process.detach(pid)
|
|
729
|
-
finished = waiter.join(time_left(
|
|
1692
|
+
finished = waiter.join(time_left(until_time))
|
|
730
1693
|
end
|
|
731
1694
|
ensure
|
|
732
1695
|
stop_groups([ pid ], grace: finished ? 1 : stop_grace)
|
|
733
1696
|
forget_step(workspace, pid)
|
|
1697
|
+
# What the group wrote last is in the log before its tail is read.
|
|
1698
|
+
copier&.join(OUTPUT_DRAIN_GRACE)
|
|
734
1699
|
end
|
|
735
1700
|
end
|
|
736
1701
|
|
|
737
1702
|
unless finished
|
|
738
|
-
|
|
1703
|
+
limit = timeout && until_time < deadline ? "within its #{timeout}s timeout" : "within the boot timeout (#{@boot_timeout || ActionAgent.local_sandbox_boot_timeout}s)"
|
|
1704
|
+
fail_step!(step, "`#{label}` did not finish #{limit}", log, secrets)
|
|
739
1705
|
end
|
|
740
1706
|
status = waiter.value
|
|
741
1707
|
fail_step!(step, "`#{label}` exited with #{describe(status)}", log, secrets) unless status.success?
|
|
1708
|
+
ensure
|
|
1709
|
+
reader&.close unless reader&.closed?
|
|
1710
|
+
writer&.close unless writer&.closed?
|
|
1711
|
+
end
|
|
1712
|
+
|
|
1713
|
+
# Appends what a boot step prints to +log+, a line at a time, scrubbed.
|
|
1714
|
+
def copy_output(io, log, secrets)
|
|
1715
|
+
File.open(log, "ab") do |file|
|
|
1716
|
+
io.each_line(LOG_LINE_BYTES) do |line|
|
|
1717
|
+
file.write(SecretScrubber.scrub(line.force_encoding(Encoding::UTF_8).scrub, secrets))
|
|
1718
|
+
file.flush
|
|
1719
|
+
end
|
|
1720
|
+
end
|
|
1721
|
+
rescue IOError
|
|
1722
|
+
# Closed under us once the step is over.
|
|
742
1723
|
end
|
|
743
1724
|
|
|
744
1725
|
def record_step(workspace, pid)
|
|
@@ -759,12 +1740,16 @@ module ActionAgent
|
|
|
759
1740
|
# The workspace is gone: nothing left to forget it in.
|
|
760
1741
|
end
|
|
761
1742
|
|
|
762
|
-
|
|
1743
|
+
# The server's output goes to logs/server.log directly, unscrubbed: it
|
|
1744
|
+
# outlives this process, and a pipe would end with it. #log_tail and
|
|
1745
|
+
# #boot_log scrub it when they read it.
|
|
1746
|
+
def start_server(workspace, command, env, port)
|
|
763
1747
|
log = log_path(workspace, "server")
|
|
764
|
-
File.open(log, "a") { |file| file.puts("$ #{
|
|
1748
|
+
File.open(log, "a") { |file| file.puts("$ #{command}") }
|
|
765
1749
|
|
|
766
|
-
pid = spawn_group(env, "sh", "-c",
|
|
1750
|
+
pid = spawn_group(env, "sh", "-c", command,
|
|
767
1751
|
chdir: workspace.join("app"), in: File::NULL, out: [ log.to_s, "a" ], err: [ :child, :out ])
|
|
1752
|
+
@server_pid = pid
|
|
768
1753
|
# Reaped by this thread for as long as the dashboard lives; after a
|
|
769
1754
|
# restart the recorded pid is all that is left, hence state.json.
|
|
770
1755
|
waiter = Process.detach(pid)
|
|
@@ -788,7 +1773,7 @@ module ActionAgent
|
|
|
788
1773
|
# server this sandbox started. The port was free when it was picked, but
|
|
789
1774
|
# nothing held it after: another process can bind it first, and then
|
|
790
1775
|
# answers in the server's place — and would be handed the MCP token.
|
|
791
|
-
def wait_until_ready!(workspace, port, manifest, server_pid, waiter, deadline, secrets)
|
|
1776
|
+
def wait_until_ready!(workspace, port, manifest, server_pid, waiter, deadline, secrets, step:, boot_deadline: deadline)
|
|
792
1777
|
log = log_path(workspace, "server")
|
|
793
1778
|
mcp_path = manifest["mcp_path"]
|
|
794
1779
|
uri = URI.parse("http://127.0.0.1:#{port}#{mcp_path}")
|
|
@@ -798,7 +1783,7 @@ module ActionAgent
|
|
|
798
1783
|
loop do
|
|
799
1784
|
unless waiter.alive?
|
|
800
1785
|
taken = foreign ? " (another process was listening on port #{port})" : ""
|
|
801
|
-
fail_step!(
|
|
1786
|
+
fail_step!(step.label, "the server exited with #{describe(waiter.value)} before it answered on port #{port}#{taken}",
|
|
802
1787
|
log, secrets)
|
|
803
1788
|
end
|
|
804
1789
|
|
|
@@ -816,13 +1801,38 @@ module ActionAgent
|
|
|
816
1801
|
elsif last_status then " (last answer: #{last_status})"
|
|
817
1802
|
else ""
|
|
818
1803
|
end
|
|
819
|
-
fail_step!(
|
|
820
|
-
"
|
|
1804
|
+
fail_step!(step.label, "the server did not answer #{mcp_path} on port #{port} " \
|
|
1805
|
+
"#{limit_reached(step, deadline, boot_deadline)}#{answered}", log, secrets)
|
|
821
1806
|
end
|
|
822
1807
|
sleep POLL_INTERVAL
|
|
823
1808
|
end
|
|
824
1809
|
end
|
|
825
1810
|
|
|
1811
|
+
# Fails the start step when +start_url+ on the booted server answers 5xx,
|
|
1812
|
+
# or never answers. Asked once the MCP facade answered, so the server is
|
|
1813
|
+
# the sandbox's own; anything else (a redirect to sign in, a 404) passes.
|
|
1814
|
+
def probe_start_url!(workspace, port, start_url, waiter, deadline, secrets, step:, boot_deadline: deadline)
|
|
1815
|
+
log = log_path(workspace, "server")
|
|
1816
|
+
uri = URI.parse("http://127.0.0.1:#{port}#{start_url}")
|
|
1817
|
+
loop do
|
|
1818
|
+
read_timeout = [ START_URL_READ_TIMEOUT, time_left(deadline) || START_URL_READ_TIMEOUT ].min
|
|
1819
|
+
status = probe(uri, accept: "text/html,*/*", read_timeout: [ read_timeout, 1 ].max)
|
|
1820
|
+
if status
|
|
1821
|
+
fail_step!(step.label, "GET #{start_url} answered #{status}", log, secrets) if status >= 500
|
|
1822
|
+
return status
|
|
1823
|
+
end
|
|
1824
|
+
unless waiter.alive?
|
|
1825
|
+
fail_step!(step.label, "the server exited with #{describe(waiter.value)} before it answered GET #{start_url}", log, secrets)
|
|
1826
|
+
end
|
|
1827
|
+
if monotonic >= deadline
|
|
1828
|
+
fail_step!(step.label, "GET #{start_url} got no answer #{limit_reached(step, deadline, boot_deadline)}", log, secrets)
|
|
1829
|
+
end
|
|
1830
|
+
sleep POLL_INTERVAL
|
|
1831
|
+
end
|
|
1832
|
+
rescue URI::InvalidURIError
|
|
1833
|
+
fail_step!(step.label, "#{start_url} is not a path this backend can request", log, secrets)
|
|
1834
|
+
end
|
|
1835
|
+
|
|
826
1836
|
# Whether what listens on +uri+'s port is the sandbox's server (process
|
|
827
1837
|
# group +pgid+). Asked of the system where it can say (/proc on Linux,
|
|
828
1838
|
# lsof elsewhere); the token is never sent before. Where it cannot say,
|
|
@@ -928,14 +1938,14 @@ module ActionAgent
|
|
|
928
1938
|
nil
|
|
929
1939
|
end
|
|
930
1940
|
|
|
931
|
-
# The HTTP status a GET on
|
|
932
|
-
#
|
|
933
|
-
def probe(uri)
|
|
1941
|
+
# The HTTP status a GET on +uri+ answers with, or nil while nothing
|
|
1942
|
+
# answers.
|
|
1943
|
+
def probe(uri, accept: "application/json", read_timeout: 2)
|
|
934
1944
|
# No proxy: the dashboard's HTTP(S)_PROXY does not know this loopback.
|
|
935
1945
|
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
936
1946
|
http.open_timeout = 1
|
|
937
|
-
http.read_timeout =
|
|
938
|
-
http.start { |connection| connection.request(Net::HTTP::Get.new(uri, "Accept" =>
|
|
1947
|
+
http.read_timeout = read_timeout
|
|
1948
|
+
http.start { |connection| connection.request(Net::HTTP::Get.new(uri, "Accept" => accept)).code.to_i }
|
|
939
1949
|
rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse
|
|
940
1950
|
nil
|
|
941
1951
|
end
|
|
@@ -969,6 +1979,19 @@ module ActionAgent
|
|
|
969
1979
|
|
|
970
1980
|
# --- Claude Code --------------------------------------------------------
|
|
971
1981
|
|
|
1982
|
+
def codex_argv(code_session)
|
|
1983
|
+
argv = [
|
|
1984
|
+
ActionAgent.codex_command.to_s, "exec", "--json", "--ephemeral",
|
|
1985
|
+
"--sandbox", "workspace-write", "--config", 'approval_policy="never"', "--color", "never"
|
|
1986
|
+
]
|
|
1987
|
+
if (model = code_session.model.presence)
|
|
1988
|
+
raise Error, "#{model.inspect} is not a model name" unless MODEL_NAME.match?(model.to_s)
|
|
1989
|
+
|
|
1990
|
+
argv += [ "--model", model.to_s ]
|
|
1991
|
+
end
|
|
1992
|
+
argv + [ "-" ]
|
|
1993
|
+
end
|
|
1994
|
+
|
|
972
1995
|
def claude_argv(code_session)
|
|
973
1996
|
command = ActionAgent.claude_code_command.to_s
|
|
974
1997
|
argv = [
|
|
@@ -979,6 +2002,11 @@ module ActionAgent
|
|
|
979
2002
|
# that would prompt is denied instead of stalling the session.
|
|
980
2003
|
argv += [ "--permission-prompts", "none" ] if permission_prompts_supported?(command)
|
|
981
2004
|
argv += [ "--max-turns", ActionAgent.claude_code_max_turns.to_i.to_s ] if ActionAgent.claude_code_max_turns.present?
|
|
2005
|
+
# A boot fix may run the few commands it needs to re-resolve the
|
|
2006
|
+
# checkout's dependencies (ProjectBootFix::BUNDLER_COMMANDS); every
|
|
2007
|
+
# other session runs none.
|
|
2008
|
+
allowed = ProjectBootFix.allowed_tools_for(code_session)
|
|
2009
|
+
argv += [ "--allowedTools", allowed.join(",") ] if allowed.any?
|
|
982
2010
|
|
|
983
2011
|
if (model = code_session.model.presence)
|
|
984
2012
|
# One argv entry, but one that must not read as a flag.
|
|
@@ -1009,18 +2037,20 @@ module ActionAgent
|
|
|
1009
2037
|
LOGGED_OUT
|
|
1010
2038
|
end
|
|
1011
2039
|
|
|
1012
|
-
def run_claude(workspace, code_session, argv, env, secrets, &on_event)
|
|
2040
|
+
def run_claude(workspace, code_session, argv, env, secrets, runner: "claude_code", &on_event)
|
|
2041
|
+
label = runner == "codex" ? "Codex" : "Claude Code"
|
|
2042
|
+
timeout = runner == "codex" ? ActionAgent.codex_timeout : ActionAgent.claude_code_timeout
|
|
1013
2043
|
key = code_session.id.to_s
|
|
1014
|
-
log = log_path(workspace, "claude-#{key}")
|
|
1015
|
-
deadline = deadline_after(
|
|
2044
|
+
log = log_path(workspace, "#{runner == 'codex' ? 'codex' : 'claude'}-#{key}")
|
|
2045
|
+
deadline = deadline_after(timeout)
|
|
1016
2046
|
# Checked again once Claude Code is recorded; this saves starting it.
|
|
1017
2047
|
state = read_state(workspace)
|
|
1018
|
-
refuse_stopped_session!(state, key)
|
|
2048
|
+
refuse_stopped_session!(state, key, label: label)
|
|
1019
2049
|
# A cancelled session frees its slot as soon as it is marked cancelled,
|
|
1020
2050
|
# while its Claude Code may still be exiting (and diffing). Two in one
|
|
1021
2051
|
# checkout would edit the same files.
|
|
1022
2052
|
if other_session_running?(state, key, workspace.basename.to_s)
|
|
1023
|
-
raise Error, "The previous
|
|
2053
|
+
raise Error, "The previous code session in this sandbox is still stopping; try again in a moment"
|
|
1024
2054
|
end
|
|
1025
2055
|
stdin_read, stdin_write = IO.pipe
|
|
1026
2056
|
stdout_read, stdout_write = IO.pipe
|
|
@@ -1029,7 +2059,7 @@ module ActionAgent
|
|
|
1029
2059
|
begin
|
|
1030
2060
|
pid = spawn_group(env, *argv, chdir: workspace.join("app"), in: stdin_read, out: stdout_write, err: stderr_write)
|
|
1031
2061
|
rescue SystemCallError => e
|
|
1032
|
-
raise Error, "Could not start
|
|
2062
|
+
raise Error, "Could not start #{label} (#{argv.first}): #{e.message}"
|
|
1033
2063
|
ensure
|
|
1034
2064
|
[ stdin_read, stdout_write, stderr_write ].each(&:close)
|
|
1035
2065
|
end
|
|
@@ -1040,7 +2070,7 @@ module ActionAgent
|
|
|
1040
2070
|
case record_code_session(workspace, key, pid)
|
|
1041
2071
|
when :terminating
|
|
1042
2072
|
# Stopped by the ensure below, before it had the prompt.
|
|
1043
|
-
raise Error, "The sandbox is being stopped, so
|
|
2073
|
+
raise Error, "The sandbox is being stopped, so #{label} did not run"
|
|
1044
2074
|
when :cancelled
|
|
1045
2075
|
# Runs its course like any cancelled session: it ends on SIGTERM.
|
|
1046
2076
|
signal_group(pid, "TERM")
|
|
@@ -1067,7 +2097,7 @@ module ActionAgent
|
|
|
1067
2097
|
unless finished
|
|
1068
2098
|
# Cancelled, and SIGTERM did not end it within the grace: SIGKILL,
|
|
1069
2099
|
# and the session finishes like any cancelled one.
|
|
1070
|
-
raise Error, "
|
|
2100
|
+
raise Error, "#{label} did not finish within #{timeout}s and was stopped" unless cancel_seen
|
|
1071
2101
|
|
|
1072
2102
|
stop_groups([ pid ], grace: 0)
|
|
1073
2103
|
end
|
|
@@ -1091,11 +2121,11 @@ module ActionAgent
|
|
|
1091
2121
|
|
|
1092
2122
|
# Why a session must not start: a terminate under way, or a cancel that
|
|
1093
2123
|
# came before there was a process to stop.
|
|
1094
|
-
def refuse_stopped_session!(state, key)
|
|
1095
|
-
raise Error, "The sandbox is being stopped, so
|
|
2124
|
+
def refuse_stopped_session!(state, key, label: "Claude Code")
|
|
2125
|
+
raise Error, "The sandbox is being stopped, so #{label} did not run" if state["terminating"]
|
|
1096
2126
|
|
|
1097
2127
|
cancels = state["cancelled_code_sessions"]
|
|
1098
|
-
raise Error, "
|
|
2128
|
+
raise Error, "#{label} session #{key} was cancelled before it started" if cancels.is_a?(Hash) && cancels.key?(key)
|
|
1099
2129
|
end
|
|
1100
2130
|
|
|
1101
2131
|
# Records Claude Code's pid (and start time) under the state.json lock,
|
|
@@ -1227,20 +2257,17 @@ module ActionAgent
|
|
|
1227
2257
|
app = workspace.join("app")
|
|
1228
2258
|
env = self.class.sanitized_environment
|
|
1229
2259
|
git = git_command(app)
|
|
1230
|
-
#
|
|
1231
|
-
|
|
1232
|
-
# dashboard's user. Rather than run it, report no diff.
|
|
1233
|
-
drivers, = capture(env, [ *git, "config", "--local", "--includes", "--name-only", "--get-regexp", "^filter\\." ],
|
|
1234
|
-
chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
|
|
1235
|
-
if drivers.to_s.strip.present?
|
|
2260
|
+
# `git add` and `git diff` would run a filter driver's command.
|
|
2261
|
+
if filter_drivers?(app, env, git)
|
|
1236
2262
|
return "(diff not recorded: the checkout's git config defines filter drivers, which would run commands)"
|
|
1237
2263
|
end
|
|
1238
|
-
|
|
2264
|
+
exclusions = SandboxCredentialPaths.pathspecs
|
|
2265
|
+
capture(env, [ *git, "add", "--intent-to-add", "--all", "--", ".", *exclusions ], chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
|
|
1239
2266
|
|
|
1240
2267
|
base = read_state(workspace)["checkout_commit"]
|
|
1241
2268
|
base = "HEAD" unless base.is_a?(String) && COMMIT_ID.match?(base)
|
|
1242
2269
|
diff = ->(commit) do
|
|
1243
|
-
capture(env, [ *git, "diff", "--no-color", "--no-ext-diff", "--no-textconv", commit, "--" ],
|
|
2270
|
+
capture(env, [ *git, "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--ignore-submodules=dirty", commit, "--", ".", *exclusions ],
|
|
1244
2271
|
chdir: app, limit: MAX_DIFF_BYTES, timeout: GIT_TIMEOUT)
|
|
1245
2272
|
end
|
|
1246
2273
|
output, status = diff.call(base)
|
|
@@ -1263,24 +2290,28 @@ module ActionAgent
|
|
|
1263
2290
|
# The Claude Code variables a session runs with. An API key comes from
|
|
1264
2291
|
# the owner's connection. The machine's own login needs none: `claude`
|
|
1265
2292
|
# finds it itself, and the dashboard neither reads nor passes it on.
|
|
1266
|
-
def session_credentials(sandbox)
|
|
1267
|
-
return {} if ClaudeCodeAuth.local_login?
|
|
2293
|
+
def session_credentials(sandbox, runner: "claude_code")
|
|
2294
|
+
return {} if runner == "claude_code" && ClaudeCodeAuth.local_login?
|
|
1268
2295
|
|
|
1269
|
-
credentials = sandbox.runtime_environment.to_h
|
|
2296
|
+
credentials = (runner == "codex" ? sandbox.runtime_environment(runner: runner) : sandbox.runtime_environment).to_h
|
|
1270
2297
|
if credentials.empty?
|
|
1271
|
-
|
|
2298
|
+
label = runner == "codex" ? "Codex is not connected: connect an OpenAI API key" : "Claude Code is not connected: connect an Anthropic API key"
|
|
2299
|
+
raise Error, "#{label} in Settings → Integrations"
|
|
1272
2300
|
end
|
|
1273
2301
|
|
|
1274
2302
|
credentials
|
|
1275
2303
|
end
|
|
1276
2304
|
|
|
2305
|
+
# What a code session's output is scrubbed of. The project's secrets are
|
|
2306
|
+
# among them although the session never gets them: the checkout's own
|
|
2307
|
+
# files and processes can still show them.
|
|
1277
2308
|
def sandbox_secrets(sandbox, credentials)
|
|
1278
2309
|
token = begin
|
|
1279
2310
|
sandbox.checkout_spec&.dig(:token)
|
|
1280
2311
|
rescue StandardError
|
|
1281
2312
|
nil
|
|
1282
2313
|
end
|
|
1283
|
-
[ token, *credentials.values ].compact.map(&:to_s)
|
|
2314
|
+
[ token, *credentials.values, *Array(sandbox.try(:project_scrub_values)) ].compact.map(&:to_s)
|
|
1284
2315
|
end
|
|
1285
2316
|
|
|
1286
2317
|
# Drops the session's pid, and the cancels it may have left.
|
|
@@ -1299,6 +2330,133 @@ module ActionAgent
|
|
|
1299
2330
|
status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
|
|
1300
2331
|
end
|
|
1301
2332
|
|
|
2333
|
+
# --- Browser ------------------------------------------------------------
|
|
2334
|
+
|
|
2335
|
+
BROWSER_DIR = /\Abrowser-\h+\z/
|
|
2336
|
+
|
|
2337
|
+
# Spawns the sidecar in a directory of its own under the workspace, hands
|
|
2338
|
+
# it its configuration and waits for the line it prints once it listens.
|
|
2339
|
+
def run_browser(workspace, session_id, mode, launch)
|
|
2340
|
+
dir = workspace.join("browser-#{SecureRandom.hex(4)}")
|
|
2341
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
2342
|
+
secrets = [ launch[:token], launch.dig(:recording, :token) ].compact
|
|
2343
|
+
env = self.class.sanitized_environment.merge(BrowserSidecar.environment).merge(SESSION_ID_ENV => session_id)
|
|
2344
|
+
log = log_path(workspace, "browser")
|
|
2345
|
+
FileUtils.mkdir_p(log.dirname)
|
|
2346
|
+
stdin_read, stdin_write = IO.pipe
|
|
2347
|
+
stdout_read, stdout_write = IO.pipe
|
|
2348
|
+
|
|
2349
|
+
begin
|
|
2350
|
+
pid = spawn_group(env, *BrowserSidecar.command("serve"), chdir: dir, in: stdin_read, out: stdout_write, err: [ log.to_s, "a" ])
|
|
2351
|
+
rescue SystemCallError => e
|
|
2352
|
+
raise Error, "Could not start the browser sidecar (#{ActionAgent.node_command}): #{e.message}"
|
|
2353
|
+
ensure
|
|
2354
|
+
[ stdin_read, stdout_write ].each(&:close)
|
|
2355
|
+
end
|
|
2356
|
+
Process.detach(pid)
|
|
2357
|
+
browser = { "pid" => pid, "dir" => dir.basename.to_s, "mode" => mode }
|
|
2358
|
+
started = false
|
|
2359
|
+
|
|
2360
|
+
begin
|
|
2361
|
+
terminating = nil
|
|
2362
|
+
update_state(workspace, create: false) do |state|
|
|
2363
|
+
state["browser"] = browser
|
|
2364
|
+
record_process_start(state, pid)
|
|
2365
|
+
terminating = state["terminating"]
|
|
2366
|
+
end
|
|
2367
|
+
raise Error, "The sandbox is being stopped, so its browser did not start" if terminating
|
|
2368
|
+
|
|
2369
|
+
begin
|
|
2370
|
+
stdin_write.write(JSON.generate(browser_config(launch, mode, dir)))
|
|
2371
|
+
stdin_write.close
|
|
2372
|
+
rescue Errno::EPIPE
|
|
2373
|
+
# It exited before reading its configuration; its log says why.
|
|
2374
|
+
end
|
|
2375
|
+
ready = await_browser_ready(stdout_read, log, secrets)
|
|
2376
|
+
unless BrowserSidecar.acceptable_version?(ready["version"].to_s)
|
|
2377
|
+
raise Error, "The browser sidecar is version #{ready["version"]}, and this dashboard needs #{ActionAgent::VERSION}; " \
|
|
2378
|
+
"run #{BrowserSidecar::INSTALL_COMMAND}"
|
|
2379
|
+
end
|
|
2380
|
+
|
|
2381
|
+
port = Integer(ready["port"])
|
|
2382
|
+
update_state(workspace, create: false) { |state| state["browser"]["port"] = port if state["browser"].is_a?(Hash) }
|
|
2383
|
+
started = true
|
|
2384
|
+
{ mcp_url: "http://127.0.0.1:#{port}/mcp", mcp_token: launch[:token], live_url: ("ws://127.0.0.1:#{port}/live" if launch[:live]) }.compact
|
|
2385
|
+
rescue Errno::ENOENT
|
|
2386
|
+
raise Error, "Sandbox #{session_id} was stopped while its browser started"
|
|
2387
|
+
ensure
|
|
2388
|
+
[ stdin_write, stdout_read ].each { |io| io.close unless io.closed? }
|
|
2389
|
+
unless started
|
|
2390
|
+
stop_groups([ pid ], grace: 1)
|
|
2391
|
+
forget_browser(workspace, browser)
|
|
2392
|
+
end
|
|
2393
|
+
end
|
|
2394
|
+
end
|
|
2395
|
+
|
|
2396
|
+
def browser_config(launch, mode, dir)
|
|
2397
|
+
recording = launch[:recording]
|
|
2398
|
+
{
|
|
2399
|
+
token: launch[:token],
|
|
2400
|
+
app_url: launch[:app_url],
|
|
2401
|
+
mode: mode,
|
|
2402
|
+
capabilities: Array(launch[:capabilities]).map(&:to_s),
|
|
2403
|
+
host: "127.0.0.1",
|
|
2404
|
+
port: 0,
|
|
2405
|
+
workdir: dir.to_s,
|
|
2406
|
+
stop_at: launch[:stop_at] && (launch[:stop_at].to_f * 1000).floor,
|
|
2407
|
+
recording: recording && recording.slice(:url, :token, :batch_events, :batch_bytes),
|
|
2408
|
+
live: launch[:live] && launch[:live].slice(:session_id, :origins),
|
|
2409
|
+
storage_state: launch[:storage_state]
|
|
2410
|
+
}.compact
|
|
2411
|
+
end
|
|
2412
|
+
|
|
2413
|
+
# The JSON line the sidecar prints once it listens. Raises with the tail
|
|
2414
|
+
# of its log when it exits first or takes longer than
|
|
2415
|
+
# ActionAgent.browser_start_timeout.
|
|
2416
|
+
def await_browser_ready(io, log, secrets)
|
|
2417
|
+
deadline = deadline_after(ActionAgent.browser_start_timeout)
|
|
2418
|
+
line = String.new(encoding: Encoding::BINARY)
|
|
2419
|
+
until line.include?("\n")
|
|
2420
|
+
left = time_left(deadline)
|
|
2421
|
+
unless left.nil? || left.positive?
|
|
2422
|
+
raise Error, "The browser did not start within #{ActionAgent.browser_start_timeout}s#{browser_log_tail(log, secrets)}"
|
|
2423
|
+
end
|
|
2424
|
+
next unless io.wait_readable([ left || POLL_INTERVAL, POLL_INTERVAL ].min)
|
|
2425
|
+
|
|
2426
|
+
chunk = io.read_nonblock(4096, exception: false)
|
|
2427
|
+
raise Error, "The browser sidecar exited before it started#{browser_log_tail(log, secrets)}" if chunk.nil?
|
|
2428
|
+
|
|
2429
|
+
line << chunk unless chunk == :wait_readable
|
|
2430
|
+
end
|
|
2431
|
+
|
|
2432
|
+
ready = JSON.parse(line.lines.first)
|
|
2433
|
+
raise Error, "The browser sidecar answered #{line.lines.first.strip.inspect}" unless ready.is_a?(Hash) && ready["ready"]
|
|
2434
|
+
|
|
2435
|
+
ready
|
|
2436
|
+
rescue JSON::ParserError
|
|
2437
|
+
raise Error, "The browser sidecar answered #{line.lines.first.to_s.strip.truncate(200).inspect}"
|
|
2438
|
+
end
|
|
2439
|
+
|
|
2440
|
+
def browser_log_tail(log, secrets)
|
|
2441
|
+
tail = log_tail(log, secrets)
|
|
2442
|
+
tail.present? ? ":\n#{tail}" : ""
|
|
2443
|
+
end
|
|
2444
|
+
|
|
2445
|
+
# Removes the browser's directory and its entry in state.json, if the
|
|
2446
|
+
# entry is still +browser+'s.
|
|
2447
|
+
def forget_browser(workspace, browser)
|
|
2448
|
+
dir = browser["dir"].to_s
|
|
2449
|
+
FileUtils.rm_rf(workspace.join(dir)) if BROWSER_DIR.match?(dir)
|
|
2450
|
+
update_state(workspace, create: false) do |state|
|
|
2451
|
+
next unless state["browser"].is_a?(Hash) && state["browser"]["pid"] == browser["pid"]
|
|
2452
|
+
|
|
2453
|
+
state.delete("browser")
|
|
2454
|
+
state_hash(state, "process_starts").delete(browser["pid"].to_s)
|
|
2455
|
+
end
|
|
2456
|
+
rescue Errno::ENOENT
|
|
2457
|
+
# Terminated meanwhile: the workspace, and its state, are gone.
|
|
2458
|
+
end
|
|
2459
|
+
|
|
1302
2460
|
# --- Processes ----------------------------------------------------------
|
|
1303
2461
|
|
|
1304
2462
|
# Every sandbox process leads its own process group, so stopping one
|
|
@@ -1491,11 +2649,14 @@ module ActionAgent
|
|
|
1491
2649
|
{}
|
|
1492
2650
|
end
|
|
1493
2651
|
sessions = state["code_sessions"].is_a?(Hash) ? state["code_sessions"].values : []
|
|
1494
|
-
|
|
2652
|
+
browser = state["browser"]["pid"] if state["browser"].is_a?(Hash)
|
|
2653
|
+
recorded = [ state["pid"], state["step_pid"], browser, *sessions ].uniq.select { |pid| signalable?(pid) }
|
|
1495
2654
|
identities = recorded.index_with { |pid| group_identity(pid, session_id, state) }
|
|
1496
2655
|
stop_groups(recorded.select { |pid| identities[pid] == :ours })
|
|
1497
2656
|
stop_escaped(session_id)
|
|
1498
2657
|
|
|
2658
|
+
logout_claude_workspace(workspace) if state["claude_login_pid"] || ClaudeCodeAuth.sandbox_login?
|
|
2659
|
+
|
|
1499
2660
|
left = recorded.select { |pid| identities[pid] != :stranger && group_alive?(pid) }
|
|
1500
2661
|
if left.any?
|
|
1501
2662
|
Rails.logger.error("[ActionAgent] sandbox #{session_id}: process groups #{left.join(", ")} are still alive and " \
|