actionagent 1.8.1 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
  18. data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
  19. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  20. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  21. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  22. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  23. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  24. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  25. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  26. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  27. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  28. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  29. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  30. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  31. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  32. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  33. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  34. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  35. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  36. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  37. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  38. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  39. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  40. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  41. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  42. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  43. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  44. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  45. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  46. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  47. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  48. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  49. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  50. data/app/jobs/action_agent/code_session_job.rb +26 -2
  51. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  52. data/app/jobs/action_agent/exploration_job.rb +80 -0
  53. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  54. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  55. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  56. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  57. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  58. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  59. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  60. data/app/models/action_agent/agent.rb +39 -26
  61. data/app/models/action_agent/agent_run.rb +134 -23
  62. data/app/models/action_agent/agent_template.rb +35 -1
  63. data/app/models/action_agent/api_key.rb +14 -0
  64. data/app/models/action_agent/catalog_scenario.rb +75 -0
  65. data/app/models/action_agent/code_session.rb +16 -4
  66. data/app/models/action_agent/draft_pull_request.rb +131 -0
  67. data/app/models/action_agent/evaluation.rb +84 -0
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/interaction_feedback.rb +24 -0
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +200 -3
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +297 -14
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  85. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  86. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  87. data/app/services/action_agent/agent_execution_service.rb +450 -72
  88. data/app/services/action_agent/agent_sync.rb +5 -2
  89. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  90. data/app/services/action_agent/agent_toolbox.rb +215 -9
  91. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  92. data/app/services/action_agent/browser_sidecar.rb +213 -0
  93. data/app/services/action_agent/browser_sign_in.rb +163 -0
  94. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  95. data/app/services/action_agent/claude_code_auth.rb +70 -4
  96. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  97. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  98. data/app/services/action_agent/evaluation_fix.rb +101 -0
  99. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  100. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  101. data/app/services/action_agent/evaluation_standing.rb +15 -8
  102. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  103. data/app/services/action_agent/github_client.rb +405 -64
  104. data/app/services/action_agent/live_updates.rb +31 -0
  105. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  106. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  107. data/app/services/action_agent/mcp_client.rb +6 -2
  108. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  109. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  110. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  111. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  112. data/app/services/action_agent/project_boot_fix.rb +309 -0
  113. data/app/services/action_agent/project_capabilities.rb +200 -0
  114. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  115. data/app/services/action_agent/project_preflight.rb +178 -0
  116. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  117. data/app/services/action_agent/project_setup.rb +429 -0
  118. data/app/services/action_agent/provider_credentials.rb +102 -0
  119. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  120. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  121. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  122. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  123. data/app/services/action_agent/sandbox_browser.rb +299 -0
  124. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  125. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  126. data/app/services/action_agent/sandbox_mail.rb +103 -0
  127. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  128. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  129. data/app/services/action_agent/sandbox_patch.rb +253 -0
  130. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  131. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  132. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  133. data/app/services/action_agent/secret_scrubber.rb +33 -4
  134. data/app/services/action_agent/session_index.rb +270 -0
  135. data/app/services/action_agent/session_recording_service.rb +11 -0
  136. data/app/services/action_agent/session_timeline.rb +505 -0
  137. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  138. data/app/views/action_agent/session_player/show.html.erb +19 -0
  139. data/config/routes.rb +155 -1
  140. data/lib/action_agent/claude_login_process.rb +107 -0
  141. data/lib/action_agent/engine.rb +55 -5
  142. data/lib/action_agent/secret_requests.rb +80 -0
  143. data/lib/action_agent/version.rb +1 -1
  144. data/lib/action_agent.rb +507 -22
  145. data/lib/generators/action_agent/install_generator.rb +37 -0
  146. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  147. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  148. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  149. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  150. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  151. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  152. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  153. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  154. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  155. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  156. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  157. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  158. data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
  159. data/lib/tasks/action_agent.rake +33 -0
  160. data/lib/tasks/browser.rake +22 -0
  161. data/lib/tasks/sandbox.rake +1 -1
  162. data/lib/tasks/scenario_catalogs.rake +71 -0
  163. metadata +100 -2
@@ -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, and
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.
@@ -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, and waits
366
- # until its MCP facade answers.
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
@@ -440,6 +589,45 @@ module ActionAgent
440
589
  %w[claude_code codex]
441
590
  end
442
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
+
443
631
  # Runs a coding agent headless in the sandbox's checkout, yielding each
444
632
  # stream-json event (a Hash, already scrubbed of the sandbox's secrets)
445
633
  # as it arrives.
@@ -455,7 +643,13 @@ module ActionAgent
455
643
  runner = code_session.try(:runner) || "claude_code"
456
644
  raise Error, "Unsupported code runner: #{runner}" unless code_runners.include?(runner)
457
645
 
458
- credentials = session_credentials(sandbox, runner: runner)
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)
459
653
  secrets = sandbox_secrets(sandbox, credentials)
460
654
  argv = runner == "codex" ? codex_argv(code_session) : claude_argv(code_session)
461
655
  database_env = read_state(workspace)["database_env"]
@@ -473,12 +667,17 @@ module ActionAgent
473
667
  # own, in the workspace. With the machine's own login it must use the
474
668
  # user's: that is where `claude /login` left the credentials (HOME,
475
669
  # which the sanitized environment keeps, or the keychain).
476
- if runner == "codex"
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"
477
675
  env["CODEX_HOME"] = workspace.join("codex").to_s
478
676
  FileUtils.mkdir_p(workspace.join("codex"), mode: 0o700)
479
677
  elsif !ClaudeCodeAuth.local_login?
480
- env["CLAUDE_CONFIG_DIR"] = workspace.join("claude").to_s
481
- FileUtils.mkdir_p(workspace.join("claude"), mode: 0o700)
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)
482
681
  end
483
682
 
484
683
  run_claude(workspace, code_session, argv, env, secrets, runner: runner, &on_event)
@@ -522,8 +721,177 @@ module ActionAgent
522
721
  true
523
722
  end
524
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
+
525
865
  private
526
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
+
527
895
  def ensure_enabled!
528
896
  return if ActionAgent.local_sandboxes_enabled?
529
897
 
@@ -531,28 +899,314 @@ module ActionAgent
531
899
  "set ActionAgent.local_sandboxes_enabled = true to allow them"
532
900
  end
533
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
+
534
1074
  # --- Boot -------------------------------------------------------------
535
1075
 
536
- def boot(session_id, spec, secrets)
1076
+ def boot(session_id, checkout, secrets, boot_spec = nil)
537
1077
  workspace = workspace_for(session_id)
538
- deadline = deadline_after(ActionAgent.local_sandbox_boot_timeout)
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)
539
1084
  booted = false
540
- server_pid = nil
1085
+ keep = false
541
1086
 
542
1087
  prepare_workspace(workspace)
543
- checkout!(workspace, spec, secrets, deadline)
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) }
544
1092
 
545
1093
  app = workspace.join("app")
546
- config = Config.load(app)
547
- databases = assign_databases(workspace, app, config, spec)
548
- env = self.class.sanitized_environment.merge(databases).merge(config.env).merge(
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(
549
1186
  # Merged after the file's env, so a checkout cannot move them.
550
1187
  SandboxManifest::PATH_ENV => workspace.join("runtime.json").to_s,
551
- SESSION_ID_ENV => session_id
1188
+ SESSION_ID_ENV => workspace.basename.to_s,
1189
+ SandboxMail::DIRECTORY_ENV => SandboxMail::DIRECTORY
552
1190
  )
1191
+ end
553
1192
 
554
- config.setup.each do |command|
555
- run_step!(workspace, "setup", command, env: env, chdir: app, deadline: deadline, secrets: secrets)
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
1206
+
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
556
1210
  end
557
1211
 
558
1212
  # Picked after setup, which can take minutes, so that the port is
@@ -561,33 +1215,305 @@ module ActionAgent
561
1215
  # of the server's own process group.
562
1216
  port = free_port
563
1217
  env = env.merge("PORT" => port.to_s)
564
- manifest = run_manifest!(workspace, config, env, deadline, secrets)
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
565
1224
 
566
- server_pid, waiter = start_server(workspace, config, env, port)
567
- wait_until_ready!(workspace, port, manifest, server_pid, waiter, deadline, secrets)
568
- booted = true
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
569
1231
 
570
1232
  {
571
- container_name: "#{HANDLE_PREFIX}#{session_id}",
1233
+ container_name: "#{HANDLE_PREFIX}#{workspace.basename}",
572
1234
  url: "http://127.0.0.1:#{port}",
573
1235
  container_ip: "127.0.0.1",
574
1236
  mcp_url: "http://127.0.0.1:#{port}#{manifest["mcp_path"]}",
575
1237
  mcp_token: manifest["mcp_token"],
1238
+ app_models: manifest["models"],
576
1239
  created_at: Time.current
577
1240
  }
578
- rescue Error
579
- raise
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 }
580
1462
  rescue StandardError => e
581
- raise Error, SecretScrubber.scrub("Sandbox boot failed: #{e.class.name}: #{e.message}", secrets)
582
- ensure
583
- # A failed boot leaves nothing running and nothing on disk: no handle
584
- # was reported, so nothing would ever reap it. The error carries the
585
- # failing step's log tail.
586
- unless booted
587
- stop_groups([ server_pid ].compact)
588
- # A setup that got as far as db:prepare created the databases.
589
- drop_databases(workspace) if workspace
590
- remove_workspace(workspace) if workspace
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)"
591
1517
  end
592
1518
  end
593
1519
 
@@ -596,9 +1522,9 @@ module ActionAgent
596
1522
  # or after a restart, drops what is recorded there. Claude Code sessions
597
1523
  # get them too, so a `bin/rails db:migrate` a session runs lands in the
598
1524
  # sandbox's database rather than the developer's.
599
- def assign_databases(workspace, app, config, spec)
1525
+ def assign_databases(workspace, app, overrides, spec)
600
1526
  plan = LocalSandboxDatabases.plan(
601
- app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides: config.env,
1527
+ app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides: overrides,
602
1528
  fallback_name: spec[:repository].to_s.split("/").last
603
1529
  )
604
1530
  if plan.notes.any?
@@ -632,11 +1558,17 @@ module ActionAgent
632
1558
  app = workspace.join("app")
633
1559
  return unless app.join("bin", "rails").file?
634
1560
 
635
- file_env = begin
636
- Config.load(app).env
637
- rescue Error
638
- {}
639
- end
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
640
1572
  env = self.class.sanitized_environment.merge(file_env).merge(database_env.transform_values(&:to_s)).merge(
641
1573
  SESSION_ID_ENV => workspace.basename.to_s,
642
1574
  "RAILS_ENV" => state.fetch("database_rails_env"),
@@ -692,13 +1624,22 @@ module ActionAgent
692
1624
  update_state(workspace) { |state| state["checkout_commit"] = commit } if status&.success? && COMMIT_ID.match?(commit)
693
1625
  end
694
1626
 
695
- def run_manifest!(workspace, config, env, deadline, secrets)
1627
+ def run_manifest!(workspace, step, env, deadline, secrets)
696
1628
  path = workspace.join("runtime.json")
697
1629
  FileUtils.rm_f(path)
698
- run_step!(workspace, "manifest", config.manifest, env: env, chdir: workspace.join("app"), deadline: deadline, secrets: secrets)
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)
699
1632
 
700
- log = log_path(workspace, "manifest")
701
- fail_step!("manifest", "`#{config.manifest}` wrote nothing to $#{SandboxManifest::PATH_ENV}", log, secrets) unless path.file?
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?
1635
+
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?
702
1643
 
703
1644
  # Written by the checkout's command, with its umask: the MCP token in
704
1645
  # it is for the dashboard alone.
@@ -707,48 +1648,78 @@ module ActionAgent
707
1648
  URI.parse("http://127.0.0.1#{manifest["mcp_path"]}")
708
1649
  manifest
709
1650
  rescue SandboxManifest::Error, URI::InvalidURIError => e
710
- fail_step!("manifest", e.message, log_path(workspace, "manifest"), secrets)
1651
+ fail_step!(step.label, e.message, log, secrets)
711
1652
  end
712
1653
 
713
1654
  # Runs one boot command to completion in its own process group, with its
714
- # output in logs/<step>.log. Anything it left running in that group is
715
- # stopped too: a setup command is not a way to start services the
716
- # sandbox never records (that is what `start` is for).
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+.
717
1660
  #
718
- # The group is stopped however the wait ends: the deadline, or any
1661
+ # The group is stopped however the wait ends: a deadline, or any
719
1662
  # exception at all (a worker shutting down raises into this thread with
720
1663
  # Thread#raise, which is no StandardError). While it runs its pid is in
721
1664
  # state.json as step_pid, so a terminate after this process itself died
722
1665
  # (a crashed dashboard, a killed worker) stops a hung `bundle install`
723
1666
  # too; nothing else would, the deadline having died with this process.
724
- def run_step!(workspace, step, command, env:, chdir:, deadline:, secrets:, label: command)
725
- log = log_path(workspace, step)
726
- 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
727
1671
 
728
1672
  pid = nil
729
1673
  waiter = nil
730
1674
  finished = false
1675
+ reader, writer = IO.pipe
1676
+ copier = nil
731
1677
  # Deferred until the group is recorded or stopped, so an interrupt
732
1678
  # never lands between the spawn and the ensure that stops it.
733
1679
  Thread.handle_interrupt(Object => :never) do
734
- pid = spawn_group(env, "sh", "-c", command, chdir: chdir, in: File::NULL, out: [ log.to_s, "a" ], err: [ :child, :out ])
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
735
1685
  begin
736
1686
  Thread.handle_interrupt(Object => :immediate) do
737
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) }
738
1691
  waiter = Process.detach(pid)
739
- finished = waiter.join(time_left(deadline))
1692
+ finished = waiter.join(time_left(until_time))
740
1693
  end
741
1694
  ensure
742
1695
  stop_groups([ pid ], grace: finished ? 1 : stop_grace)
743
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)
744
1699
  end
745
1700
  end
746
1701
 
747
1702
  unless finished
748
- fail_step!(step, "`#{label}` did not finish within the boot timeout (#{ActionAgent.local_sandbox_boot_timeout}s)", log, secrets)
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)
749
1705
  end
750
1706
  status = waiter.value
751
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.
752
1723
  end
753
1724
 
754
1725
  def record_step(workspace, pid)
@@ -769,12 +1740,16 @@ module ActionAgent
769
1740
  # The workspace is gone: nothing left to forget it in.
770
1741
  end
771
1742
 
772
- def start_server(workspace, config, env, port)
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)
773
1747
  log = log_path(workspace, "server")
774
- File.open(log, "a") { |file| file.puts("$ #{config.start}") }
1748
+ File.open(log, "a") { |file| file.puts("$ #{command}") }
775
1749
 
776
- pid = spawn_group(env, "sh", "-c", config.start,
1750
+ pid = spawn_group(env, "sh", "-c", command,
777
1751
  chdir: workspace.join("app"), in: File::NULL, out: [ log.to_s, "a" ], err: [ :child, :out ])
1752
+ @server_pid = pid
778
1753
  # Reaped by this thread for as long as the dashboard lives; after a
779
1754
  # restart the recorded pid is all that is left, hence state.json.
780
1755
  waiter = Process.detach(pid)
@@ -798,7 +1773,7 @@ module ActionAgent
798
1773
  # server this sandbox started. The port was free when it was picked, but
799
1774
  # nothing held it after: another process can bind it first, and then
800
1775
  # answers in the server's place — and would be handed the MCP token.
801
- 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)
802
1777
  log = log_path(workspace, "server")
803
1778
  mcp_path = manifest["mcp_path"]
804
1779
  uri = URI.parse("http://127.0.0.1:#{port}#{mcp_path}")
@@ -808,7 +1783,7 @@ module ActionAgent
808
1783
  loop do
809
1784
  unless waiter.alive?
810
1785
  taken = foreign ? " (another process was listening on port #{port})" : ""
811
- fail_step!("server", "the server exited with #{describe(waiter.value)} before it answered on port #{port}#{taken}",
1786
+ fail_step!(step.label, "the server exited with #{describe(waiter.value)} before it answered on port #{port}#{taken}",
812
1787
  log, secrets)
813
1788
  end
814
1789
 
@@ -826,13 +1801,38 @@ module ActionAgent
826
1801
  elsif last_status then " (last answer: #{last_status})"
827
1802
  else ""
828
1803
  end
829
- fail_step!("server", "the server did not answer #{mcp_path} on port #{port} within the boot timeout " \
830
- "(#{ActionAgent.local_sandbox_boot_timeout}s)#{answered}", log, secrets)
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)
831
1806
  end
832
1807
  sleep POLL_INTERVAL
833
1808
  end
834
1809
  end
835
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
+
836
1836
  # Whether what listens on +uri+'s port is the sandbox's server (process
837
1837
  # group +pgid+). Asked of the system where it can say (/proc on Linux,
838
1838
  # lsof elsewhere); the token is never sent before. Where it cannot say,
@@ -938,14 +1938,14 @@ module ActionAgent
938
1938
  nil
939
1939
  end
940
1940
 
941
- # The HTTP status a GET on the MCP path answers with, or nil while
942
- # nothing answers.
943
- 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)
944
1944
  # No proxy: the dashboard's HTTP(S)_PROXY does not know this loopback.
945
1945
  http = Net::HTTP.new(uri.host, uri.port, nil)
946
1946
  http.open_timeout = 1
947
- http.read_timeout = 2
948
- http.start { |connection| connection.request(Net::HTTP::Get.new(uri, "Accept" => "application/json")).code.to_i }
1947
+ http.read_timeout = read_timeout
1948
+ http.start { |connection| connection.request(Net::HTTP::Get.new(uri, "Accept" => accept)).code.to_i }
949
1949
  rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse
950
1950
  nil
951
1951
  end
@@ -1002,6 +2002,11 @@ module ActionAgent
1002
2002
  # that would prompt is denied instead of stalling the session.
1003
2003
  argv += [ "--permission-prompts", "none" ] if permission_prompts_supported?(command)
1004
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?
1005
2010
 
1006
2011
  if (model = code_session.model.presence)
1007
2012
  # One argv entry, but one that must not read as a flag.
@@ -1252,20 +2257,17 @@ module ActionAgent
1252
2257
  app = workspace.join("app")
1253
2258
  env = self.class.sanitized_environment
1254
2259
  git = git_command(app)
1255
- # A filter driver in the checkout's git config (which the session could
1256
- # have written) runs its command on `git add` and `git diff`, as the
1257
- # dashboard's user. Rather than run it, report no diff.
1258
- drivers, = capture(env, [ *git, "config", "--local", "--includes", "--name-only", "--get-regexp", "^filter\\." ],
1259
- chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
1260
- 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)
1261
2262
  return "(diff not recorded: the checkout's git config defines filter drivers, which would run commands)"
1262
2263
  end
1263
- capture(env, [ *git, "add", "--intent-to-add", "--all" ], chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
2264
+ exclusions = SandboxCredentialPaths.pathspecs
2265
+ capture(env, [ *git, "add", "--intent-to-add", "--all", "--", ".", *exclusions ], chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
1264
2266
 
1265
2267
  base = read_state(workspace)["checkout_commit"]
1266
2268
  base = "HEAD" unless base.is_a?(String) && COMMIT_ID.match?(base)
1267
2269
  diff = ->(commit) do
1268
- 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 ],
1269
2271
  chdir: app, limit: MAX_DIFF_BYTES, timeout: GIT_TIMEOUT)
1270
2272
  end
1271
2273
  output, status = diff.call(base)
@@ -1300,13 +2302,16 @@ module ActionAgent
1300
2302
  credentials
1301
2303
  end
1302
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.
1303
2308
  def sandbox_secrets(sandbox, credentials)
1304
2309
  token = begin
1305
2310
  sandbox.checkout_spec&.dig(:token)
1306
2311
  rescue StandardError
1307
2312
  nil
1308
2313
  end
1309
- [ token, *credentials.values ].compact.map(&:to_s)
2314
+ [ token, *credentials.values, *Array(sandbox.try(:project_scrub_values)) ].compact.map(&:to_s)
1310
2315
  end
1311
2316
 
1312
2317
  # Drops the session's pid, and the cancels it may have left.
@@ -1325,6 +2330,133 @@ module ActionAgent
1325
2330
  status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
1326
2331
  end
1327
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
+
1328
2460
  # --- Processes ----------------------------------------------------------
1329
2461
 
1330
2462
  # Every sandbox process leads its own process group, so stopping one
@@ -1517,11 +2649,14 @@ module ActionAgent
1517
2649
  {}
1518
2650
  end
1519
2651
  sessions = state["code_sessions"].is_a?(Hash) ? state["code_sessions"].values : []
1520
- recorded = [ state["pid"], state["step_pid"], *sessions ].uniq.select { |pid| signalable?(pid) }
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) }
1521
2654
  identities = recorded.index_with { |pid| group_identity(pid, session_id, state) }
1522
2655
  stop_groups(recorded.select { |pid| identities[pid] == :ours })
1523
2656
  stop_escaped(session_id)
1524
2657
 
2658
+ logout_claude_workspace(workspace) if state["claude_login_pid"] || ClaudeCodeAuth.sandbox_login?
2659
+
1525
2660
  left = recorded.select { |pid| identities[pid] != :stranger && group_alive?(pid) }
1526
2661
  if left.any?
1527
2662
  Rails.logger.error("[ActionAgent] sandbox #{session_id}: process groups #{left.join(", ")} are still alive and " \