actionagent 1.9.2 → 1.9.3

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -0
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +61 -61
  5. data/app/controllers/action_agent/api/agents_controller.rb +23 -0
  6. data/app/controllers/action_agent/api/code_sessions_controller.rb +49 -10
  7. data/app/controllers/action_agent/api/evaluation_reports_controller.rb +66 -2
  8. data/app/controllers/action_agent/api/evaluations_controller.rb +41 -3
  9. data/app/controllers/action_agent/api/interactions_controller.rb +55 -1
  10. data/app/controllers/action_agent/api/sandboxes_controller.rb +13 -2
  11. data/app/controllers/action_agent/api/trace_reports_controller.rb +11 -6
  12. data/app/jobs/action_agent/code_session_job.rb +19 -2
  13. data/app/models/action_agent/agent.rb +54 -1
  14. data/app/models/action_agent/code_session.rb +80 -0
  15. data/app/models/action_agent/evaluation_run.rb +17 -2
  16. data/app/models/action_agent/provider_key.rb +5 -1
  17. data/app/queries/action_agent/trace_window_report.rb +60 -0
  18. data/app/serializers/action_agent/code_conversation_serializer.rb +86 -0
  19. data/app/serializers/action_agent/evaluation_serializer.rb +13 -2
  20. data/app/services/action_agent/agent_execution_service.rb +37 -0
  21. data/app/services/action_agent/agent_tool_roster.rb +53 -18
  22. data/app/services/action_agent/agent_toolbox.rb +156 -3
  23. data/app/services/action_agent/browser_client_token.rb +86 -0
  24. data/app/services/action_agent/checkout_tools.rb +154 -0
  25. data/app/services/action_agent/code_conversation.rb +133 -0
  26. data/app/services/action_agent/codex_auth.rb +81 -0
  27. data/app/services/action_agent/dashboard_assistant_service.rb +19 -1
  28. data/app/services/action_agent/diff_stat.rb +27 -0
  29. data/app/services/action_agent/evaluation_report_export.rb +175 -0
  30. data/app/services/action_agent/evaluation_report_import.rb +40 -17
  31. data/app/services/action_agent/evaluation_runner_service.rb +48 -12
  32. data/app/services/action_agent/local_sandbox_backend.rb +295 -30
  33. data/app/services/action_agent/mcp_catalog.rb +25 -0
  34. data/app/services/action_agent/mcp_client.rb +1 -0
  35. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -1
  36. data/app/services/action_agent/project_boot_fix.rb +10 -3
  37. data/app/services/action_agent/sandbox_orchestrator.rb +41 -1
  38. data/app/services/action_agent/scenario_evaluation_runner.rb +1 -0
  39. data/app/services/action_agent/slack_tools.rb +101 -0
  40. data/config/routes.rb +10 -1
  41. data/lib/action_agent/engine.rb +6 -0
  42. data/lib/action_agent/version.rb +1 -1
  43. data/lib/action_agent.rb +35 -2
  44. data/lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb +18 -0
  45. data/lib/tasks/evaluations.rake +78 -0
  46. metadata +13 -2
@@ -11,9 +11,11 @@ module ActionAgent
11
11
  # tool-call roundtrips that show up as :tool spans in Traces, tool messages
12
12
  # in Interactions, and tool counts in Metrics.
13
13
  #
14
- # Only tools with a safe server-side implementation are mapped; anything
15
- # else an agent enables (terminal, playwright, ...) is ignored for platform
16
- # execution.
14
+ # Every capability in Agent::AVAILABLE_TOOLS has definitions here. The ones
15
+ # that act for the run — on its checkout (CheckoutTools), its Slack
16
+ # connection (SlackTools), its provider (translate_text), its memory, its
17
+ # workspace's agents, or a person — are routed by AgentExecutionService and
18
+ # have no FUNCTIONS entry.
17
19
  class AgentToolbox
18
20
  FETCH_LIMIT_BYTES = 50_000
19
21
  FETCH_TIMEOUT_SECONDS = 5
@@ -262,6 +264,138 @@ module ActionAgent
262
264
  }
263
265
  }
264
266
  ],
267
+ # The checkout capabilities: they act on the checkout sandbox the run
268
+ # was given (CheckoutTools), so AgentExecutionService routes them, and
269
+ # they are NOT in FUNCTIONS below.
270
+ "terminal" => [
271
+ {
272
+ name: "run_command",
273
+ description: "Run a shell command (bash) in the root of the repository checkout this run was given, with the " \
274
+ "app's environment and database, and return its exit status and combined output. Use it to run tests, " \
275
+ "rake tasks, scripts or git. Each call starts a fresh shell.",
276
+ parameters: {
277
+ type: "object",
278
+ properties: {
279
+ command: { type: "string", description: "The command line, e.g. bin/rails test test/models" },
280
+ timeout_seconds: { type: "integer", description: "Stop the command after this many seconds (at most 120)" }
281
+ },
282
+ required: [ "command" ]
283
+ }
284
+ }
285
+ ],
286
+ "filesystem" => [
287
+ {
288
+ name: "list_files",
289
+ description: "List the files in the repository checkout this run was given, tracked and untracked, without " \
290
+ "the ones .gitignore excludes. Pass a directory to list only what is under it.",
291
+ parameters: {
292
+ type: "object",
293
+ properties: { path: { type: "string", description: "A directory relative to the checkout root, e.g. app/models" } },
294
+ required: []
295
+ }
296
+ },
297
+ {
298
+ name: "read_file",
299
+ description: "Read a text file from the repository checkout this run was given.",
300
+ parameters: {
301
+ type: "object",
302
+ properties: { path: { type: "string", description: "The file's path relative to the checkout root" } },
303
+ required: [ "path" ]
304
+ }
305
+ },
306
+ {
307
+ name: "write_file",
308
+ description: "Write a text file in the repository checkout this run was given, replacing it if it exists and " \
309
+ "creating the directories it needs.",
310
+ parameters: {
311
+ type: "object",
312
+ properties: {
313
+ path: { type: "string", description: "The file's path relative to the checkout root" },
314
+ content: { type: "string", description: "The file's complete new content" }
315
+ },
316
+ required: [ "path", "content" ]
317
+ }
318
+ }
319
+ ],
320
+ "edit" => [
321
+ {
322
+ name: "edit_file",
323
+ description: "Edit a file in the repository checkout this run was given by replacing one exact passage. " \
324
+ "old_text must appear exactly once in the file; include enough surrounding lines to make it unique.",
325
+ parameters: {
326
+ type: "object",
327
+ properties: {
328
+ path: { type: "string", description: "The file's path relative to the checkout root" },
329
+ old_text: { type: "string", description: "The exact text to replace" },
330
+ new_text: { type: "string", description: "What to put in its place" }
331
+ },
332
+ required: [ "path", "old_text", "new_text" ]
333
+ }
334
+ }
335
+ ],
336
+ "database" => [
337
+ {
338
+ name: "query_database",
339
+ description: "Run a read-only SQL query against the app's database in the repository checkout this run was " \
340
+ "given, and return the columns and up to 200 rows. Writes are refused.",
341
+ parameters: {
342
+ type: "object",
343
+ properties: { sql: { type: "string", description: "One SQL statement, e.g. SELECT id, email FROM users LIMIT 10" } },
344
+ required: [ "sql" ]
345
+ }
346
+ }
347
+ ],
348
+ # Through the owner's Slack bot token (SlackTools), routed by
349
+ # AgentExecutionService.
350
+ "slack" => [
351
+ {
352
+ name: "slack_list_channels",
353
+ description: "List the Slack channels the workspace's bot can see, with their ids.",
354
+ parameters: { type: "object", properties: {}, required: [] }
355
+ },
356
+ {
357
+ name: "slack_read_channel",
358
+ description: "Read a Slack channel's most recent messages, newest first.",
359
+ parameters: {
360
+ type: "object",
361
+ properties: {
362
+ channel: { type: "string", description: "The channel id, e.g. C0123456789" },
363
+ limit: { type: "integer", description: "How many messages (default 20, at most 50)" }
364
+ },
365
+ required: [ "channel" ]
366
+ }
367
+ },
368
+ {
369
+ name: "slack_post_message",
370
+ description: "Post a message to a Slack channel as the workspace's bot, or reply in a thread.",
371
+ parameters: {
372
+ type: "object",
373
+ properties: {
374
+ channel: { type: "string", description: "The channel id, e.g. C0123456789" },
375
+ text: { type: "string", description: "The message, in Slack mrkdwn" },
376
+ thread_ts: { type: "string", description: "Reply in the thread of this message ts" }
377
+ },
378
+ required: [ "channel", "text" ]
379
+ }
380
+ }
381
+ ],
382
+ # A generation on the run's own provider, model and credentials,
383
+ # routed by AgentExecutionService.
384
+ "translate" => [
385
+ {
386
+ name: "translate_text",
387
+ description: "Translate text into another language, keeping its formatting, code and placeholders.",
388
+ parameters: {
389
+ type: "object",
390
+ properties: {
391
+ text: { type: "string", description: "The text to translate" },
392
+ target_language: { type: "string", description: "The language to translate into, e.g. Spanish or ja" },
393
+ source_language: { type: "string", description: "The text's language, when known" }
394
+ },
395
+ required: [ "text", "target_language" ]
396
+ }
397
+ }
398
+ ],
265
399
  # Memory tools mirror solid_agent's HasMemory contract. They are NOT in
266
400
  # FUNCTIONS below — execution is subject-bound, so AgentExecutionService
267
401
  # routes them to the run's AgentMemory instead of this module.
@@ -315,6 +449,19 @@ module ActionAgent
315
449
  # AgentExecutionService.
316
450
  INPUT_FUNCTIONS = %w[ask_user request_approval request_secret].freeze
317
451
 
452
+ # The functions with side effects outside the conversation, by the
453
+ # capability that exposes them. They wait for a person's approval unless
454
+ # the agent's approval list says otherwise: Agent puts them on the list
455
+ # when a capability is gained without one (a template, the agents API),
456
+ # and the builder and the Tools tab add them when a capability is
457
+ # switched on (frontend/utils/builderRoster.mjs keeps the same table).
458
+ APPROVAL_BY_DEFAULT = {
459
+ "terminal" => %w[run_command],
460
+ "filesystem" => %w[write_file],
461
+ "edit" => %w[edit_file],
462
+ "slack" => %w[slack_post_message]
463
+ }.freeze
464
+
318
465
  # Function name => implementation method, for routing tool calls.
319
466
  FUNCTIONS = {
320
467
  "fetch_url" => :fetch_url,
@@ -365,6 +512,12 @@ module ActionAgent
365
512
  end.reject { |definition| definition[:name].to_s == REQUEST_SECRET_DEFINITION[:name] }
366
513
  end
367
514
 
515
+ # The approval list +tool_names+ get by default: the side-effecting
516
+ # functions of the capabilities among them (APPROVAL_BY_DEFAULT).
517
+ def default_approvals(tool_names)
518
+ Array(tool_names).flat_map { |name| APPROVAL_BY_DEFAULT[name.to_s] || [] }.uniq
519
+ end
520
+
368
521
  # Whether runs may use the process-wide browser: never in a
369
522
  # multi-tenant install.
370
523
  def shared_browser?
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module ActionAgent
8
+ # Used to give a client that runs other people's code, such as a Claude
9
+ # Code turn, the sandbox's browser without its master token: the sidecar
10
+ # mints a client token for /mcp only (POST /tokens), labelled with who it
11
+ # is for, and the token is revoked once the turn ends (DELETE /tokens/:id).
12
+ # Its shell commands inherit what Claude Code was given, so a token they
13
+ # could read must be worthless once the turn is over.
14
+ class BrowserClientToken
15
+ TIMEOUT = 5
16
+
17
+ class Error < StandardError; end
18
+
19
+ attr_reader :id, :token, :mcp_url
20
+
21
+ # Mints a token for +sandbox+'s running browser.
22
+ #
23
+ # @param sandbox [SandboxSession]
24
+ # @param label [String] what the live view calls the client
25
+ # @return [BrowserClientToken]
26
+ # @raise [Error] when the browser is not running or refuses
27
+ def self.mint(sandbox, label:)
28
+ raise Error, "The sandbox's browser is not running" unless sandbox.browser_running? && sandbox.browser_token.present?
29
+
30
+ mcp_url = URI(sandbox.browser_mcp_url)
31
+ body = request(Net::HTTP::Post, tokens_uri(mcp_url), sandbox.browser_token, JSON.generate(label: label))
32
+ new(id: body.fetch("id"), token: body.fetch("token"), mcp_url: mcp_url.to_s, sandbox: sandbox)
33
+ rescue KeyError, JSON::ParserError
34
+ raise Error, "The browser answered a token request without one"
35
+ end
36
+
37
+ def initialize(id:, token:, mcp_url:, sandbox:)
38
+ @id = id
39
+ @token = token
40
+ @mcp_url = mcp_url
41
+ @sandbox = sandbox
42
+ end
43
+
44
+ # Revokes the token. A browser that stopped meanwhile took its tokens
45
+ # with it, so a failure here is only logged.
46
+ def revoke!
47
+ self.class.request(Net::HTTP::Delete, self.class.tokens_uri(URI(@mcp_url), @id), @sandbox.browser_token)
48
+ true
49
+ rescue Error, SystemCallError, IOError, Timeout::Error, URI::Error => e
50
+ Rails.logger.info("[ActionAgent] browser client token #{@id} not revoked: #{e.message}")
51
+ false
52
+ end
53
+
54
+ # The Claude Code --mcp-config document reaching the browser as the
55
+ # "browser" server with this token.
56
+ def mcp_config
57
+ { mcpServers: { browser: { type: "http", url: mcp_url, headers: { "Authorization" => "Bearer #{token}" } } } }
58
+ end
59
+
60
+ class << self
61
+ def tokens_uri(mcp_url, id = nil)
62
+ uri = mcp_url.dup
63
+ uri.path = id ? "/tokens/#{id}" : "/tokens"
64
+ uri.query = nil
65
+ uri
66
+ end
67
+
68
+ # @return [Hash] the parsed body
69
+ def request(klass, uri, master_token, body = nil)
70
+ raise Error, "Only a loopback browser is reached this way" unless %w[127.0.0.1 localhost ::1 [::1]].include?(uri.host)
71
+
72
+ http = Net::HTTP.new(uri.host, uri.port)
73
+ http.open_timeout = TIMEOUT
74
+ http.read_timeout = TIMEOUT
75
+ request = klass.new(uri)
76
+ request["Authorization"] = "Bearer #{master_token}"
77
+ request["Content-Type"] = "application/json"
78
+ request.body = body if body
79
+ response = http.request(request)
80
+ raise Error, "The browser answered HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
81
+
82
+ response.body.to_s.empty? ? {} : JSON.parse(response.body)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # The toolbox capabilities that work in a checkout: terminal (run_command),
5
+ # filesystem (list_files, read_file, write_file), edit (edit_file) and
6
+ # database (query_database). Each acts on the checkout sandbox the run was
7
+ # given (AgentRun#sandbox_id — the runner's and an evaluation's
8
+ # `sandbox_id`), through SandboxOrchestrator, so they reach exactly what a
9
+ # Claude Code session in that checkout reaches.
10
+ #
11
+ # A run given no checkout gets an error saying how to give it one, rather
12
+ # than a tool that silently does nothing. Results and errors are returned
13
+ # as hashes for the model to read.
14
+ class CheckoutTools
15
+ FUNCTIONS = %w[run_command list_files read_file write_file edit_file query_database].freeze
16
+
17
+ # Rows query_database returns at most, and how long it may take, Rails
18
+ # boot included.
19
+ MAX_QUERY_ROWS = 200
20
+ QUERY_TIMEOUT = 90
21
+ # The line the query script's JSON follows, so whatever the app prints
22
+ # while booting is not mistaken for the result.
23
+ QUERY_MARKER = "__ACTION_AGENT_QUERY__"
24
+
25
+ NO_CHECKOUT = "This run has no checkout sandbox. Choose one under Checkout on the agent's Run page " \
26
+ "(or run an evaluation against one), then run again."
27
+
28
+ def self.function?(name)
29
+ FUNCTIONS.include?(name.to_s)
30
+ end
31
+
32
+ # @param sandbox [SandboxSession, nil] the run's checkout, or nil
33
+ def initialize(sandbox, orchestrator: nil)
34
+ @sandbox = sandbox
35
+ @orchestrator = orchestrator
36
+ end
37
+
38
+ # @return [Hash]
39
+ def call(name, kwargs)
40
+ return { error: "Unknown tool: #{name}" } unless self.class.function?(name)
41
+ return { error: NO_CHECKOUT } unless @sandbox
42
+ unless @sandbox.ready? || @sandbox.running?
43
+ return { error: "The checkout sandbox #{@sandbox.session_id} is #{@sandbox.status}; start it again and rerun" }
44
+ end
45
+
46
+ args = kwargs.to_h.symbolize_keys
47
+ case name.to_s
48
+ when "run_command" then run_command(**args.slice(:command, :timeout_seconds))
49
+ when "list_files" then list_files(**args.slice(:path))
50
+ when "read_file" then read_file(**args.slice(:path))
51
+ when "write_file" then write_file(**args.slice(:path, :content))
52
+ when "edit_file" then edit_file(**args.slice(:path, :old_text, :new_text))
53
+ when "query_database" then query_database(**args.slice(:sql))
54
+ end
55
+ rescue ArgumentError, SandboxOrchestrator::UnsupportedBackendError, LocalSandboxBackend::Error => e
56
+ { error: e.message }
57
+ end
58
+
59
+ def run_command(command:, timeout_seconds: nil)
60
+ orchestrator.run_command(@sandbox, command.to_s, timeout: timeout_seconds&.to_i)
61
+ end
62
+
63
+ def list_files(path: nil)
64
+ orchestrator.list_files(@sandbox, path.presence)
65
+ end
66
+
67
+ def read_file(path:)
68
+ content = orchestrator.read_file(@sandbox, path.to_s)
69
+ return { error: "#{path} does not exist in the checkout" } if content.nil?
70
+
71
+ text = content.dup.force_encoding(Encoding::UTF_8)
72
+ return { error: "#{path} is not a text file" } unless text.valid_encoding?
73
+
74
+ { path: path, content: text }
75
+ end
76
+
77
+ def write_file(path:, content:)
78
+ orchestrator.write_file(@sandbox, path.to_s, content.to_s)
79
+ end
80
+
81
+ # One exact replacement, the edit a model can say precisely: +old_text+
82
+ # must appear exactly once, so the edit lands where it was meant to.
83
+ def edit_file(path:, old_text:, new_text:)
84
+ current = read_file(path: path)
85
+ return current if current[:error]
86
+
87
+ occurrences = current[:content].scan(old_text.to_s).size
88
+ return { error: "old_text is empty" } if old_text.to_s.empty?
89
+ return { error: "old_text was not found in #{path}" } if occurrences.zero?
90
+ return { error: "old_text appears #{occurrences} times in #{path}; include more context so it appears once" } if occurrences > 1
91
+
92
+ written = orchestrator.write_file(@sandbox, path.to_s, current[:content].sub(old_text.to_s) { new_text.to_s })
93
+ written.merge(replaced: 1)
94
+ end
95
+
96
+ # Runs +sql+ in the checkout's own Rails environment, against the
97
+ # database its boot provisioned, inside a read-only transaction that is
98
+ # rolled back (PostgreSQL, MySQL), or on a query_only connection (SQLite). The SQL travels base64-encoded inside the script, so
99
+ # nothing in it reaches the shell.
100
+ def query_database(sql:)
101
+ return { error: "No SQL to run" } if sql.to_s.strip.empty?
102
+
103
+ result = orchestrator.run_command(@sandbox, query_command(sql.to_s), timeout: QUERY_TIMEOUT)
104
+ line = result[:output].to_s.lines.reverse.find { |candidate| candidate.start_with?(QUERY_MARKER) }
105
+ unless line
106
+ return { error: "The query did not run", exit_status: result[:exit_status], output: result[:output].to_s.last(4_000) }
107
+ end
108
+
109
+ JSON.parse(line.delete_prefix(QUERY_MARKER))
110
+ rescue JSON::ParserError
111
+ { error: "The query's result could not be read", output: result[:output].to_s.last(4_000) }
112
+ end
113
+
114
+ private
115
+
116
+ def orchestrator
117
+ @orchestrator ||= SandboxOrchestrator.new
118
+ end
119
+
120
+ def query_command(sql)
121
+ encoded = [ sql ].pack("m0")
122
+ script = <<~RUBY
123
+ sql = "#{encoded}".unpack1("m0").force_encoding("UTF-8")
124
+ connection = ActiveRecord::Base.connection
125
+ adapter = connection.adapter_name.downcase
126
+ out = nil
127
+ read = lambda do
128
+ result = connection.select_all(sql)
129
+ out = { columns: result.columns, rows: result.rows.first(#{MAX_QUERY_ROWS}), row_count: result.rows.size, truncated: result.rows.size > #{MAX_QUERY_ROWS} }
130
+ end
131
+ begin
132
+ if adapter.include?("sqlite")
133
+ # Rails opens SQLite transactions with BEGIN IMMEDIATE, which
134
+ # query_only refuses; query_only alone keeps the connection from writing.
135
+ connection.execute("PRAGMA query_only = ON")
136
+ read.call
137
+ else
138
+ connection.execute("SET SESSION TRANSACTION READ ONLY") if adapter.match?(/mysql|trilogy/)
139
+ connection.transaction do
140
+ connection.execute("SET TRANSACTION READ ONLY") if adapter.include?("postg")
141
+ read.call
142
+ raise ActiveRecord::Rollback
143
+ end
144
+ end
145
+ rescue => e
146
+ out = { error: e.message.to_s[0, 2000] }
147
+ end
148
+ puts "#{QUERY_MARKER}" + out.to_json
149
+ RUBY
150
+
151
+ "bin/rails runner #{Shellwords.escape(script)}"
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to carry on a Claude Code or Codex conversation: a chain of
5
+ # CodeSessions, one per turn, the first being the root (see CodeSession).
6
+ #
7
+ # A turn runs only after the one before it settled, one at a time per
8
+ # checkout. A message sent while a turn runs is queued in the conversation
9
+ # (.post!) and started once that turn settles (.advance!, called by
10
+ # CodeSessionJob). A turn resumes the CLI session the first turn started, so
11
+ # it sees everything said before. That session lives in the CLI's own
12
+ # configuration for the credential the conversation started with, so a
13
+ # conversation keeps that credential: a turn on another is refused.
14
+ module CodeConversation
15
+ # A conversation's length, and how many messages may wait behind its
16
+ # running turn.
17
+ MAX_TURNS = 50
18
+ MAX_QUEUED_TURNS = 3
19
+ MAX_NOTE_CHARACTERS = 2_000
20
+
21
+ class Refused < StandardError; end
22
+
23
+ module_function
24
+
25
+ # Adds a turn to +root+'s conversation: queued, and started at once when
26
+ # nothing else runs in its checkout. The caller has checked the checkout
27
+ # can take a session (CodeSessionsController#refusal_for) and charged the
28
+ # execution.
29
+ #
30
+ # @param root [CodeSession] the conversation's first turn
31
+ # @param prompt [String] the person's message, exactly as typed
32
+ # @param user_id [Integer, nil] who sent it, billed for it
33
+ # @param credential_mode [String, nil] what the turn would run on now
34
+ # @param handoff [Hash, nil] { "taken_at", "released_at", "page_url", "by" }:
35
+ # the sender's own account of driving the browser since the last turn
36
+ # @param model [String, nil]
37
+ # @return [CodeSession] the new turn
38
+ # @raise [Refused]
39
+ def post!(root, prompt:, user_id:, credential_mode: nil, handoff: nil, model: nil)
40
+ raise Refused, "Continue the conversation from its first turn" unless root.root?
41
+
42
+ root.sandbox_session.with_lock do
43
+ turns = root.turns
44
+ raise Refused, "This conversation has reached its #{MAX_TURNS} turns: start a new one" if turns.size >= MAX_TURNS
45
+ raise Refused, "#{MAX_QUEUED_TURNS} messages are already waiting in this conversation" if turns.count(&:queued?) >= MAX_QUEUED_TURNS
46
+ if credential_mode && root.credential_mode && credential_mode != root.credential_mode
47
+ raise Refused, "This conversation ran on another Claude Code credential (#{root.credential_mode}): start a new conversation"
48
+ end
49
+ if (other = busy_elsewhere(root))
50
+ raise Refused, "Another #{other.runner == 'codex' ? 'Codex' : 'Claude Code'} session is #{other.status} in this sandbox"
51
+ end
52
+ raise Refused, "The first turn never started a session to continue: start a new conversation" if resume_id(root).nil?
53
+
54
+ turn = CodeSession.create!(
55
+ sandbox_session: root.sandbox_session,
56
+ conversation_root: root,
57
+ turn_index: turns.last.turn_index + 1,
58
+ prompt: CodeSession.strip_note_tags(prompt),
59
+ context_note: note_for(handoff),
60
+ model: model.presence || root.model,
61
+ runner: root.runner,
62
+ origin: root.origin,
63
+ credential_mode: root.credential_mode,
64
+ resume_session_id: resume_id(root),
65
+ user_id: user_id,
66
+ account_id: root.account_id
67
+ )
68
+ end.tap { advance!(root) }
69
+ end
70
+
71
+ # Starts the oldest queued turn of +root+'s conversation, when no turn of
72
+ # it runs. Called by CodeSessionJob once a turn settled, and by .post!.
73
+ #
74
+ # @return [CodeSession, nil] the turn started
75
+ def advance!(root)
76
+ root = root.root
77
+ turn = root.sandbox_session.with_lock do
78
+ turns = root.turns
79
+ next nil if turns.any?(&:running?)
80
+
81
+ # The root starts by itself (CodeSessionsController#create); a
82
+ # follow-up waits for it. The oldest queued follow-up goes next.
83
+ # Enqueuing one twice does no harm: CodeSessionJob claims a turn
84
+ # atomically, and a second job finds it no longer queued.
85
+ next nil if root.queued?
86
+
87
+ turns.find { |candidate| candidate.queued? && !candidate.root? }
88
+ end
89
+ CodeSessionJob.perform_later(turn.id) if turn
90
+ turn
91
+ end
92
+
93
+ # The CLI session +root+'s conversation resumes: the id the first turn
94
+ # started with, or, for a Codex conversation, the thread its first turn
95
+ # reported.
96
+ def resume_id(root)
97
+ root.runner == "codex" ? root.runner_session_id.presence : root.resume_session_id.presence
98
+ end
99
+
100
+ # A queued or running session in +root+'s checkout that is not a turn of
101
+ # its conversation.
102
+ def busy_elsewhere(root)
103
+ root.sandbox_session.code_sessions.where(status: [ :queued, :running ])
104
+ .where.not(id: root.id).where("conversation_root_id IS NULL OR conversation_root_id <> ?", root.id).first
105
+ end
106
+
107
+ # What the dashboard tells Claude ahead of the message, from the sender's
108
+ # account of driving the browser, or nil.
109
+ def note_for(handoff)
110
+ return nil unless handoff.respond_to?(:to_h)
111
+
112
+ handoff = handoff.to_h.transform_keys(&:to_s)
113
+ by = handoff["by"].to_s.gsub(/[[:cntrl:]]/, " ").strip.presence || "The person"
114
+ taken = timestamp(handoff["taken_at"])
115
+ released = timestamp(handoff["released_at"])
116
+ return nil unless taken || released
117
+
118
+ page = handoff["page_url"].to_s[%r{\Ahttps?://[^\s<>"]{1,500}\z}]
119
+ parts = []
120
+ parts << "#{by} took control of the browser at #{taken}" if taken
121
+ parts << "handed it back at #{released}" if released
122
+ text = "#{parts.join(' and ')}."
123
+ text += " The page on screen was #{page}." if page
124
+ "#{text} The page may have changed: take a snapshot before acting.".truncate(MAX_NOTE_CHARACTERS)
125
+ end
126
+
127
+ def timestamp(value)
128
+ Time.iso8601(value.to_s).utc.strftime("%H:%M:%S UTC")
129
+ rescue ArgumentError
130
+ nil
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # How Codex sessions authenticate (ActionAgent.codex_auth), and the one rule
5
+ # for whether an owner's Codex is "connected", shared by the sandbox
6
+ # listing, the assistant's configuration and the session API so they never
7
+ # disagree. The Codex counterpart of ClaudeCodeAuth.
8
+ #
9
+ # :api_key the owner's OpenAI API key, stored as a ProviderKey and
10
+ # handed to the session as CODEX_API_KEY.
11
+ # :local_login the login of the machine the dashboard runs on, as
12
+ # `codex login status` reports it (see
13
+ # LocalSandboxBackend.codex_login_status). The dashboard never
14
+ # sees that credential.
15
+ module CodexAuth
16
+ module_function
17
+
18
+ # @return [String] "api_key" or "local_login"
19
+ def mode
20
+ ActionAgent.codex_auth.to_s
21
+ end
22
+
23
+ def local_login?
24
+ mode == "local_login"
25
+ end
26
+
27
+ # Why +orchestrator+'s backend cannot run Codex sessions with the
28
+ # configured authentication, or nil. A machine's own login is the
29
+ # dashboard user's, so only a backend running sessions as that user, on
30
+ # that machine, may use it: a container or a remote host would either
31
+ # find no login or need it copied there, which is what this mode exists
32
+ # to never do.
33
+ def backend_refusal(orchestrator)
34
+ return unless local_login?
35
+ return if orchestrator.local?
36
+
37
+ "ActionAgent.codex_auth = :local_login uses this machine's own Codex login, so it works only " \
38
+ "with the :local sandbox backend, not #{orchestrator.backend_name}"
39
+ end
40
+
41
+ # Whether the owner of +provider_keys+ (a ProviderKey scope already
42
+ # narrowed to them) has an API key Codex can run on.
43
+ def api_key_connected?(provider_keys)
44
+ provider_keys.where(provider: "codex").any? { |key| key.runtime_environment.present? }
45
+ end
46
+
47
+ # What the dashboard reports about Codex for the owner of
48
+ # +provider_keys+: booleans and the login's method, never a credential.
49
+ #
50
+ # @return [Hash] { mode:, connected:, login: } (login for :local_login only)
51
+ def status(provider_keys)
52
+ if local_login?
53
+ # Only the :local backend uses the login, and no other one needs
54
+ # this machine's CLI asked about it.
55
+ login = local_backend? ? LocalSandboxBackend.codex_login_status : LocalSandboxBackend::LOGGED_OUT
56
+ { mode: mode, connected: login[:logged_in], login: login.slice(:logged_in, :auth_method) }
57
+ else
58
+ { mode: mode, connected: api_key_connected?(provider_keys) }
59
+ end
60
+ end
61
+
62
+ # A backend that cannot even be loaded is not the :local one.
63
+ def local_backend?
64
+ SandboxOrchestrator.new.local?
65
+ rescue StandardError, LoadError
66
+ false
67
+ end
68
+
69
+ # Why a Codex session cannot start in +sandbox+ for want of credentials,
70
+ # or nil.
71
+ def credential_refusal(sandbox)
72
+ if local_login?
73
+ return if LocalSandboxBackend.codex_login_status[:logged_in]
74
+
75
+ "Codex is not logged in on this machine: run `codex login` as the user the dashboard runs as"
76
+ elsif sandbox.runtime_environment(runner: "codex").blank?
77
+ "Codex is not connected: connect an OpenAI API key in Settings -> Integrations first"
78
+ end
79
+ end
80
+ end
81
+ end