actionagent 1.7.2 → 1.8.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 (69) 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 +59 -54
  4. data/app/controllers/action_agent/api/agents_controller.rb +19 -3
  5. data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
  6. data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
  7. data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
  8. data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
  9. data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
  13. data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
  15. data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
  16. data/app/jobs/action_agent/code_session_job.rb +171 -0
  17. data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
  18. data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
  19. data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
  20. data/app/models/action_agent/agent.rb +49 -6
  21. data/app/models/action_agent/agent_run.rb +20 -1
  22. data/app/models/action_agent/code_session.rb +145 -0
  23. data/app/models/action_agent/evaluation.rb +64 -0
  24. data/app/models/action_agent/evaluation_run.rb +166 -54
  25. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  26. data/app/models/action_agent/github_connection.rb +75 -0
  27. data/app/models/action_agent/model_pricing.rb +214 -34
  28. data/app/models/action_agent/provider_key.rb +148 -10
  29. data/app/models/action_agent/sandbox_session.rb +194 -17
  30. data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
  31. data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
  32. data/app/services/action_agent/agent_execution_service.rb +4 -2
  33. data/app/services/action_agent/agent_scorecard.rb +50 -21
  34. data/app/services/action_agent/agent_tool_roster.rb +20 -9
  35. data/app/services/action_agent/claude_code_auth.rb +86 -0
  36. data/app/services/action_agent/codex_session_events.rb +37 -0
  37. data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
  38. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  39. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  40. data/app/services/action_agent/evaluation_runner_service.rb +25 -5
  41. data/app/services/action_agent/evaluation_standing.rb +145 -0
  42. data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
  43. data/app/services/action_agent/github_client.rb +111 -0
  44. data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
  45. data/app/services/action_agent/local_sandbox_databases.rb +257 -0
  46. data/app/services/action_agent/mcp_client.rb +5 -1
  47. data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
  48. data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
  49. data/app/services/action_agent/ollama_host_probe.rb +75 -0
  50. data/app/services/action_agent/payload_bounds.rb +36 -0
  51. data/app/services/action_agent/sandbox_manifest.rb +67 -0
  52. data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
  53. data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
  54. data/app/services/action_agent/secret_scrubber.rb +37 -0
  55. data/app/services/action_agent/tool_discovery.rb +19 -5
  56. data/config/routes.rb +23 -4
  57. data/lib/action_agent/engine.rb +1 -0
  58. data/lib/action_agent/version.rb +1 -1
  59. data/lib/action_agent.rb +153 -3
  60. data/lib/generators/action_agent/install_generator.rb +35 -5
  61. data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
  62. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  63. data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
  64. data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
  66. data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
  67. data/lib/tasks/claude_code.rake +16 -0
  68. data/lib/tasks/sandbox.rake +26 -0
  69. metadata +28 -2
@@ -0,0 +1,1715 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+ require "net/http"
5
+ require "open3"
6
+ require "socket"
7
+ require "tmpdir"
8
+
9
+ module ActionAgent
10
+ # Boots app_runtime checkouts as child processes of the dashboard itself,
11
+ # with no containers: the sandbox backend for a developer's own machine
12
+ # (see ActionAgent.local_sandboxes_enabled?). Each sandbox is a workspace
13
+ # under ActionAgent.local_sandbox_root:
14
+ #
15
+ # <session_id>/
16
+ # app/ the checkout
17
+ # db/ its SQLite databases, if it uses SQLite (see
18
+ # LocalSandboxDatabases; state.json records the
19
+ # database variables it was given)
20
+ # runtime.json the manifest the checkout wrote (see SandboxManifest)
21
+ # state.json { pid, port, started_at, code_sessions: { "<id>" => pid } },
22
+ # plus the commit checked out, the boot step running
23
+ # (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
26
+ # state.lock what changes to state.json are serialized on
27
+ # logs/ checkout, setup, manifest, server and claude-<id> logs
28
+ # claude/ CLAUDE_CONFIG_DIR for Claude Code sessions (with
29
+ # ActionAgent.claude_code_auth = :api_key; with
30
+ # :local_login they use the user's own configuration)
31
+ #
32
+ # The orchestrator builds a new backend for every call, and the dashboard
33
+ # may restart while a sandbox runs, so whatever a later call needs lives in
34
+ # state.json rather than in instance variables. Calls race each other
35
+ # through that file (a cancel or a terminate while Claude Code starts), so
36
+ # it is only ever changed under an exclusive lock, and replaced whole (see
37
+ # #update_state) so a crash never leaves it half written.
38
+ #
39
+ # How a checkout boots is up to its .activeagents/sandbox.yml (see Config).
40
+ # Every process starts from a sanitized copy of the dashboard's environment
41
+ # (see .sanitized_environment), so a checkout never sees the dashboard's
42
+ # database or secrets. The GitHub token reaches only the fetch, and the
43
+ # Claude Code API key only Claude Code. With
44
+ # ActionAgent.claude_code_auth = :local_login no credential is passed at
45
+ # all: Claude Code runs on the machine's own login.
46
+ class LocalSandboxBackend
47
+ class Error < RuntimeError; end
48
+
49
+ HANDLE_PREFIX = "local-"
50
+ # Session ids are generated UUIDs; anything else is refused before it can
51
+ # name a path outside the sandbox root.
52
+ SESSION_ID = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,127}\z/
53
+ # Set in every process a sandbox starts. Where state.json has no start
54
+ # time for a recorded pid, this is how it is told apart from an
55
+ # unrelated process that later reused it.
56
+ SESSION_ID_ENV = "ACTION_AGENT_SANDBOX_SESSION_ID"
57
+ COMMIT_ID = /\A\h{40}(?:\h{24})?\z/
58
+
59
+ # A GET on the MCP path answers 405 once the engine is mounted and
60
+ # serving; 401 and 200 also mean something is up and answering there.
61
+ READY_STATUSES = [ 405, 401, 200 ].freeze
62
+ POLL_INTERVAL = 0.25
63
+ # Between SIGTERM and SIGKILL when stopping a sandbox, or a cancelled
64
+ # Claude Code session.
65
+ STOP_GRACE = 10
66
+ LOG_TAIL_LINES = 20
67
+ LOG_TAIL_BYTES = 64 * 1024
68
+
69
+ # Bounds on the git commands run in a checkout (rev-parse after the
70
+ # fetch, add and diff after a session) and on the diff a Claude Code
71
+ # session hands back. The diff limit is above CodeSession's own, so its
72
+ # truncation notice still shows.
73
+ GIT_TIMEOUT = 60
74
+ MAX_DIFF_BYTES = 1_000_000
75
+ # How long a cancel that found no Claude Code process is remembered. The
76
+ # session it names starts within seconds of being marked running, or
77
+ # never; this only keeps old ones from piling up in state.json.
78
+ CANCEL_MEMORY = 3600
79
+ # One stream-json line can carry a whole file; past this it is cut.
80
+ MAX_EVENT_LINE_BYTES = 8 * 1024 * 1024
81
+ # How long output may keep arriving after Claude Code itself exited.
82
+ OUTPUT_DRAIN_GRACE = 2
83
+ HELP_TIMEOUT = 15
84
+ PS_ENVIRONMENT = { "TZ" => "UTC", "LC_ALL" => "C", "LANG" => "C" }.freeze
85
+ # How often a running Claude Code session looks for a cancel in
86
+ # state.json.
87
+ CANCEL_CHECK_INTERVAL = 0.5
88
+ # How long terminate waits on the checkout's database cleanup.
89
+ DATABASE_DROP_TIMEOUT = 60
90
+ DATABASE_DROP_TARGETS_ENV = "ACTION_AGENT_SANDBOX_DATABASE_DROP_TARGETS"
91
+ # Runs in the checkout's Rails bundle. Filter its resolved configurations
92
+ # before either checking protection or dropping anything. A url: added
93
+ # after boot overrides DATABASE_URL in Rails, so require the recorded URL
94
+ # itself as well as its adapter/database to match. No matching config
95
+ # means no drop; legacy state without this allow-list is never inferred.
96
+ DATABASE_DROP_SCRIPT = <<~'RUBY'.freeze
97
+ require "json"
98
+ targets = JSON.parse(ENV.fetch("ACTION_AGENT_SANDBOX_DATABASE_DROP_TARGETS"))
99
+ selected = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).select do |config|
100
+ url = targets[config.name]
101
+ next false unless url.is_a?(String)
102
+
103
+ expected = ActiveRecord::DatabaseConfigurations::UrlConfig.new(Rails.env, config.name, url, {})
104
+ config.is_a?(ActiveRecord::DatabaseConfigurations::UrlConfig) && config.url == url &&
105
+ config.adapter == expected.adapter && config.database == expected.database
106
+ end
107
+ ActiveRecord::Base.configurations = selected
108
+ ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!(Rails.env)
109
+ selected.each { |config| ActiveRecord::Tasks::DatabaseTasks.drop(config) }
110
+ RUBY
111
+ MODEL_NAME = %r{\A[A-Za-z0-9][A-Za-z0-9._:/@\[\]-]{0,127}\z}
112
+ # `claude auth status`: how long its answer is trusted, how long it may
113
+ # take, and what its authMethod and apiProvider may look like to be
114
+ # shown (a short label, never free text).
115
+ LOGIN_STATUS_TTL = 60
116
+ # A logged-out answer is re-asked soon: someone who just ran
117
+ # `claude /login` clicks "Check again" and expects it to turn green.
118
+ LOGGED_OUT_STATUS_TTL = 3
119
+ LOGIN_STATUS_TIMEOUT = 10
120
+ LOGIN_LABEL = /\A[A-Za-z0-9][A-Za-z0-9._ -]{0,63}\z/
121
+ LOGGED_OUT = { logged_in: false, auth_method: nil, api_provider: nil }.freeze
122
+
123
+ # Never inherited from the dashboard: its database, its keys, and the
124
+ # Ruby/Bundler setup of its own bundle (a checkout has its own Gemfile).
125
+ DROPPED_VARIABLES = %w[
126
+ DATABASE_URL REDIS_URL SECRET_KEY_BASE RAILS_MASTER_KEY RAILS_ENV RACK_ENV PORT
127
+ BUNDLE_GEMFILE RUBYOPT RUBYLIB
128
+ SSH_AUTH_SOCK
129
+ ].freeze
130
+ DROPPED_VARIABLE_PATTERN = /
131
+ _DATABASE_URL\z | \AACTIVE_RECORD_ENCRYPTION_ | \ABUNDLER?_ |
132
+ # Where git finds a repository, and its configuration. Set when the
133
+ # dashboard runs under a git hook, and they would point the checkout's
134
+ # git at the dashboard's own repository or config.
135
+ \AGIT_(?:DIR|WORK_TREE|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|COMMON_DIR|
136
+ NAMESPACE|PREFIX|QUARANTINE_PATH|CONFIG|CONFIG_PARAMETERS|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+|
137
+ CONFIG_GLOBAL|CONFIG_SYSTEM|CONFIG_NOSYSTEM)\z |
138
+ # The dashboard's own model-provider and Claude Code settings. A
139
+ # developer often runs the dashboard from inside Claude Code, which
140
+ # exports CLAUDECODE, CLAUDE_CODE_* (its own session id among them) and
141
+ # ANTHROPIC_BASE_URL; a session inheriting those joins the developer's
142
+ # session, and a base URL redirects the owner's credential. A session
143
+ # gets exactly the Claude Code variables the backend sets.
144
+ \A(?:ANTHROPIC|CLAUDE|CODEX|OPENAI|OPEN_AI|OPENROUTER|OPEN_ROUTER|OLLAMA)(?:_|\z) | \ACLAUDECODE\z
145
+ /x
146
+ SECRET_VARIABLE = /
147
+ SECRET | TOKEN | PASSWORD | PASSWD | PASSPHRASE | API_KEY | APIKEY | PRIVATE_KEY | CREDENTIAL | ACCESS_KEY |
148
+ # DB_PASS, MYSQL_PWD, LOCKBOX_MASTER_KEY, SENTRY_DSN, SLACK_WEBHOOK_URL, GITHUB_PAT
149
+ (?:\A|_)PASS\z | (?:\A|_)PWD\z | _KEY\z | DSN\z | WEBHOOK | (?:\A|_)PAT\z
150
+ /xi
151
+ # A URL carrying credentials, whatever its name: a password
152
+ # (redis://:secret@host) or a token as the username alone
153
+ # (https://ghp_x@github.com). Any userinfo counts, anywhere in the value.
154
+ CREDENTIALED_URL = %r{[a-z][a-z0-9+.-]*://[^/\s@]+@}i
155
+
156
+ # Fetches the checkout with the token in this process's environment only.
157
+ # Git gets the credential through GIT_CONFIG_* for the one fetch: never in
158
+ # argv (which `ps` shows every user) and never in .git/config (which the
159
+ # checked-out app and Claude Code can read). No credential helper is asked,
160
+ # so the fetch uses this token or nothing.
161
+ CHECKOUT_SCRIPT = <<~'SH'
162
+ set -eu
163
+ git init -q "$APP_DIR"
164
+ cd "$APP_DIR"
165
+ git remote add origin "$CHECKOUT_URL"
166
+ header=""
167
+ if [ -n "${CHECKOUT_TOKEN:-}" ]; then
168
+ header="Authorization: Basic $(printf '%s:%s' "$CHECKOUT_USERNAME" "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
169
+ fi
170
+ unset CHECKOUT_TOKEN
171
+ GIT_CONFIG_COUNT=2 \
172
+ GIT_CONFIG_KEY_0=credential.helper GIT_CONFIG_VALUE_0= \
173
+ GIT_CONFIG_KEY_1=http.extraHeader GIT_CONFIG_VALUE_1="$header" \
174
+ git fetch -q --depth 1 origin "$CHECKOUT_REF"
175
+ git checkout -q --detach FETCH_HEAD
176
+ SH
177
+
178
+ # .activeagents/sandbox.yml, the checkout's say in how it boots. Every key
179
+ # is optional: a Rails app that mounts this engine boots without the file.
180
+ #
181
+ # env: extra environment for setup, manifest and server
182
+ # setup: commands run once after checkout, in order
183
+ # manifest: writes the manifest JSON to $ACTION_AGENT_SANDBOX_MANIFEST
184
+ # start: serves on 127.0.0.1:$PORT and keeps running
185
+ #
186
+ # Unknown keys are ignored, so a newer file still boots here.
187
+ class Config
188
+ PATH = File.join(".activeagents", "sandbox.yml")
189
+ DEFAULT_SETUP = [ "bundle install", "bin/rails db:prepare" ].freeze
190
+ DEFAULT_MANIFEST = "bin/rails action_agent:sandbox:manifest"
191
+ DEFAULT_START = "bin/rails server -b 127.0.0.1 -p $PORT"
192
+ ENV_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
193
+
194
+ attr_reader :env, :setup, :manifest, :start
195
+
196
+ def self.load(app_dir)
197
+ file = Pathname(app_dir).join(PATH)
198
+ data = file.file? ? YAML.safe_load(file.read, aliases: false) : nil
199
+ data = {} if data.nil?
200
+ invalid!("it must be a mapping of settings") unless data.is_a?(Hash)
201
+
202
+ new(
203
+ env: parse_env(data["env"]),
204
+ setup: parse_setup(data["setup"]),
205
+ manifest: parse_command(data, "manifest", DEFAULT_MANIFEST),
206
+ start: parse_command(data, "start", DEFAULT_START)
207
+ )
208
+ rescue Psych::Exception => e
209
+ invalid!("it is not valid YAML (#{e.message.truncate(200)})")
210
+ end
211
+
212
+ def self.parse_env(value)
213
+ return {} if value.nil?
214
+ invalid!("`env` must be a mapping of names to strings") unless value.is_a?(Hash)
215
+
216
+ value.to_h do |name, setting|
217
+ scalar = case setting
218
+ when String, Integer, Float, true, false then true
219
+ else false
220
+ end
221
+ unless scalar && name.is_a?(String) && ENV_NAME.match?(name)
222
+ invalid!("`env` must map variable names to strings (#{name.inspect} does not)")
223
+ end
224
+
225
+ [ name, setting.to_s ]
226
+ end
227
+ end
228
+
229
+ def self.parse_setup(value)
230
+ return DEFAULT_SETUP if value.nil?
231
+
232
+ commands = value.is_a?(String) ? [ value ] : value
233
+ unless commands.is_a?(Array) && commands.all? { |command| command.is_a?(String) && command.present? }
234
+ invalid!("`setup` must be a list of commands")
235
+ end
236
+ commands
237
+ end
238
+
239
+ def self.parse_command(data, key, default)
240
+ return default if data[key].nil?
241
+ invalid!("`#{key}` must be a command") unless data[key].is_a?(String) && data[key].present?
242
+
243
+ data[key]
244
+ end
245
+
246
+ def self.invalid!(problem)
247
+ raise Error, "Sandbox configuration failed: #{PATH} is malformed: #{problem}"
248
+ end
249
+
250
+ def initialize(env:, setup:, manifest:, start:)
251
+ @env = env
252
+ @setup = setup
253
+ @manifest = manifest
254
+ @start = start
255
+ end
256
+ end
257
+
258
+ class << self
259
+ # The environment every sandbox process starts from: the dashboard's
260
+ # own, as it was before Bundler set it up, minus the dashboard's
261
+ # database, keys and anything named like a credential. Processes are
262
+ # spawned with exactly this (plus what the step adds) and
263
+ # unsetenv_others, so nothing else leaks through.
264
+ #
265
+ # Bundler.unbundled_env is what Bundler.with_unbundled_env swaps into
266
+ # ENV; reading it directly leaves the process-wide ENV alone, which other
267
+ # threads of the dashboard are reading at the same time.
268
+ #
269
+ # @param source [Hash, nil] the environment to sanitize (for tests)
270
+ # @return [Hash{String => String}]
271
+ def sanitized_environment(source = nil)
272
+ source ||= defined?(::Bundler) ? ::Bundler.unbundled_env : ENV.to_h
273
+
274
+ source.each_with_object({}) do |(name, value), env|
275
+ name = name.to_s
276
+ next if value.nil? || DROPPED_VARIABLES.include?(name)
277
+ next if DROPPED_VARIABLE_PATTERN.match?(name) || SECRET_VARIABLE.match?(name)
278
+ next if CREDENTIALED_URL.match?(value.to_s)
279
+
280
+ env[name] = value.to_s
281
+ end
282
+ end
283
+
284
+ # Whether +command+ (a Claude Code executable) takes
285
+ # `--permission-prompts`, read from its --help once per process: the
286
+ # answer only changes when the CLI is upgraded. The block runs the help
287
+ # and returns its output, or nil when it could not run (which is not
288
+ # remembered, so installing the CLI later is noticed).
289
+ def permission_prompts_supported?(command)
290
+ @cli_support_lock.synchronize do
291
+ return @cli_support[command] if @cli_support.key?(command)
292
+
293
+ help = yield
294
+ help.nil? ? false : (@cli_support[command] = help.include?("--permission-prompts"))
295
+ end
296
+ end
297
+ end
298
+
299
+ @cli_support = {}
300
+ @cli_support_lock = Mutex.new
301
+ @login_status = {}
302
+ @login_status_lock = Mutex.new
303
+
304
+ class << self
305
+ # Whether this machine's Claude Code is logged in, for
306
+ # ActionAgent.claude_code_auth = :local_login: what
307
+ # `<claude_code_command> auth status --json` says, run with the
308
+ # sanitized environment (so it reads the user's own ~/.claude, as a
309
+ # session will) and bounded in time.
310
+ #
311
+ # Only whether it is logged in and how (authMethod, apiProvider) is
312
+ # kept. The rest of that output (an account's email, its organization)
313
+ # is never logged, stored or returned, and the credential itself is
314
+ # never in it. A CLI that is missing, fails or answers something else
315
+ # reads as logged out.
316
+ #
317
+ # Asked at most once a LOGIN_STATUS_TTL per command (a logged-out
318
+ # answer, LOGGED_OUT_STATUS_TTL): the listing that shows it is polled.
319
+ #
320
+ # @return [Hash] { logged_in: Boolean, auth_method: String?, api_provider: String? }
321
+ def claude_login_status
322
+ command = ActionAgent.claude_code_command.to_s
323
+ @login_status_lock.synchronize do
324
+ cached = @login_status[command]
325
+ return cached[:status] if cached && Process.clock_gettime(Process::CLOCK_MONOTONIC) < cached[:until]
326
+ end
327
+
328
+ status = new.send(:read_claude_login_status, command)
329
+ @login_status_lock.synchronize do
330
+ @login_status[command] = {
331
+ status: status,
332
+ until: Process.clock_gettime(Process::CLOCK_MONOTONIC) +
333
+ (status[:logged_in] ? LOGIN_STATUS_TTL : LOGGED_OUT_STATUS_TTL)
334
+ }
335
+ end
336
+ status
337
+ end
338
+
339
+ # Forgets the cached login status, so the next call asks the CLI again.
340
+ def reset_claude_login_status!
341
+ @login_status_lock.synchronize { @login_status.clear }
342
+ end
343
+
344
+ # The fields of `claude auth status --json` the dashboard keeps.
345
+ def parse_login_status(output)
346
+ data = JSON.parse(output.to_s.strip)
347
+ return LOGGED_OUT unless data.is_a?(Hash) && data["loggedIn"] == true
348
+
349
+ {
350
+ logged_in: true,
351
+ auth_method: login_label(data["authMethod"]),
352
+ api_provider: login_label(data["apiProvider"])
353
+ }
354
+ rescue JSON::ParserError
355
+ LOGGED_OUT
356
+ end
357
+
358
+ private
359
+
360
+ def login_label(value)
361
+ value if value.is_a?(String) && LOGIN_LABEL.match?(value)
362
+ end
363
+ end
364
+
365
+ # Clones the session's checkout, boots it as sandbox.yml says, and waits
366
+ # until its MCP facade answers.
367
+ #
368
+ # @return [Hash] the handle (container_name), url, mcp_url and mcp_token
369
+ def create_sandbox(session, instance_tier: nil)
370
+ ensure_enabled!
371
+ unless session.app_runtime?
372
+ raise Error, "The local sandbox backend only boots app_runtime checkouts, not #{session.sandbox_type} sandboxes"
373
+ end
374
+
375
+ spec = session.checkout_spec
376
+ raise Error, "Sandbox #{session.session_id} has no checkout to boot (is GitHub still connected?)" if spec.blank?
377
+
378
+ session_id = session_id!(session.session_id)
379
+ # A retried provision (say the dashboard restarted mid-boot) starts
380
+ # clean rather than on a half-built checkout next to a stray server.
381
+ unless discard(session_id)
382
+ raise Error, "Sandbox #{session_id} still has processes from an earlier boot that could not be stopped; " \
383
+ "see the dashboard log"
384
+ end
385
+ boot(session_id, spec, [ spec[:token], *session.runtime_environment.values ])
386
+ end
387
+
388
+ # A sandbox's handle follows from its session id, so one whose boot was
389
+ # never recorded can still be found and stopped.
390
+ def handle_for(session)
391
+ "local-#{session.session_id}"
392
+ end
393
+
394
+ # Stops the sandbox's server, its boot step and any Claude Code sessions
395
+ # it recorded, then removes its workspace. Returns true, also when there
396
+ # was nothing to stop.
397
+ #
398
+ # False when something the sandbox recorded is still alive and could
399
+ # not be stopped (or told apart from an unrelated process): its
400
+ # workspace, and state.json with it, is kept, so the handle is kept for
401
+ # the reaper to try again.
402
+ def terminate(handle)
403
+ session_id = session_id_from(handle)
404
+ session_id ? discard(session_id) : true
405
+ end
406
+
407
+ # @return [Hash] { status: "running" | "stopped" | "not_found", pid:, port: }
408
+ def status(handle)
409
+ session_id = session_id_from(handle)
410
+ workspace = session_id && workspace_for(session_id)
411
+ return { status: "not_found", pid: nil, port: nil } unless workspace&.directory?
412
+
413
+ state = read_state(workspace)
414
+ pid = state["pid"]
415
+ running = recorded_group?(pid, session_id, state) && group_alive?(pid)
416
+ { status: running ? "running" : "stopped", pid: pid, port: state["port"] }
417
+ end
418
+
419
+ # One entry per workspace on disk.
420
+ def list_sandboxes
421
+ root = ActionAgent.local_sandbox_root
422
+ return [] unless root.directory?
423
+
424
+ root.children.select(&:directory?).filter_map do |dir|
425
+ session_id = dir.basename.to_s
426
+ next unless SESSION_ID.match?(session_id)
427
+
428
+ handle = "#{HANDLE_PREFIX}#{session_id}"
429
+ status(handle).merge(container_name: handle, session_id: session_id)
430
+ end
431
+ end
432
+
433
+ # The engine reaps expired sandboxes itself (SandboxCleanupJob), one
434
+ # terminate at a time.
435
+ def cleanup_expired
436
+ 0
437
+ end
438
+
439
+ def code_runners
440
+ %w[claude_code codex]
441
+ end
442
+
443
+ # Runs a coding agent headless in the sandbox's checkout, yielding each
444
+ # stream-json event (a Hash, already scrubbed of the sandbox's secrets)
445
+ # as it arrives.
446
+ #
447
+ # @return [Hash] { exit_status: Integer, diff: String, stderr_tail: String }
448
+ def run_code_session(sandbox, code_session, &on_event)
449
+ ensure_enabled!
450
+ session_id = session_id!(sandbox.session_id)
451
+ workspace = workspace_for(session_id)
452
+ app = workspace.join("app")
453
+ raise Error, "Sandbox #{session_id} has no local checkout: start the sandbox again" unless app.directory?
454
+
455
+ runner = code_session.try(:runner) || "claude_code"
456
+ raise Error, "Unsupported code runner: #{runner}" unless code_runners.include?(runner)
457
+
458
+ credentials = session_credentials(sandbox, runner: runner)
459
+ secrets = sandbox_secrets(sandbox, credentials)
460
+ argv = runner == "codex" ? codex_argv(code_session) : claude_argv(code_session)
461
+ database_env = read_state(workspace)["database_env"]
462
+ env = self.class.sanitized_environment
463
+ .merge(database_env.is_a?(Hash) ? database_env.transform_values(&:to_s) : {})
464
+ .merge(credentials.to_h { |name, value| [ name.to_s, value.to_s ] })
465
+ .merge(
466
+ SESSION_ID_ENV => session_id,
467
+ "DISABLE_AUTOUPDATER" => "1",
468
+ "DISABLE_TELEMETRY" => "1",
469
+ "DISABLE_ERROR_REPORTING" => "1",
470
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC" => "1"
471
+ )
472
+ # With an API key a session gets a Claude Code configuration of its
473
+ # own, in the workspace. With the machine's own login it must use the
474
+ # user's: that is where `claude /login` left the credentials (HOME,
475
+ # which the sanitized environment keeps, or the keychain).
476
+ if runner == "codex"
477
+ env["CODEX_HOME"] = workspace.join("codex").to_s
478
+ FileUtils.mkdir_p(workspace.join("codex"), mode: 0o700)
479
+ elsif !ClaudeCodeAuth.local_login?
480
+ env["CLAUDE_CONFIG_DIR"] = workspace.join("claude").to_s
481
+ FileUtils.mkdir_p(workspace.join("claude"), mode: 0o700)
482
+ end
483
+
484
+ run_claude(workspace, code_session, argv, env, secrets, runner: runner, &on_event)
485
+ end
486
+
487
+ # Stops a running Claude Code session: SIGTERM to its process group. The
488
+ # run_code_session that started it then finishes with what it has. The
489
+ # cancel is noted in state.json too, and run_code_session, which watches
490
+ # for it, sends SIGKILL once STOP_GRACE has passed: a CLI that ignores
491
+ # SIGTERM would otherwise run on until claude_code_timeout.
492
+ #
493
+ # The session is marked running before Claude Code starts (the --help
494
+ # probe alone can take seconds), so a cancel can find no process yet.
495
+ # It is then remembered in state.json, under the lock run_code_session
496
+ # records its process with, and the session stops as soon as it starts
497
+ # (see #record_code_session), or never starts at all.
498
+ def cancel_code_session(sandbox, code_session)
499
+ session_id = session_id!(sandbox.session_id)
500
+ workspace = workspace_for(session_id)
501
+ return true unless workspace.directory?
502
+
503
+ key = code_session.id.to_s
504
+ update_state(workspace, create: false) do |state|
505
+ pid = state_hash(state, "code_sessions")[key]
506
+ if pid
507
+ if recorded_group?(pid, session_id, state)
508
+ signal_group(pid, "TERM")
509
+ # Wall-clock time: the session may run in another process.
510
+ state_hash(state, "cancelling_code_sessions")[key] ||= Time.now.to_f
511
+ end
512
+ else
513
+ cancels = state_hash(state, "cancelled_code_sessions")
514
+ cancels.delete_if { |_id, at| !at.is_a?(Numeric) || at < Time.now.to_f - CANCEL_MEMORY }
515
+ cancels[key] = Time.now.to_i
516
+ end
517
+ end
518
+ true
519
+ rescue Errno::ENOENT
520
+ # No state.json, so the sandbox never booted, or it was removed
521
+ # meanwhile: either way no Claude Code session runs there.
522
+ true
523
+ end
524
+
525
+ private
526
+
527
+ def ensure_enabled!
528
+ return if ActionAgent.local_sandboxes_enabled?
529
+
530
+ raise Error, "Local sandboxes are disabled. They run checkouts and Claude Code as processes on this machine; " \
531
+ "set ActionAgent.local_sandboxes_enabled = true to allow them"
532
+ end
533
+
534
+ # --- Boot -------------------------------------------------------------
535
+
536
+ def boot(session_id, spec, secrets)
537
+ workspace = workspace_for(session_id)
538
+ deadline = deadline_after(ActionAgent.local_sandbox_boot_timeout)
539
+ booted = false
540
+ server_pid = nil
541
+
542
+ prepare_workspace(workspace)
543
+ checkout!(workspace, spec, secrets, deadline)
544
+
545
+ 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(
549
+ # Merged after the file's env, so a checkout cannot move them.
550
+ SandboxManifest::PATH_ENV => workspace.join("runtime.json").to_s,
551
+ SESSION_ID_ENV => session_id
552
+ )
553
+
554
+ config.setup.each do |command|
555
+ run_step!(workspace, "setup", command, env: env, chdir: app, deadline: deadline, secrets: secrets)
556
+ end
557
+
558
+ # Picked after setup, which can take minutes, so that the port is
559
+ # likely still free when the server binds it. Nothing reserves it
560
+ # meanwhile: #wait_until_ready! only accepts an answer from a listener
561
+ # of the server's own process group.
562
+ port = free_port
563
+ env = env.merge("PORT" => port.to_s)
564
+ manifest = run_manifest!(workspace, config, env, deadline, secrets)
565
+
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
569
+
570
+ {
571
+ container_name: "#{HANDLE_PREFIX}#{session_id}",
572
+ url: "http://127.0.0.1:#{port}",
573
+ container_ip: "127.0.0.1",
574
+ mcp_url: "http://127.0.0.1:#{port}#{manifest["mcp_path"]}",
575
+ mcp_token: manifest["mcp_token"],
576
+ created_at: Time.current
577
+ }
578
+ rescue Error
579
+ raise
580
+ 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
591
+ end
592
+ end
593
+
594
+ # The sandbox's own databases (see LocalSandboxDatabases), recorded in
595
+ # state.json before setup can create them: a terminate, in this process
596
+ # or after a restart, drops what is recorded there. Claude Code sessions
597
+ # get them too, so a `bin/rails db:migrate` a session runs lands in the
598
+ # sandbox's database rather than the developer's.
599
+ def assign_databases(workspace, app, config, spec)
600
+ plan = LocalSandboxDatabases.plan(
601
+ app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides: config.env,
602
+ fallback_name: spec[:repository].to_s.split("/").last
603
+ )
604
+ if plan.notes.any?
605
+ File.open(log_path(workspace, "setup"), "a") do |file|
606
+ plan.notes.each { |line| file.puts("# sandbox database: #{line}") }
607
+ end
608
+ end
609
+ return {} if plan.empty?
610
+
611
+ update_state(workspace) do |state|
612
+ state["database_env"] = plan.env
613
+ state["drop_databases"] = plan.drop
614
+ state["database_drop_targets"] = plan.drop_targets
615
+ state["database_rails_env"] = plan.rails_env
616
+ end
617
+ plan.env
618
+ end
619
+
620
+ # Drops the server databases a sandbox was given (PostgreSQL, MySQL),
621
+ # with the checkout's own Rails database tasks: the adapter, its gem and
622
+ # its credentials are the checkout's. Best effort and bounded: a drop
623
+ # that fails or hangs is logged and the sandbox goes anyway. Only ever
624
+ # for the named URLs recorded here. Overridden databases and newly added
625
+ # configurations are never selected, even after the checkout is edited.
626
+ def drop_databases(workspace)
627
+ state = read_state(workspace)
628
+ database_env = state["database_env"]
629
+ targets = state["database_drop_targets"]
630
+ return unless targets.is_a?(Hash) && targets.any? && database_env.is_a?(Hash)
631
+
632
+ app = workspace.join("app")
633
+ return unless app.join("bin", "rails").file?
634
+
635
+ file_env = begin
636
+ Config.load(app).env
637
+ rescue Error
638
+ {}
639
+ end
640
+ env = self.class.sanitized_environment.merge(file_env).merge(database_env.transform_values(&:to_s)).merge(
641
+ SESSION_ID_ENV => workspace.basename.to_s,
642
+ "RAILS_ENV" => state.fetch("database_rails_env"),
643
+ DATABASE_DROP_TARGETS_ENV => JSON.generate(targets)
644
+ )
645
+ output, status = capture(env, [ "bin/rails", "runner", DATABASE_DROP_SCRIPT ], chdir: app, limit: 64 * 1024, timeout: database_drop_timeout,
646
+ err: [ :child, :out ])
647
+ return if status&.success?
648
+
649
+ Rails.logger.warn("[ActionAgent] sandbox #{workspace.basename}: could not drop its databases " \
650
+ "(#{status ? describe(status) : "timed out"}): #{output.to_s.force_encoding(Encoding::UTF_8).scrub.lines.last(5).join.strip}")
651
+ rescue StandardError => e
652
+ Rails.logger.warn("[ActionAgent] sandbox #{workspace.basename}: could not drop its databases: #{e.class.name}: #{e.message}")
653
+ end
654
+
655
+ # A method, so the tests can shorten it.
656
+ def database_drop_timeout
657
+ DATABASE_DROP_TIMEOUT
658
+ end
659
+
660
+ def prepare_workspace(workspace)
661
+ FileUtils.mkdir_p(ActionAgent.local_sandbox_root)
662
+ # Owner-only: the checkout and runtime.json hold the app's own secrets.
663
+ FileUtils.mkdir_p(workspace, mode: 0o700)
664
+ FileUtils.mkdir_p(workspace.join("logs"), mode: 0o700)
665
+ FileUtils.mkdir_p(workspace.join("claude"), mode: 0o700)
666
+ end
667
+
668
+ def checkout!(workspace, spec, secrets, deadline)
669
+ env = self.class.sanitized_environment.merge(
670
+ "APP_DIR" => workspace.join("app").to_s,
671
+ "CHECKOUT_URL" => spec[:clone_url].to_s,
672
+ "CHECKOUT_REF" => spec[:ref].presence || "HEAD",
673
+ "CHECKOUT_USERNAME" => spec[:username].presence || "x-access-token",
674
+ "CHECKOUT_TOKEN" => spec[:token].to_s,
675
+ SESSION_ID_ENV => workspace.basename.to_s,
676
+ # Fail rather than prompt when the token is refused.
677
+ "GIT_TERMINAL_PROMPT" => "0",
678
+ "GIT_ASKPASS" => ""
679
+ )
680
+ run_step!(workspace, "checkout", CHECKOUT_SCRIPT, env: env, chdir: workspace, deadline: deadline, secrets: secrets,
681
+ label: "git fetch #{spec[:clone_url]} #{spec[:ref]}")
682
+ record_checkout_commit(workspace)
683
+ end
684
+
685
+ # What a Claude Code session's diff is taken against, so it shows the
686
+ # session's changes even where the session committed them, or staged a
687
+ # removal, itself. Without it the diff falls back to HEAD.
688
+ def record_checkout_commit(workspace)
689
+ argv = [ *git_command(workspace.join("app")), "rev-parse", "--verify", "HEAD^{commit}" ]
690
+ output, status = capture(self.class.sanitized_environment, argv, chdir: workspace, limit: 1024, timeout: GIT_TIMEOUT)
691
+ commit = output.strip
692
+ update_state(workspace) { |state| state["checkout_commit"] = commit } if status&.success? && COMMIT_ID.match?(commit)
693
+ end
694
+
695
+ def run_manifest!(workspace, config, env, deadline, secrets)
696
+ path = workspace.join("runtime.json")
697
+ FileUtils.rm_f(path)
698
+ run_step!(workspace, "manifest", config.manifest, env: env, chdir: workspace.join("app"), deadline: deadline, secrets: secrets)
699
+
700
+ log = log_path(workspace, "manifest")
701
+ fail_step!("manifest", "`#{config.manifest}` wrote nothing to $#{SandboxManifest::PATH_ENV}", log, secrets) unless path.file?
702
+
703
+ # Written by the checkout's command, with its umask: the MCP token in
704
+ # it is for the dashboard alone.
705
+ File.chmod(0o600, path)
706
+ manifest = SandboxManifest.parse(path.read)
707
+ URI.parse("http://127.0.0.1#{manifest["mcp_path"]}")
708
+ manifest
709
+ rescue SandboxManifest::Error, URI::InvalidURIError => e
710
+ fail_step!("manifest", e.message, log_path(workspace, "manifest"), secrets)
711
+ end
712
+
713
+ # 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).
717
+ #
718
+ # The group is stopped however the wait ends: the deadline, or any
719
+ # exception at all (a worker shutting down raises into this thread with
720
+ # Thread#raise, which is no StandardError). While it runs its pid is in
721
+ # state.json as step_pid, so a terminate after this process itself died
722
+ # (a crashed dashboard, a killed worker) stops a hung `bundle install`
723
+ # 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}") }
727
+
728
+ pid = nil
729
+ waiter = nil
730
+ finished = false
731
+ # Deferred until the group is recorded or stopped, so an interrupt
732
+ # never lands between the spawn and the ensure that stops it.
733
+ 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 ])
735
+ begin
736
+ Thread.handle_interrupt(Object => :immediate) do
737
+ record_step(workspace, pid)
738
+ waiter = Process.detach(pid)
739
+ finished = waiter.join(time_left(deadline))
740
+ end
741
+ ensure
742
+ stop_groups([ pid ], grace: finished ? 1 : stop_grace)
743
+ forget_step(workspace, pid)
744
+ end
745
+ end
746
+
747
+ unless finished
748
+ fail_step!(step, "`#{label}` did not finish within the boot timeout (#{ActionAgent.local_sandbox_boot_timeout}s)", log, secrets)
749
+ end
750
+ status = waiter.value
751
+ fail_step!(step, "`#{label}` exited with #{describe(status)}", log, secrets) unless status.success?
752
+ end
753
+
754
+ def record_step(workspace, pid)
755
+ update_state(workspace) do |state|
756
+ state["step_pid"] = pid
757
+ record_process_start(state, pid)
758
+ end
759
+ end
760
+
761
+ def forget_step(workspace, pid)
762
+ update_state(workspace, create: false) do |state|
763
+ next unless state["step_pid"] == pid
764
+
765
+ state.delete("step_pid")
766
+ state_hash(state, "process_starts").delete(pid.to_s)
767
+ end
768
+ rescue SystemCallError
769
+ # The workspace is gone: nothing left to forget it in.
770
+ end
771
+
772
+ def start_server(workspace, config, env, port)
773
+ log = log_path(workspace, "server")
774
+ File.open(log, "a") { |file| file.puts("$ #{config.start}") }
775
+
776
+ pid = spawn_group(env, "sh", "-c", config.start,
777
+ chdir: workspace.join("app"), in: File::NULL, out: [ log.to_s, "a" ], err: [ :child, :out ])
778
+ # Reaped by this thread for as long as the dashboard lives; after a
779
+ # restart the recorded pid is all that is left, hence state.json.
780
+ waiter = Process.detach(pid)
781
+ recorded = false
782
+ begin
783
+ update_state(workspace) do |state|
784
+ state.merge!("pid" => pid, "port" => port, "started_at" => Time.current.iso8601(3))
785
+ state_hash(state, "code_sessions")
786
+ record_process_start(state, pid)
787
+ end
788
+ recorded = true
789
+ ensure
790
+ # Unrecorded, nothing could ever stop it later, whatever the
791
+ # exception (see #run_step!).
792
+ stop_groups([ pid ]) unless recorded
793
+ end
794
+ [ pid, waiter ]
795
+ end
796
+
797
+ # Polls until the MCP path answers, and the answer comes from the
798
+ # server this sandbox started. The port was free when it was picked, but
799
+ # nothing held it after: another process can bind it first, and then
800
+ # 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)
802
+ log = log_path(workspace, "server")
803
+ mcp_path = manifest["mcp_path"]
804
+ uri = URI.parse("http://127.0.0.1:#{port}#{mcp_path}")
805
+ last_status = nil
806
+ foreign = false
807
+
808
+ loop do
809
+ unless waiter.alive?
810
+ 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}",
812
+ log, secrets)
813
+ end
814
+
815
+ status = probe(uri)
816
+ if READY_STATUSES.include?(status)
817
+ return if served_by_sandbox?(uri, server_pid, workspace.basename.to_s, manifest["mcp_token"])
818
+
819
+ foreign = true
820
+ end
821
+
822
+ last_status = status if status
823
+ if monotonic >= deadline
824
+ answered =
825
+ if foreign then " (port #{port} answered, but not from the sandbox's server)"
826
+ elsif last_status then " (last answer: #{last_status})"
827
+ else ""
828
+ 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)
831
+ end
832
+ sleep POLL_INTERVAL
833
+ end
834
+ end
835
+
836
+ # Whether what listens on +uri+'s port is the sandbox's server (process
837
+ # group +pgid+). Asked of the system where it can say (/proc on Linux,
838
+ # lsof elsewhere); the token is never sent before. Where it cannot say,
839
+ # the listener must answer as only the sandbox's own MCP facade would:
840
+ # refuse a request without the manifest's token and accept one with it.
841
+ def served_by_sandbox?(uri, pgid, session_id, token)
842
+ owned = sandbox_listener?(uri.port, pgid, session_id)
843
+ return owned unless owned.nil?
844
+
845
+ rpc_status(uri, nil) == 401 && rpc_status(uri, token).to_i.between?(200, 299)
846
+ end
847
+
848
+ # true or false when the system can say whether the sandbox listens on
849
+ # +port+: a process of the server's group +pgid+ or, where /proc shows
850
+ # environments, one carrying the sandbox's session id (a server that
851
+ # starts its workers in groups of their own). nil when it cannot say.
852
+ def sandbox_listener?(port, pgid, session_id)
853
+ unless procfs?
854
+ listeners = lsof_listeners(port)
855
+ return listeners&.any? { |pid| group_of(pid) == pgid }
856
+ end
857
+
858
+ inodes = listening_socket_inodes(port)
859
+ return nil if inodes.blank?
860
+ return true if group_members(pgid).any? { |pid| socket_inodes(pid).intersect?(inodes) }
861
+
862
+ # Another user's process does not show its descriptors: whoever holds
863
+ # the socket then is not the sandbox, whose processes are this one's.
864
+ marker = "#{SESSION_ID_ENV}=#{session_id}"
865
+ Dir.children("/proc").any? do |entry|
866
+ next false unless entry.match?(/\A\d+\z/) && socket_inodes(entry).intersect?(inodes)
867
+
868
+ File.binread("/proc/#{entry}/environ").split("\0").include?(marker)
869
+ rescue SystemCallError
870
+ false
871
+ end
872
+ end
873
+
874
+ # The inodes of the TCP sockets listening on +port+, from
875
+ # /proc/net/tcp and tcp6; nil when neither can be read.
876
+ def listening_socket_inodes(port)
877
+ inodes = nil
878
+ %w[/proc/net/tcp /proc/net/tcp6].each do |table|
879
+ lines = File.readlines(table).drop(1)
880
+ inodes ||= Set.new
881
+ lines.each do |line|
882
+ # sl local_address rem_address st tx:rx tr:when retrnsmt uid timeout inode
883
+ fields = line.split
884
+ next unless fields[3] == "0A" && fields[1].to_s.split(":").last.to_i(16) == port
885
+
886
+ inodes << fields[9]
887
+ end
888
+ rescue SystemCallError
889
+ next
890
+ end
891
+ inodes
892
+ end
893
+
894
+ def group_members(pgid)
895
+ Dir.children("/proc").select do |entry|
896
+ next false unless entry.match?(/\A\d+\z/)
897
+
898
+ state, _ppid, group = proc_stat(entry)
899
+ group.to_i == pgid && !%w[Z X].include?(state)
900
+ end
901
+ end
902
+
903
+ def socket_inodes(pid)
904
+ Dir.children("/proc/#{pid}/fd").filter_map do |fd|
905
+ File.readlink("/proc/#{pid}/fd/#{fd}")[/\Asocket:\[(\d+)\]\z/, 1]
906
+ rescue SystemCallError
907
+ nil
908
+ end.to_set
909
+ rescue SystemCallError
910
+ Set.new
911
+ end
912
+
913
+ # The pids listening on +port+ as lsof reports them; nil without lsof.
914
+ def lsof_listeners(port)
915
+ output, _status = Open3.capture2("lsof", "-nP", "-a", "-iTCP:#{port}", "-sTCP:LISTEN", "-Fp", err: File::NULL)
916
+ output.lines.filter_map { |line| Integer(line[/\Ap(\d+)/, 1], exception: false) }
917
+ rescue SystemCallError
918
+ nil
919
+ end
920
+
921
+ def group_of(pid)
922
+ Process.getpgid(pid)
923
+ rescue SystemCallError
924
+ nil
925
+ end
926
+
927
+ # The HTTP status a JSON-RPC ping to +uri+ answers with, carrying
928
+ # +token+ as its bearer when given; nil while nothing answers.
929
+ def rpc_status(uri, token)
930
+ request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Accept" => "application/json, text/event-stream")
931
+ request["Authorization"] = "Bearer #{token}" if token
932
+ request.body = JSON.generate(jsonrpc: "2.0", id: "readiness", method: "ping")
933
+ http = Net::HTTP.new(uri.host, uri.port, nil)
934
+ http.open_timeout = 1
935
+ http.read_timeout = 2
936
+ http.start { |connection| connection.request(request).code.to_i }
937
+ rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse
938
+ nil
939
+ end
940
+
941
+ # The HTTP status a GET on the MCP path answers with, or nil while
942
+ # nothing answers.
943
+ def probe(uri)
944
+ # No proxy: the dashboard's HTTP(S)_PROXY does not know this loopback.
945
+ http = Net::HTTP.new(uri.host, uri.port, nil)
946
+ 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 }
949
+ rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse
950
+ nil
951
+ end
952
+
953
+ def free_port
954
+ server = TCPServer.new("127.0.0.1", 0)
955
+ server.addr[1]
956
+ ensure
957
+ server&.close
958
+ end
959
+
960
+ # Raises the boot failure: the step, what went wrong, and the end of
961
+ # that step's log, all scrubbed of the sandbox's secrets.
962
+ def fail_step!(step, problem, log, secrets)
963
+ tail = log_tail(log, secrets)
964
+ message = "Sandbox #{step} failed: #{problem}"
965
+ message += "\n--- last lines of logs/#{File.basename(log)} ---\n#{tail}" if tail.present?
966
+ raise Error, SecretScrubber.scrub(message, secrets)
967
+ end
968
+
969
+ def log_tail(log, secrets)
970
+ return "" unless File.file?(log)
971
+
972
+ data = File.open(log, "rb") do |file|
973
+ file.seek([ file.size - LOG_TAIL_BYTES, 0 ].max)
974
+ file.read
975
+ end
976
+ lines = data.force_encoding(Encoding::UTF_8).scrub.lines.last(LOG_TAIL_LINES)
977
+ SecretScrubber.scrub(lines.map { |line| line.chomp.truncate(500) }.join("\n"), secrets)
978
+ end
979
+
980
+ # --- Claude Code --------------------------------------------------------
981
+
982
+ def codex_argv(code_session)
983
+ argv = [
984
+ ActionAgent.codex_command.to_s, "exec", "--json", "--ephemeral",
985
+ "--sandbox", "workspace-write", "--config", 'approval_policy="never"', "--color", "never"
986
+ ]
987
+ if (model = code_session.model.presence)
988
+ raise Error, "#{model.inspect} is not a model name" unless MODEL_NAME.match?(model.to_s)
989
+
990
+ argv += [ "--model", model.to_s ]
991
+ end
992
+ argv + [ "-" ]
993
+ end
994
+
995
+ def claude_argv(code_session)
996
+ command = ActionAgent.claude_code_command.to_s
997
+ argv = [
998
+ command, "-p", "--output-format", "stream-json", "--verbose",
999
+ "--permission-mode", ActionAgent.claude_code_permission_mode.to_s, "--no-session-persistence"
1000
+ ]
1001
+ # Nobody is there to answer a permission prompt: with this, anything
1002
+ # that would prompt is denied instead of stalling the session.
1003
+ argv += [ "--permission-prompts", "none" ] if permission_prompts_supported?(command)
1004
+ argv += [ "--max-turns", ActionAgent.claude_code_max_turns.to_i.to_s ] if ActionAgent.claude_code_max_turns.present?
1005
+
1006
+ if (model = code_session.model.presence)
1007
+ # One argv entry, but one that must not read as a flag.
1008
+ raise Error, "#{model.inspect} is not a model name" unless MODEL_NAME.match?(model.to_s)
1009
+
1010
+ argv += [ "--model", model.to_s ]
1011
+ end
1012
+ argv
1013
+ end
1014
+
1015
+ def permission_prompts_supported?(command)
1016
+ self.class.permission_prompts_supported?(command) do
1017
+ output, status = capture(self.class.sanitized_environment, [ command, "--help" ],
1018
+ chdir: Dir.tmpdir, limit: 256 * 1024, timeout: HELP_TIMEOUT, err: [ :child, :out ])
1019
+ status ? output : nil
1020
+ rescue SystemCallError
1021
+ nil
1022
+ end
1023
+ end
1024
+
1025
+ def read_claude_login_status(command)
1026
+ output, status = capture(self.class.sanitized_environment, [ command, "auth", "status", "--json" ],
1027
+ chdir: Dir.tmpdir, limit: 64 * 1024, timeout: LOGIN_STATUS_TIMEOUT)
1028
+ # A logged-out CLI may exit non-zero and still say so; one stopped at
1029
+ # the timeout said nothing to trust.
1030
+ status.nil? ? LOGGED_OUT : self.class.parse_login_status(output)
1031
+ rescue SystemCallError
1032
+ LOGGED_OUT
1033
+ end
1034
+
1035
+ def run_claude(workspace, code_session, argv, env, secrets, runner: "claude_code", &on_event)
1036
+ label = runner == "codex" ? "Codex" : "Claude Code"
1037
+ timeout = runner == "codex" ? ActionAgent.codex_timeout : ActionAgent.claude_code_timeout
1038
+ key = code_session.id.to_s
1039
+ log = log_path(workspace, "#{runner == 'codex' ? 'codex' : 'claude'}-#{key}")
1040
+ deadline = deadline_after(timeout)
1041
+ # Checked again once Claude Code is recorded; this saves starting it.
1042
+ state = read_state(workspace)
1043
+ refuse_stopped_session!(state, key, label: label)
1044
+ # A cancelled session frees its slot as soon as it is marked cancelled,
1045
+ # while its Claude Code may still be exiting (and diffing). Two in one
1046
+ # checkout would edit the same files.
1047
+ if other_session_running?(state, key, workspace.basename.to_s)
1048
+ raise Error, "The previous code session in this sandbox is still stopping; try again in a moment"
1049
+ end
1050
+ stdin_read, stdin_write = IO.pipe
1051
+ stdout_read, stdout_write = IO.pipe
1052
+ stderr_read, stderr_write = IO.pipe
1053
+
1054
+ begin
1055
+ pid = spawn_group(env, *argv, chdir: workspace.join("app"), in: stdin_read, out: stdout_write, err: stderr_write)
1056
+ rescue SystemCallError => e
1057
+ raise Error, "Could not start #{label} (#{argv.first}): #{e.message}"
1058
+ ensure
1059
+ [ stdin_read, stdout_write, stderr_write ].each(&:close)
1060
+ end
1061
+ waiter = Process.detach(pid)
1062
+ # When a cancel was first seen here (monotonic): from then on the
1063
+ # session gets STOP_GRACE to end on SIGTERM, then SIGKILL.
1064
+ cancel_seen = nil
1065
+ case record_code_session(workspace, key, pid)
1066
+ when :terminating
1067
+ # Stopped by the ensure below, before it had the prompt.
1068
+ raise Error, "The sandbox is being stopped, so #{label} did not run"
1069
+ when :cancelled
1070
+ # Runs its course like any cancelled session: it ends on SIGTERM.
1071
+ signal_group(pid, "TERM")
1072
+ cancel_seen = monotonic
1073
+ end
1074
+
1075
+ # The prompt goes in on stdin, never argv, where `ps` would show it.
1076
+ writer = background { write_prompt(stdin_write, code_session.prompt) }
1077
+ stderr_tail = []
1078
+ reader = background { copy_stderr(stderr_read, log, secrets, stderr_tail) }
1079
+
1080
+ # A cancel sent from another call (or process) is read from
1081
+ # state.json, where cancel_code_session notes it.
1082
+ next_check = monotonic
1083
+ stop_by = lambda do
1084
+ if cancel_seen.nil? && monotonic >= next_check
1085
+ next_check = monotonic + CANCEL_CHECK_INTERVAL
1086
+ cancel_seen = cancel_noted(workspace, key)
1087
+ end
1088
+ cancel_seen ? [ deadline, cancel_seen + stop_grace ].min : deadline
1089
+ end
1090
+
1091
+ finished = stream_events(stdout_read, waiter, stop_by, secrets, &on_event) && waiter.join(time_left(stop_by.call))
1092
+ unless finished
1093
+ # Cancelled, and SIGTERM did not end it within the grace: SIGKILL,
1094
+ # and the session finishes like any cancelled one.
1095
+ raise Error, "#{label} did not finish within #{timeout}s and was stopped" unless cancel_seen
1096
+
1097
+ stop_groups([ pid ], grace: 0)
1098
+ end
1099
+
1100
+ # Whatever the session left running in the background goes too.
1101
+ stop_groups([ pid ], grace: 1)
1102
+ reader.join(OUTPUT_DRAIN_GRACE)
1103
+
1104
+ {
1105
+ exit_status: waiter.join(OUTPUT_DRAIN_GRACE) ? exit_code(waiter.value) : 128 + Signal.list.fetch("KILL"),
1106
+ diff: capture_diff(workspace, secrets),
1107
+ stderr_tail: SecretScrubber.scrub(stderr_tail.join("\n"), secrets)
1108
+ }
1109
+ ensure
1110
+ stop_groups([ pid ], grace: cancel_seen ? 0 : stop_grace) if pid
1111
+ [ stdin_write, stdout_read, stderr_read ].each { |io| io&.close unless io&.closed? }
1112
+ writer&.join(1)
1113
+ reader&.join(1)
1114
+ forget_code_session(workspace, key)
1115
+ end
1116
+
1117
+ # Why a session must not start: a terminate under way, or a cancel that
1118
+ # came before there was a process to stop.
1119
+ def refuse_stopped_session!(state, key, label: "Claude Code")
1120
+ raise Error, "The sandbox is being stopped, so #{label} did not run" if state["terminating"]
1121
+
1122
+ cancels = state["cancelled_code_sessions"]
1123
+ raise Error, "#{label} session #{key} was cancelled before it started" if cancels.is_a?(Hash) && cancels.key?(key)
1124
+ end
1125
+
1126
+ # Records Claude Code's pid (and start time) under the state.json lock,
1127
+ # the one cancel_code_session and terminate take too, so each either
1128
+ # finds this pid or left a mark here first. Returns :terminating when a
1129
+ # terminate is under way (or already removed the workspace), :cancelled
1130
+ # when a cancel came first, and nil otherwise.
1131
+ def other_session_running?(state, key, session_id)
1132
+ sessions = state["code_sessions"].is_a?(Hash) ? state["code_sessions"] : {}
1133
+ sessions.any? do |other, pid|
1134
+ other != key && pid.is_a?(Integer) && group_alive?(pid) && recorded_group?(pid, session_id, state)
1135
+ end
1136
+ end
1137
+
1138
+ def record_code_session(workspace, key, pid)
1139
+ mark = nil
1140
+ update_state(workspace) do |state|
1141
+ state_hash(state, "code_sessions")[key] = pid
1142
+ record_process_start(state, pid)
1143
+ mark =
1144
+ if state["terminating"] then :terminating
1145
+ elsif state_hash(state, "cancelled_code_sessions").delete(key) then :cancelled
1146
+ end
1147
+ end
1148
+ mark
1149
+ rescue Errno::ENOENT
1150
+ :terminating
1151
+ end
1152
+
1153
+ # When a cancel_code_session for session +key+ signalled it, as a
1154
+ # monotonic time; nil while none has.
1155
+ def cancel_noted(workspace, key)
1156
+ at = read_state(workspace).dig("cancelling_code_sessions", key)
1157
+ at.is_a?(Numeric) ? monotonic - (Time.now.to_f - at).clamp(0, Float::INFINITY) : nil
1158
+ rescue SystemCallError
1159
+ nil
1160
+ end
1161
+
1162
+ # Reads stream-json from +io+ until it closes, yielding each line as an
1163
+ # event. Returns false when the deadline +stop_by+ answers (it can move
1164
+ # earlier, on a cancel) passed first.
1165
+ def stream_events(io, waiter, stop_by, secrets, &on_event)
1166
+ buffer = String.new(encoding: Encoding::BINARY)
1167
+ skipping = false
1168
+ exited_at = nil
1169
+
1170
+ loop do
1171
+ deadline = stop_by.call
1172
+ return false if monotonic >= deadline
1173
+
1174
+ # Claude Code exited but something it started still holds stdout.
1175
+ unless waiter.alive?
1176
+ exited_at ||= monotonic
1177
+ break if monotonic - exited_at > OUTPUT_DRAIN_GRACE
1178
+ end
1179
+
1180
+ next unless io.wait_readable([ POLL_INTERVAL, time_left(deadline) || POLL_INTERVAL ].min)
1181
+
1182
+ chunk = io.read_nonblock(64 * 1024, exception: false)
1183
+ break if chunk.nil?
1184
+ next if chunk == :wait_readable
1185
+
1186
+ buffer << chunk
1187
+ while (newline = buffer.index("\n"))
1188
+ line = buffer.slice!(0, newline + 1)
1189
+ if skipping
1190
+ skipping = false
1191
+ else
1192
+ emit_event(line, secrets, &on_event)
1193
+ end
1194
+ end
1195
+
1196
+ next unless buffer.bytesize > MAX_EVENT_LINE_BYTES
1197
+
1198
+ unless skipping
1199
+ head = buffer.byteslice(0, CodeSession::MAX_EVENT_STRING).force_encoding(Encoding::UTF_8).scrub
1200
+ emit_event("#{head}… (line truncated)", secrets, &on_event)
1201
+ end
1202
+ buffer.clear
1203
+ skipping = true
1204
+ end
1205
+
1206
+ emit_event(buffer, secrets, &on_event) unless skipping || buffer.empty?
1207
+ true
1208
+ end
1209
+
1210
+ def emit_event(line, secrets)
1211
+ text = line.dup.force_encoding(Encoding::UTF_8).scrub.chomp
1212
+ return if text.strip.empty?
1213
+
1214
+ event = begin
1215
+ parsed = JSON.parse(text)
1216
+ parsed.is_a?(Hash) ? parsed : nil
1217
+ rescue JSON::ParserError
1218
+ nil
1219
+ end
1220
+ yield SecretScrubber.scrub(event || { "type" => "raw", "text" => text }, secrets) if block_given?
1221
+ end
1222
+
1223
+ def write_prompt(io, prompt)
1224
+ io.write(prompt.to_s)
1225
+ rescue IOError, SystemCallError
1226
+ # The CLI exited without reading it; its exit status says why.
1227
+ ensure
1228
+ io.close unless io.closed?
1229
+ end
1230
+
1231
+ def copy_stderr(io, log, secrets, tail)
1232
+ File.open(log, "a") do |file|
1233
+ io.each_line(64 * 1024) do |line|
1234
+ line = SecretScrubber.scrub(line.scrub, secrets)
1235
+ file.write(line)
1236
+ file.flush
1237
+ tail << line.chomp.truncate(500)
1238
+ tail.shift while tail.size > LOG_TAIL_LINES
1239
+ end
1240
+ end
1241
+ rescue IOError
1242
+ # Closed under us once the session is over.
1243
+ end
1244
+
1245
+ # The checkout's changes since it was fetched: this session's, and any
1246
+ # earlier session's. Untracked files are marked intent-to-add so new
1247
+ # files show up alongside edits. `add --all` also stages removals, which
1248
+ # a plain `git diff` (worktree against index) would then leave out, so
1249
+ # the worktree is compared with the commit checked out instead; that also
1250
+ # keeps changes the session committed itself.
1251
+ def capture_diff(workspace, secrets)
1252
+ app = workspace.join("app")
1253
+ env = self.class.sanitized_environment
1254
+ 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?
1261
+ return "(diff not recorded: the checkout's git config defines filter drivers, which would run commands)"
1262
+ end
1263
+ capture(env, [ *git, "add", "--intent-to-add", "--all" ], chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
1264
+
1265
+ base = read_state(workspace)["checkout_commit"]
1266
+ base = "HEAD" unless base.is_a?(String) && COMMIT_ID.match?(base)
1267
+ diff = ->(commit) do
1268
+ capture(env, [ *git, "diff", "--no-color", "--no-ext-diff", "--no-textconv", commit, "--" ],
1269
+ chdir: app, limit: MAX_DIFF_BYTES, timeout: GIT_TIMEOUT)
1270
+ end
1271
+ output, status = diff.call(base)
1272
+ # The session pruned the commit away (a gc after moving HEAD, say).
1273
+ output, = diff.call("HEAD") if base != "HEAD" && output.empty? && status && !status.success?
1274
+
1275
+ SecretScrubber.scrub(output.force_encoding(Encoding::UTF_8).scrub, secrets)
1276
+ rescue SystemCallError => e
1277
+ Rails.logger.warn("[ActionAgent] could not diff the sandbox checkout: #{e.message}")
1278
+ ""
1279
+ end
1280
+
1281
+ # git in the checkout. A Claude Code session could have edited
1282
+ # .git/config: whatever hooks or filesystem monitor it set up there do
1283
+ # not run here.
1284
+ def git_command(app)
1285
+ [ "git", "-C", app.to_s, "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null" ]
1286
+ end
1287
+
1288
+ # The Claude Code variables a session runs with. An API key comes from
1289
+ # the owner's connection. The machine's own login needs none: `claude`
1290
+ # finds it itself, and the dashboard neither reads nor passes it on.
1291
+ def session_credentials(sandbox, runner: "claude_code")
1292
+ return {} if runner == "claude_code" && ClaudeCodeAuth.local_login?
1293
+
1294
+ credentials = (runner == "codex" ? sandbox.runtime_environment(runner: runner) : sandbox.runtime_environment).to_h
1295
+ if credentials.empty?
1296
+ label = runner == "codex" ? "Codex is not connected: connect an OpenAI API key" : "Claude Code is not connected: connect an Anthropic API key"
1297
+ raise Error, "#{label} in Settings → Integrations"
1298
+ end
1299
+
1300
+ credentials
1301
+ end
1302
+
1303
+ def sandbox_secrets(sandbox, credentials)
1304
+ token = begin
1305
+ sandbox.checkout_spec&.dig(:token)
1306
+ rescue StandardError
1307
+ nil
1308
+ end
1309
+ [ token, *credentials.values ].compact.map(&:to_s)
1310
+ end
1311
+
1312
+ # Drops the session's pid, and the cancels it may have left.
1313
+ def forget_code_session(workspace, key)
1314
+ update_state(workspace, create: false) do |state|
1315
+ pid = state_hash(state, "code_sessions").delete(key)
1316
+ state_hash(state, "process_starts").delete(pid.to_s) if pid
1317
+ state_hash(state, "cancelled_code_sessions").delete(key)
1318
+ state_hash(state, "cancelling_code_sessions").delete(key)
1319
+ end
1320
+ rescue Errno::ENOENT
1321
+ # Terminated meanwhile: the workspace, and its state, are gone.
1322
+ end
1323
+
1324
+ def exit_code(status)
1325
+ status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
1326
+ end
1327
+
1328
+ # --- Processes ----------------------------------------------------------
1329
+
1330
+ # Every sandbox process leads its own process group, so stopping one
1331
+ # reaches whatever it started, and gets exactly the environment given.
1332
+ def spawn_group(env, *argv, chdir:, **redirects)
1333
+ Process.spawn(env, *argv, chdir: chdir.to_s, pgroup: true, unsetenv_others: true, close_others: true, **redirects)
1334
+ end
1335
+
1336
+ # Runs +argv+ to completion, bounded in time and output. Returns the
1337
+ # output and the exit status (nil when it had to be stopped).
1338
+ def capture(env, argv, chdir:, limit:, timeout:, err: File::NULL)
1339
+ output = String.new(encoding: Encoding::BINARY)
1340
+ reader, writer = IO.pipe
1341
+ pid = spawn_group(env, *argv, chdir: chdir, in: File::NULL, out: writer, err: err)
1342
+ writer.close
1343
+ waiter = Process.detach(pid)
1344
+ deadline = deadline_after(timeout)
1345
+
1346
+ while output.bytesize < limit && monotonic < deadline
1347
+ next unless reader.wait_readable([ POLL_INTERVAL, time_left(deadline) || POLL_INTERVAL ].min)
1348
+
1349
+ chunk = reader.read_nonblock(64 * 1024, exception: false)
1350
+ break if chunk.nil?
1351
+
1352
+ output << chunk.byteslice(0, limit - output.bytesize) unless chunk == :wait_readable
1353
+ end
1354
+
1355
+ [ output, waiter.join(output.bytesize < limit ? time_left(deadline) : 0) && waiter.value ]
1356
+ ensure
1357
+ stop_groups([ pid ], grace: 1) if pid
1358
+ [ reader, writer ].each { |io| io&.close unless io&.closed? }
1359
+ end
1360
+
1361
+ # TERM to each group, KILL to whatever is left after +grace+ (STOP_GRACE
1362
+ # by default), then a moment for the kernel to finish them off.
1363
+ def stop_groups(pids, grace: nil)
1364
+ grace ||= stop_grace
1365
+ live = pids.select { |pid| group_alive?(pid) }
1366
+ return if live.empty?
1367
+
1368
+ live.each { |pid| signal_group(pid, "TERM") }
1369
+ # Not deadline_after, for which 0 means no limit: here it means none.
1370
+ deadline = monotonic + grace
1371
+ sleep 0.1 while live.any? { |pid| group_alive?(pid) } && monotonic < deadline
1372
+
1373
+ live.each { |pid| signal_group(pid, "KILL") if group_alive?(pid) }
1374
+ deadline = deadline_after(2)
1375
+ sleep 0.05 while live.any? { |pid| group_alive?(pid) } && monotonic < deadline
1376
+ end
1377
+
1378
+ # A method, not the constant alone, so the tests can shorten it.
1379
+ def stop_grace
1380
+ STOP_GRACE
1381
+ end
1382
+
1383
+ def signal_group(pgid, signal)
1384
+ return false unless signalable?(pgid)
1385
+
1386
+ Process.kill(signal, -pgid)
1387
+ true
1388
+ rescue Errno::ESRCH, Errno::EPERM
1389
+ false
1390
+ end
1391
+
1392
+ # Never 0 or 1 (kill(-1) signals every process the user owns) and never
1393
+ # the dashboard's own group.
1394
+ def signalable?(pgid)
1395
+ pgid.is_a?(Integer) && pgid > 1 && pgid != Process.getpgrp
1396
+ end
1397
+
1398
+ # Whether any live process remains in group +pgid+. Where /proc exists it
1399
+ # is read directly: kill(0) also succeeds for zombies, which an init that
1400
+ # does not reap (a container's) keeps around indefinitely.
1401
+ def group_alive?(pgid)
1402
+ return false unless signalable?(pgid)
1403
+ return proc_group_alive?(pgid) if procfs?
1404
+
1405
+ Process.kill(0, -pgid)
1406
+ true
1407
+ rescue Errno::ESRCH
1408
+ false
1409
+ rescue Errno::EPERM
1410
+ true
1411
+ end
1412
+
1413
+ def proc_group_alive?(pgid)
1414
+ Dir.each_child("/proc").any? do |entry|
1415
+ next false unless entry.match?(/\A\d+\z/)
1416
+
1417
+ state, _ppid, group = proc_stat(entry)
1418
+ group.to_i == pgid && !%w[Z X].include?(state)
1419
+ end
1420
+ end
1421
+
1422
+ # The fields of /proc/<pid>/stat after the command name, which may itself
1423
+ # contain spaces and parentheses.
1424
+ def proc_stat(pid)
1425
+ stat = File.read("/proc/#{pid}/stat")
1426
+ stat[(stat.rindex(")") + 2)..].split
1427
+ rescue SystemCallError
1428
+ []
1429
+ end
1430
+
1431
+ def procfs?
1432
+ File.exist?("/proc/self/stat")
1433
+ end
1434
+
1435
+ # When process +pid+ started, in clock ticks since boot: what tells it
1436
+ # apart from a later process that reused its pid. Unlike its environment
1437
+ # the process cannot rewrite it, as a long enough process title (Ruby's
1438
+ # `$0=`, setproctitle) does. nil where /proc cannot say.
1439
+ def process_start(pid)
1440
+ # Field 22 of the stat line; proc_stat starts at field 3.
1441
+ return Integer(proc_stat(pid)[19], exception: false) if procfs?
1442
+
1443
+ # No /proc (macOS): ps reports the start time to the second, enough to
1444
+ # tell a reused pid apart. nil when the process is gone. Written in the
1445
+ # local time zone and language, so pinned to UTC and C: a dashboard
1446
+ # restarted under another TZ would otherwise read every recorded
1447
+ # process as a stranger.
1448
+ output, status = Open3.capture2(PS_ENVIRONMENT, "ps", "-o", "lstart=", "-p", pid.to_s)
1449
+ status.success? ? output.strip.presence : nil
1450
+ rescue SystemCallError
1451
+ nil
1452
+ end
1453
+
1454
+ def record_process_start(state, pid)
1455
+ started = process_start(pid)
1456
+ state_hash(state, "process_starts")[pid.to_s] = started if started
1457
+ end
1458
+
1459
+ # Whether +pid+, read from state.json, may be signalled as this
1460
+ # sandbox's process group (see #group_identity).
1461
+ def recorded_group?(pid, session_id, state)
1462
+ group_identity(pid, session_id, state) == :ours
1463
+ end
1464
+
1465
+ # What +pid+, read from state.json, is now: :ours, :stranger or
1466
+ # :unknown. A pid is reused once its process is gone, so where the
1467
+ # process is there it must be the one recorded: started when state.json
1468
+ # says, or, for a pid recorded without a start time, carrying this
1469
+ # sandbox's session id in its environment. Where it is gone there is
1470
+ # nothing to confuse: a group id is not reused while any process of the
1471
+ # group lives. :unknown when nothing can tell (no start time recorded,
1472
+ # and no /proc to read its environment, or no permission to read it):
1473
+ # such a pid is never signalled, and never forgotten either.
1474
+ def group_identity(pid, session_id, state)
1475
+ return :stranger unless signalable?(pid)
1476
+
1477
+ started = state["process_starts"][pid.to_s] if state["process_starts"].is_a?(Hash)
1478
+ if started
1479
+ current = process_start(pid)
1480
+ return current.nil? || current == started ? :ours : :stranger
1481
+ end
1482
+ return :unknown unless procfs?
1483
+
1484
+ environ = File.binread("/proc/#{pid}/environ")
1485
+ if environ.empty?
1486
+ # Zombies have no environment and still own their pid. A live
1487
+ # process can also have an empty environment, including during exec:
1488
+ # without a recorded start time it cannot be identified safely.
1489
+ state, = proc_stat(pid)
1490
+ return %w[Z X].include?(state) ? :ours : :unknown
1491
+ end
1492
+ environ.split("\0").include?("#{SESSION_ID_ENV}=#{session_id}") ? :ours : :stranger
1493
+ rescue Errno::ENOENT, Errno::ESRCH
1494
+ :ours
1495
+ rescue Errno::EACCES, Errno::EPERM
1496
+ :unknown
1497
+ end
1498
+
1499
+ # Stops everything the workspace recorded, then removes it. The
1500
+ # terminating mark goes in under the same lock that reads the pids: a
1501
+ # Claude Code session that records itself later finds the mark and stops
1502
+ # on its own (see #record_code_session), and one that recorded itself
1503
+ # earlier is among the pids stopped here.
1504
+ #
1505
+ # Returns false, keeping the workspace, when a recorded group is still
1506
+ # alive afterwards and is not known to be a stranger's: one that could
1507
+ # not be stopped, or not told apart from an unrelated process. Removing
1508
+ # state.json would drop the only record of it; kept, the next terminate
1509
+ # tries again.
1510
+ def discard(session_id)
1511
+ workspace = workspace_for(session_id)
1512
+ return true unless workspace.exist?
1513
+
1514
+ state = begin
1515
+ update_state(workspace) { |current| current["terminating"] = true }
1516
+ rescue Errno::ENOENT, Errno::ENOTDIR
1517
+ {}
1518
+ end
1519
+ sessions = state["code_sessions"].is_a?(Hash) ? state["code_sessions"].values : []
1520
+ recorded = [ state["pid"], state["step_pid"], *sessions ].uniq.select { |pid| signalable?(pid) }
1521
+ identities = recorded.index_with { |pid| group_identity(pid, session_id, state) }
1522
+ stop_groups(recorded.select { |pid| identities[pid] == :ours })
1523
+ stop_escaped(session_id)
1524
+
1525
+ left = recorded.select { |pid| identities[pid] != :stranger && group_alive?(pid) }
1526
+ if left.any?
1527
+ Rails.logger.error("[ActionAgent] sandbox #{session_id}: process groups #{left.join(", ")} are still alive and " \
1528
+ "could not be #{left.any? { |pid| identities[pid] == :unknown } ? "identified" : "stopped"}; " \
1529
+ "keeping #{workspace} so a later terminate can try again")
1530
+ return false
1531
+ end
1532
+
1533
+ # After the server is gone: PostgreSQL refuses to drop a database
1534
+ # while anything is connected to it.
1535
+ drop_databases(workspace)
1536
+ remove_workspace(workspace)
1537
+ true
1538
+ end
1539
+
1540
+ # Processes of this sandbox that left its process groups (setsid, a
1541
+ # daemonizing server) and so escaped #stop_groups, found where /proc
1542
+ # shows every process's environment: exactly this sandbox's session id,
1543
+ # never this process or its group. One that also rewrote its
1544
+ # environment (a long process title) is not found.
1545
+ def stop_escaped(session_id)
1546
+ return unless procfs?
1547
+
1548
+ marker = "#{SESSION_ID_ENV}=#{session_id}"
1549
+ own_group = Process.getpgrp
1550
+ escaped = Dir.children("/proc").filter_map do |entry|
1551
+ next unless entry.match?(/\A\d+\z/)
1552
+
1553
+ pid = entry.to_i
1554
+ next if pid == Process.pid
1555
+
1556
+ state, _ppid, group = proc_stat(entry)
1557
+ next if state.nil? || %w[Z X].include?(state) || group.to_i == own_group
1558
+
1559
+ pid if File.binread("/proc/#{pid}/environ").split("\0").include?(marker)
1560
+ rescue SystemCallError
1561
+ nil
1562
+ end
1563
+ return if escaped.empty?
1564
+
1565
+ escaped.each { |pid| signal_process(pid, "TERM") }
1566
+ deadline = deadline_after(stop_grace)
1567
+ sleep 0.1 while escaped.any? { |pid| process_alive?(pid) } && monotonic < deadline
1568
+ escaped.each { |pid| signal_process(pid, "KILL") if process_alive?(pid) }
1569
+ end
1570
+
1571
+ def signal_process(pid, signal)
1572
+ return false unless pid.is_a?(Integer) && pid > 1 && pid != Process.pid
1573
+
1574
+ Process.kill(signal, pid)
1575
+ true
1576
+ rescue Errno::ESRCH, Errno::EPERM
1577
+ false
1578
+ end
1579
+
1580
+ def process_alive?(pid)
1581
+ state, = proc_stat(pid)
1582
+ !state.nil? && !%w[Z X].include?(state)
1583
+ end
1584
+
1585
+ # Moved aside before it is deleted: a Claude Code session finishing in
1586
+ # this process may still be diffing or updating state.json there, and
1587
+ # has to find the workspace gone rather than recreate parts of it.
1588
+ def remove_workspace(workspace)
1589
+ return unless workspace.exist?
1590
+
1591
+ doomed = workspace.dirname.join(".#{workspace.basename}.removed-#{SecureRandom.hex(4)}")
1592
+ File.rename(workspace, doomed)
1593
+ 3.times do
1594
+ FileUtils.rm_rf(doomed)
1595
+ break unless doomed.exist?
1596
+
1597
+ sleep 0.2
1598
+ end
1599
+ rescue Errno::ENOENT
1600
+ # Removed meanwhile.
1601
+ end
1602
+
1603
+ def background(&block)
1604
+ Thread.new(&block).tap { |thread| thread.report_on_exception = false }
1605
+ end
1606
+
1607
+ def describe(status)
1608
+ status.exitstatus ? "status #{status.exitstatus}" : "signal #{status.termsig}"
1609
+ end
1610
+
1611
+ # --- Workspace ----------------------------------------------------------
1612
+
1613
+ def workspace_for(session_id)
1614
+ ActionAgent.local_sandbox_root.join(session_id)
1615
+ end
1616
+
1617
+ def log_path(workspace, name)
1618
+ workspace.join("logs", "#{name}.log")
1619
+ end
1620
+
1621
+ def session_id!(value)
1622
+ value = value.to_s
1623
+ raise Error, "#{value.inspect} is not a sandbox session id" unless SESSION_ID.match?(value)
1624
+
1625
+ value
1626
+ end
1627
+
1628
+ # The session id in a handle this backend issued, or nil for anything
1629
+ # else.
1630
+ def session_id_from(handle)
1631
+ handle = handle.to_s
1632
+ return nil unless handle.start_with?(HANDLE_PREFIX)
1633
+
1634
+ session_id = handle.delete_prefix(HANDLE_PREFIX)
1635
+ SESSION_ID.match?(session_id) ? session_id : nil
1636
+ end
1637
+
1638
+ # No lock needed: state.json is only ever replaced whole (see
1639
+ # #update_state), so a read sees one version or the next.
1640
+ def read_state(workspace)
1641
+ parse_state(File.read(workspace.join("state.json")))
1642
+ rescue Errno::ENOENT, Errno::ENOTDIR
1643
+ {}
1644
+ end
1645
+
1646
+ # Read-modify-write under an exclusive lock: a Claude Code session
1647
+ # records its pid while terminate may be reading the same file.
1648
+ #
1649
+ # The new state goes to a temporary file in the workspace, which is then
1650
+ # renamed over state.json: a crash mid-write leaves the old version, not
1651
+ # a truncated one. The lock is on state.lock, which is never replaced; a
1652
+ # lock on state.json itself would stay on the inode the rename
1653
+ # unlinked, and cover nothing. Raises Errno::ENOENT when there is no
1654
+ # state.json and +create+ is false, or no workspace at all.
1655
+ def update_state(workspace, create: true)
1656
+ path = workspace.join("state.json")
1657
+ File.open(workspace.join("state.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
1658
+ lock.flock(File::LOCK_EX)
1659
+ current = begin
1660
+ File.read(path)
1661
+ rescue Errno::ENOENT
1662
+ raise unless create
1663
+
1664
+ nil
1665
+ end
1666
+ state = parse_state(current)
1667
+ yield state
1668
+
1669
+ temporary = workspace.join(".state.json.#{SecureRandom.hex(4)}")
1670
+ begin
1671
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
1672
+ file.write(JSON.generate(state))
1673
+ file.flush
1674
+ file.fsync
1675
+ end
1676
+ File.rename(temporary, path)
1677
+ ensure
1678
+ FileUtils.rm_f(temporary)
1679
+ end
1680
+ state
1681
+ end
1682
+ end
1683
+
1684
+ def parse_state(json)
1685
+ state = JSON.parse(json.presence || "{}")
1686
+ state.is_a?(Hash) ? state : {}
1687
+ rescue JSON::ParserError
1688
+ {}
1689
+ end
1690
+
1691
+ # state[key] as a Hash, in place of whatever a damaged file held there.
1692
+ def state_hash(state, key)
1693
+ state[key] = {} unless state[key].is_a?(Hash)
1694
+ state[key]
1695
+ end
1696
+
1697
+ # --- Time ---------------------------------------------------------------
1698
+
1699
+ def monotonic
1700
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1701
+ end
1702
+
1703
+ # No (or a non-positive) timeout means none.
1704
+ def deadline_after(seconds)
1705
+ seconds = seconds.to_f
1706
+ seconds.positive? ? monotonic + seconds : Float::INFINITY
1707
+ end
1708
+
1709
+ # Seconds until +deadline+, or nil for no limit (what Thread#join and
1710
+ # IO#wait_readable take for "forever").
1711
+ def time_left(deadline)
1712
+ deadline.infinite? ? nil : [ deadline - monotonic, 0 ].max
1713
+ end
1714
+ end
1715
+ end