lib/openagents/scv/executor/open_code.ex

58e6347eeb72 · 31 KB

defmodule OpenAgents.SCV.Executor.OpenCode do
  @moduledoc """
  Runs one bounded OpenCode process under the local SCV control boundary.

  This initial executor treats the OpenCode process as one coarse effect. It
  isolates OpenCode state, denies unsafe capabilities, records JSON events, and
  collects host-observed measurements. A later worker transport persists each
  individual OpenCode tool request before execution.
  """

  alias OpenAgents.SCV.OpenCodeEvents
  alias OpenAgents.SCV.OpenCodeReport
  alias OpenAgents.SCV.ResourceSampler

  @schema "openagents.scv.opencode.run.v1"
  @default_model "openai/gpt-5.6-luna"
  @default_reasoning_effort "low"
  @reasoning_efforts ~w(none low)
  @default_timeout_ms 5 * 60 * 1_000
  @maximum_timeout_ms 60 * 60 * 1_000
  @default_maximum_output_bytes 16 * 1_024 * 1_024
  @maximum_output_bytes 64 * 1_024 * 1_024
  @default_sample_interval_ms 250
  @default_heartbeat_interval_ms 5_000
  @maximum_prompt_bytes 32_768
  @maximum_model_bytes 128
  @model_pattern ~r/\A[a-zA-Z0-9_.:-]+\/[a-zA-Z0-9_.:-]+\z/

  @spec run(Path.t(), String.t(), keyword()) :: {:ok, map()} | {:error, atom() | tuple()}
  def run(repository, prompt, options \\ []) do
    with {:ok, input} <- validate_input(repository, prompt, options),
         :ok <-
           emit_event(input, "run_preparing", %{
             model: input.model,
             reasoning_effort: input.reasoning_effort,
             permission_profile: Atom.to_string(input.permissions)
           }),
         {:ok, paths} <- prepare_paths(input.output_root, input.run_id, input.config_seed) do
      try do
        execute(input, paths, options)
      after
        File.rm_rf(paths.scratch)
      end
    end
  end

  defp validate_input(repository, prompt, options) do
    model = Keyword.get(options, :model, @default_model)
    reasoning_effort = Keyword.get(options, :reasoning_effort, @default_reasoning_effort)
    timeout_ms = Keyword.get(options, :timeout_ms, @default_timeout_ms)

    maximum_output_bytes =
      Keyword.get(options, :maximum_output_bytes, @default_maximum_output_bytes)

    sample_interval_ms = Keyword.get(options, :sample_interval_ms, @default_sample_interval_ms)
    permissions = Keyword.get(options, :permissions, :read_only)
    run_id = Keyword.get(options, :run_id, Ecto.UUID.generate())
    output_root = Keyword.get(options, :output_root, default_output_root())
    api_key = Keyword.get(options, :api_key, System.get_env("OPENAI_API_KEY"))
    opencode_api_key = Keyword.get(options, :opencode_api_key)
    models_fetch = Keyword.get(options, :models_fetch, false)
    executable = Keyword.get(options, :executable, default_executable())
    config_seed = Keyword.get(options, :config_seed)
    diagnostic_logs = Keyword.get(options, :diagnostic_logs, false)
    repository_revision = Keyword.get(options, :repository_revision)
    event_context = Keyword.get(options, :event_context, %{})
    run_context = Keyword.get(options, :run_context, %{})

    heartbeat_interval_ms =
      Keyword.get(options, :heartbeat_interval_ms, @default_heartbeat_interval_ms)

    event_sink = Keyword.get(options, :event_sink, fn _event -> :ok end)

    with {:ok, repository} <- validate_directory(repository),
         :ok <- validate_prompt(prompt),
         :ok <- validate_model(model),
         :ok <- validate_reasoning_effort(reasoning_effort),
         :ok <- validate_integer(timeout_ms, 1, @maximum_timeout_ms, :timeout_invalid),
         :ok <-
           validate_integer(
             maximum_output_bytes,
             1,
             @maximum_output_bytes,
             :maximum_output_invalid
           ),
         :ok <- validate_integer(sample_interval_ms, 10, 60_000, :sample_interval_invalid),
         :ok <- validate_permissions(permissions),
         :ok <- validate_run_id(run_id),
         {:ok, output_root} <- validate_output_root(output_root),
         :ok <- validate_api_key(model, api_key),
         :ok <- validate_optional_api_key(opencode_api_key),
         :ok <- validate_boolean(models_fetch, :models_fetch_invalid),
         :ok <- validate_boolean(diagnostic_logs, :diagnostic_logs_invalid),
         :ok <-
           validate_integer(
             heartbeat_interval_ms,
             250,
             60_000,
             :heartbeat_interval_invalid
           ),
         :ok <- validate_event_sink(event_sink),
         :ok <- validate_repository_revision(repository_revision),
         :ok <- validate_context(event_context, :event_context_invalid),
         :ok <- validate_context(run_context, :run_context_invalid),
         {:ok, config_seed} <- validate_config_seed(config_seed),
         {:ok, executable} <- validate_executable(executable) do
      {:ok,
       %{
         repository: repository,
         prompt: prompt,
         prompt_bytes: byte_size(prompt),
         model: model,
         reasoning_effort: reasoning_effort,
         timeout_ms: timeout_ms,
         maximum_output_bytes: maximum_output_bytes,
         sample_interval_ms: sample_interval_ms,
         permissions: permissions,
         run_id: run_id,
         output_root: output_root,
         api_key: api_key,
         opencode_api_key: opencode_api_key,
         models_fetch: models_fetch,
         executable: executable,
         config_seed: config_seed,
         diagnostic_logs: diagnostic_logs,
         repository_revision: repository_revision,
         event_context: event_context,
         run_context: run_context,
         heartbeat_interval_ms: heartbeat_interval_ms,
         event_sink: event_sink
       }}
    end
  end

  defp prepare_paths(output_root, run_id, config_seed) do
    run_dir = Path.join(output_root, run_id)

    paths = %{
      run_dir: run_dir,
      artifacts: Path.join(run_dir, "artifacts"),
      events: Path.join([run_dir, "artifacts", "events.jsonl"]),
      summary: Path.join(run_dir, "summary.json"),
      scratch: Path.join(run_dir, "scratch"),
      home: Path.join([run_dir, "scratch", "home"]),
      config_root: Path.join([run_dir, "scratch", "xdg", "config"]),
      config: Path.join([run_dir, "scratch", "xdg", "config", "opencode"]),
      data: Path.join([run_dir, "scratch", "xdg", "data"]),
      state: Path.join([run_dir, "scratch", "xdg", "state"]),
      cache: Path.join([run_dir, "scratch", "xdg", "cache"]),
      tmp: Path.join([run_dir, "scratch", "tmp"]),
      database: Path.join([run_dir, "scratch", "opencode.db"]),
      prompt: Path.join([run_dir, "scratch", "prompt.txt"])
    }

    directories = [
      paths.artifacts,
      paths.home,
      paths.config,
      paths.data,
      paths.state,
      paths.cache,
      paths.tmp
    ]

    if File.exists?(run_dir) do
      {:error, :run_exists}
    else
      with :ok <- File.mkdir_p(run_dir),
           :ok <- File.chmod(run_dir, 0o700),
           :ok <- create_directories(directories),
           :ok <- seed_config(paths.config, config_seed) do
        {:ok, paths}
      end
    end
  end

  defp execute(input, paths, options) do
    started_at = DateTime.utc_now()
    started_ms = monotonic_ms()
    permission_map = permission_map(input.permissions)
    config = open_code_config()
    environment = command_environment(input, paths, config, permission_map)
    arguments = command_arguments(input)
    sample_fun = Keyword.get(options, :sample_fun, &ResourceSampler.sample/1)

    with :ok <- write_prompt(paths.prompt, input.prompt),
         {:ok, events_io} <- File.open(paths.events, [:write, :binary]),
         :ok <- File.chmod(paths.events, 0o600) do
      :ok = emit_event(input, "process_starting", %{executable: input.executable})

      execution =
        try do
          run_port(input, arguments, environment, events_io, sample_fun)
        after
          File.close(events_io)
        end

      result =
        build_result(
          input,
          paths,
          execution,
          started_at,
          started_ms,
          config,
          permission_map
        )

      with :ok <- write_summary(paths.summary, result) do
        :ok =
          emit_event(input, "run_finished", %{
            status: result.status,
            duration_ms: result.duration_ms,
            summary_path: paths.summary
          })

        {:ok, Map.put(result, :summary_path, paths.summary)}
      end
    else
      {:error, {:prompt_write_failed, _reason}} = error -> error
      {:error, reason} -> {:error, {:artifact_open_failed, reason}}
    end
  end

  defp run_port(input, arguments, environment, events_io, sample_fun) do
    shell = System.find_executable("sh") || "/bin/sh"

    shell_arguments = [
      "-c",
      "exec \"$@\" < \"$SCV_PROMPT_FILE\"",
      "scv-opencode",
      input.executable | arguments
    ]

    port =
      Port.open(
        {:spawn_executable, String.to_charlist(shell)},
        [
          :binary,
          :exit_status,
          :stderr_to_stdout,
          args: Enum.map(shell_arguments, &String.to_charlist/1),
          cd: String.to_charlist(input.repository),
          env: port_environment(environment)
        ]
      )

    {:os_pid, os_pid} = Port.info(port, :os_pid)
    now = monotonic_ms()

    :ok = emit_event(input, "process_started", %{os_pid: os_pid})

    state = %{
      events: OpenCodeEvents.new(),
      report: OpenCodeReport.new(),
      line_buffer: "",
      observed_output_bytes: 0,
      captured_output_bytes: 0,
      output_truncated?: false,
      sample_count: 0,
      sample_error_count: 0,
      peak_rss_bytes: 0,
      maximum_cpu_percent: 0.0,
      started_ms: now,
      next_heartbeat_ms: now + input.heartbeat_interval_ms,
      heartbeat_interval_ms: input.heartbeat_interval_ms,
      input: input
    }

    collect_port(
      port,
      os_pid,
      events_io,
      state,
      monotonic_ms() + input.timeout_ms,
      now,
      input.sample_interval_ms,
      input.maximum_output_bytes,
      sample_fun,
      [input.api_key, input.opencode_api_key]
    )
  rescue
    error ->
      %{
        status: "failed",
        exit_status: nil,
        error_code: "process_start_failed",
        error_detail: Exception.message(error),
        events: OpenCodeEvents.new(),
        report: OpenCodeReport.new(),
        observed_output_bytes: 0,
        captured_output_bytes: 0,
        output_truncated?: false,
        sample_count: 0,
        sample_error_count: 0,
        peak_rss_bytes: 0,
        maximum_cpu_percent: 0.0
      }
  end

  defp collect_port(
         port,
         os_pid,
         events_io,
         state,
         deadline_ms,
         next_sample_ms,
         sample_interval_ms,
         maximum_output_bytes,
         sample_fun,
         redactions
       ) do
    now = monotonic_ms()

    cond do
      now >= deadline_ms ->
        terminate_port(port, os_pid)
        finish_collection(state, events_io, redactions, "timeout", nil, "command_timeout")

      now >= next_sample_ms ->
        sampled = sample_resources(state, os_pid, sample_fun)

        collect_port(
          port,
          os_pid,
          events_io,
          sampled,
          deadline_ms,
          now + sample_interval_ms,
          sample_interval_ms,
          maximum_output_bytes,
          sample_fun,
          redactions
        )

      true ->
        wait_ms = max(min(deadline_ms, next_sample_ms) - now, 1)

        receive do
          {^port, {:data, data}} when is_binary(data) ->
            {updated, limit_reached?} =
              capture_output(state, data, events_io, maximum_output_bytes, redactions)

            if limit_reached? do
              terminate_port(port, os_pid)

              finish_collection(
                updated,
                events_io,
                redactions,
                "output_limit",
                nil,
                "output_limit"
              )
            else
              collect_port(
                port,
                os_pid,
                events_io,
                updated,
                deadline_ms,
                next_sample_ms,
                sample_interval_ms,
                maximum_output_bytes,
                sample_fun,
                redactions
              )
            end

          {^port, {:exit_status, exit_status}} ->
            status = if exit_status == 0, do: "succeeded", else: "failed"
            error_code = if exit_status == 0, do: nil, else: "command_failed"

            finish_collection(
              state,
              events_io,
              redactions,
              status,
              exit_status,
              error_code
            )
        after
          wait_ms ->
            collect_port(
              port,
              os_pid,
              events_io,
              state,
              deadline_ms,
              next_sample_ms,
              sample_interval_ms,
              maximum_output_bytes,
              sample_fun,
              redactions
            )
        end
    end
  end

  defp capture_output(state, data, events_io, maximum_output_bytes, redactions) do
    observed = state.observed_output_bytes + byte_size(data)
    available = max(maximum_output_bytes - state.captured_output_bytes, 0)
    captured = binary_part(data, 0, min(byte_size(data), available))
    updated = append_event_bytes(state, captured, events_io, redactions)

    updated = %{
      updated
      | observed_output_bytes: observed,
        captured_output_bytes: updated.captured_output_bytes + byte_size(captured),
        output_truncated?: byte_size(captured) < byte_size(data)
    }

    {updated, updated.output_truncated? or updated.captured_output_bytes >= maximum_output_bytes}
  end

  defp append_event_bytes(state, "", _events_io, _redactions), do: state

  defp append_event_bytes(state, data, events_io, redactions) do
    pieces = :binary.split(state.line_buffer <> data, "\n", [:global])
    {buffer, complete_lines} = List.pop_at(pieces, -1)

    {events, report} =
      Enum.reduce(complete_lines, {state.events, state.report}, fn line, {events, report} ->
        redacted = redact(line, redactions)
        :ok = IO.binwrite(events_io, redacted <> "\n")
        :ok = observe_output_line(state.input, redacted)
        {ingest_nonempty(events, redacted), ingest_report(report, redacted)}
      end)

    %{state | events: events, report: report, line_buffer: buffer || ""}
  end

  defp finish_collection(state, events_io, redactions, status, exit_status, error_code) do
    {events, report, line_buffer} =
      flush_line(
        state.input,
        state.events,
        state.report,
        state.line_buffer,
        events_io,
        redactions
      )

    :ok = :file.sync(events_io)

    :ok =
      emit_event(state.input, "process_finished", %{
        status: status,
        exit_status: exit_status,
        error_code: error_code
      })

    state
    |> Map.put(:events, events)
    |> Map.put(:report, report)
    |> Map.put(:line_buffer, line_buffer)
    |> Map.put(:status, status)
    |> Map.put(:exit_status, exit_status)
    |> Map.put(:error_code, error_code)
    |> Map.put(:error_detail, nil)
  end

  defp flush_line(_input, events, report, "", _events_io, _redactions),
    do: {events, report, ""}

  defp flush_line(input, events, report, line, events_io, redactions) do
    redacted = redact(line, redactions)
    :ok = IO.binwrite(events_io, redacted)
    :ok = observe_output_line(input, redacted)

    {
      ingest_nonempty(events, redacted),
      ingest_report(report, redacted),
      ""
    }
  end

  defp ingest_nonempty(events, ""), do: events
  defp ingest_nonempty(events, line), do: OpenCodeEvents.ingest(events, line)

  defp ingest_report(report, ""), do: report
  defp ingest_report(report, line), do: OpenCodeReport.ingest(report, line)

  defp observe_output_line(_input, ""), do: :ok

  defp observe_output_line(input, line) do
    case Jason.decode(line) do
      {:ok, event} when is_map(event) ->
        emit_event(input, "opencode_event", live_open_code_event(event))

      _invalid when input.diagnostic_logs ->
        emit_event(input, "opencode_diagnostic", %{line: line})

      _invalid ->
        :ok
    end
  end

  defp live_open_code_event(event) do
    part = if is_map(event["part"]), do: event["part"], else: %{}
    state = if is_map(part["state"]), do: part["state"], else: %{}

    %{
      event_type: bounded_value(event["type"], 64, "unknown"),
      session_id: bounded_value(event["sessionID"], 128, nil),
      tool: bounded_value(part["tool"], 128, nil),
      tool_status: bounded_value(state["status"], 32, nil),
      text_bytes: byte_count(part["text"]),
      error_name: bounded_value(get_in(event, ["error", "name"]), 128, nil)
    }
  end

  defp sample_resources(state, os_pid, sample_fun) do
    sampled =
      case sample_fun.(os_pid) do
        {:ok, %{rss_bytes: rss_bytes, cpu_percent: cpu_percent}}
        when is_integer(rss_bytes) and rss_bytes >= 0 and is_number(cpu_percent) and
               cpu_percent >= 0 ->
          %{
            state
            | sample_count: state.sample_count + 1,
              peak_rss_bytes: max(state.peak_rss_bytes, rss_bytes),
              maximum_cpu_percent: max(state.maximum_cpu_percent, cpu_percent)
          }

        _error ->
          %{state | sample_error_count: state.sample_error_count + 1}
      end

    maybe_emit_heartbeat(sampled)
  end

  defp maybe_emit_heartbeat(state) do
    now = monotonic_ms()

    if now >= state.next_heartbeat_ms do
      :ok =
        emit_event(state.input, "heartbeat", %{
          elapsed_ms: max(now - state.started_ms, 0),
          peak_rss_bytes: state.peak_rss_bytes,
          maximum_cpu_percent: state.maximum_cpu_percent,
          observed_output_bytes: state.observed_output_bytes,
          sample_count: state.sample_count
        })

      %{state | next_heartbeat_ms: now + state.heartbeat_interval_ms}
    else
      state
    end
  end

  defp terminate_port(port, os_pid) do
    terminate_os_process(os_pid)

    if Port.info(port) do
      Port.close(port)
    end

    :ok
  rescue
    ArgumentError -> :ok
  end

  defp terminate_os_process(os_pid) when is_integer(os_pid) and os_pid > 0 do
    case System.find_executable("kill") do
      nil ->
        :ok

      executable ->
        System.cmd(executable, ["-KILL", Integer.to_string(os_pid)], stderr_to_stdout: true)
    end

    :ok
  rescue
    _error -> :ok
  end

  defp build_result(input, paths, execution, started_at, started_ms, config, permissions) do
    finished_at = DateTime.utc_now()
    duration_ms = max(monotonic_ms() - started_ms, 0)
    events_bytes = File.read!(paths.events)

    %{
      schema: @schema,
      run_id: input.run_id,
      status: execution.status,
      error_code: execution.error_code,
      exit_status: execution.exit_status,
      started_at: DateTime.to_iso8601(started_at),
      finished_at: DateTime.to_iso8601(finished_at),
      duration_ms: duration_ms,
      repository: %{
        path: input.repository,
        git_sha: input.repository_revision || git_sha(input.repository)
      },
      scv: input.run_context,
      runtime: %{
        adapter: "opencode",
        executable: input.executable,
        model: input.model,
        reasoning_effort: input.reasoning_effort,
        models_fetch: input.models_fetch,
        diagnostic_logs: input.diagnostic_logs,
        config_seeded: not is_nil(input.config_seed),
        permission_profile: Atom.to_string(input.permissions),
        permission_digest: digest(Jason.encode!(permissions)),
        config_digest: digest(Jason.encode!(config))
      },
      request: %{
        prompt_bytes: input.prompt_bytes,
        timeout_ms: input.timeout_ms,
        maximum_output_bytes: input.maximum_output_bytes
      },
      events: OpenCodeEvents.summary(execution.events),
      report: OpenCodeReport.summary(execution.report),
      resources: %{
        wall_time_ms: duration_ms,
        sample_count: execution.sample_count,
        sample_error_count: execution.sample_error_count,
        peak_rss_bytes: execution.peak_rss_bytes,
        maximum_cpu_percent: execution.maximum_cpu_percent,
        observed_output_bytes: execution.observed_output_bytes,
        captured_output_bytes: execution.captured_output_bytes,
        output_truncated: execution.output_truncated?
      },
      artifacts: %{
        events_path: paths.events,
        events_bytes: byte_size(events_bytes),
        events_digest: digest(events_bytes)
      }
    }
  end

  defp write_summary(path, result) do
    temporary = path <> ".tmp-" <> Integer.to_string(System.unique_integer([:positive]))
    bytes = Jason.encode!(result, pretty: true)

    with :ok <- File.write(temporary, bytes, [:binary, :exclusive]),
         :ok <- File.chmod(temporary, 0o600),
         :ok <- File.rename(temporary, path) do
      :ok
    else
      {:error, reason} ->
        File.rm(temporary)
        {:error, {:summary_write_failed, reason}}
    end
  end

  defp write_prompt(path, prompt) do
    with :ok <- File.write(path, prompt, [:binary, :exclusive]),
         :ok <- File.chmod(path, 0o600) do
      :ok
    else
      {:error, reason} -> {:error, {:prompt_write_failed, reason}}
    end
  end

  defp command_arguments(input) do
    arguments = [
      "run",
      "--pure",
      "--format",
      "json",
      "--model",
      input.model,
      "--variant",
      input.reasoning_effort,
      "--dir",
      input.repository
    ]

    if input.diagnostic_logs do
      ["--print-logs", "--log-level", "DEBUG" | arguments]
    else
      arguments
    end
  end

  defp command_environment(input, paths, config, permissions) do
    safe = %{
      "CI" => "1",
      "GIT_CONFIG_NOSYSTEM" => "1",
      "GIT_TERMINAL_PROMPT" => "0",
      "HOME" => paths.home,
      "LANG" => "C.UTF-8",
      "LC_ALL" => "C.UTF-8",
      "LOGNAME" => "scv",
      "NO_COLOR" => "1",
      "OPENCODE_CLIENT" => "scv",
      "OPENCODE_CONFIG_CONTENT" => Jason.encode!(config),
      "OPENCODE_CONFIG_DIR" => paths.config,
      "OPENCODE_DB" => paths.database,
      "OPENCODE_DISABLE_AUTOUPDATE" => "1",
      "OPENCODE_DISABLE_CLAUDE_CODE" => "1",
      "OPENCODE_DISABLE_DEFAULT_PLUGINS" => "1",
      "OPENCODE_DISABLE_EMBEDDED_WEB_UI" => "1",
      "OPENCODE_DISABLE_EXTERNAL_SKILLS" => "1",
      "OPENCODE_DISABLE_LSP_DOWNLOAD" => "1",
      "OPENCODE_DISABLE_MODELS_FETCH" => if(input.models_fetch, do: "0", else: "1"),
      "OPENCODE_DISABLE_PROJECT_CONFIG" => "1",
      "OPENCODE_DISABLE_SHARE" => "1",
      "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER" => "1",
      "OPENCODE_PERMISSION" => Jason.encode!(permissions),
      "OPENCODE_PURE" => "1",
      "PATH" => System.get_env("PATH", "/usr/local/bin:/usr/bin:/bin"),
      "PWD" => input.repository,
      "SCV_PROMPT_FILE" => paths.prompt,
      "SHELL" => "/bin/sh",
      "TERM" => "dumb",
      "TMPDIR" => paths.tmp,
      "USER" => "scv",
      "XDG_CACHE_HOME" => paths.cache,
      "XDG_CONFIG_HOME" => paths.config_root,
      "XDG_DATA_HOME" => paths.data,
      "XDG_STATE_HOME" => paths.state
    }

    System.get_env()
    |> Map.new(fn {key, _value} -> {key, false} end)
    |> Map.merge(safe)
    |> put_credential("OPENAI_API_KEY", input.api_key)
    |> put_credential("OPENCODE_API_KEY", input.opencode_api_key)
  end

  # A credential this run does not hold stays scrubbed to `false` rather than
  # exported empty, so the child process cannot mistake "" for a usable key.
  defp put_credential(environment, key, value) when is_binary(value) and value != "",
    do: Map.put(environment, key, value)

  defp put_credential(environment, _key, _value), do: environment

  defp port_environment(environment) do
    Enum.map(environment, fn
      {key, false} -> {String.to_charlist(key), false}
      {key, value} -> {String.to_charlist(key), String.to_charlist(value)}
    end)
  end

  defp permission_map(:read_only) do
    %{
      "bash" => "deny",
      "edit" => "deny",
      "external_directory" => "deny",
      "glob" => "allow",
      "grep" => "allow",
      "list" => "allow",
      "lsp" => "deny",
      "question" => "deny",
      "read" => "allow",
      "skill" => "deny",
      "task" => "deny",
      "webfetch" => "deny",
      "websearch" => "deny"
    }
  end

  defp permission_map(:workspace_write) do
    permission_map(:read_only)
    |> Map.put("edit", "allow")
  end

  defp open_code_config do
    %{
      "$schema" => "https://opencode.ai/config.json",
      "autoupdate" => false,
      "share" => "disabled",
      "username" => "scv"
    }
  end

  defp create_directories(directories) do
    Enum.reduce_while(directories, :ok, fn directory, :ok ->
      case File.mkdir_p(directory) do
        :ok ->
          case File.chmod(directory, 0o700) do
            :ok -> {:cont, :ok}
            {:error, reason} -> {:halt, {:error, {:directory_chmod_failed, reason}}}
          end

        {:error, reason} ->
          {:halt, {:error, {:directory_create_failed, reason}}}
      end
    end)
  end

  defp seed_config(_destination, nil), do: :ok

  defp seed_config(destination, source) do
    entries = ["node_modules", "package.json", "bun.lock", "bun.lockb", "package-lock.json"]

    Enum.reduce_while(entries, :ok, fn entry, :ok ->
      source_path = Path.join(source, entry)
      destination_path = Path.join(destination, entry)

      if File.exists?(source_path) do
        case File.cp_r(source_path, destination_path) do
          {:ok, _copied} -> {:cont, :ok}
          {:error, _path, reason} -> {:halt, {:error, {:config_seed_failed, reason}}}
        end
      else
        {:cont, :ok}
      end
    end)
  end

  defp validate_directory(repository) when is_binary(repository) do
    expanded = Path.expand(repository)

    cond do
      Path.type(repository) != :absolute -> {:error, :repository_not_absolute}
      not File.dir?(expanded) -> {:error, :repository_not_found}
      true -> {:ok, expanded}
    end
  end

  defp validate_directory(_repository), do: {:error, :repository_invalid}

  defp validate_prompt(prompt)
       when is_binary(prompt) and byte_size(prompt) in 1..@maximum_prompt_bytes do
    if String.trim(prompt) == "", do: {:error, :prompt_empty}, else: :ok
  end

  defp validate_prompt(_prompt), do: {:error, :prompt_invalid}

  defp validate_model(model)
       when is_binary(model) and byte_size(model) in 1..@maximum_model_bytes do
    if Regex.match?(@model_pattern, model), do: :ok, else: {:error, :model_invalid}
  end

  defp validate_model(_model), do: {:error, :model_invalid}

  defp validate_reasoning_effort(reasoning_effort)
       when reasoning_effort in @reasoning_efforts,
       do: :ok

  defp validate_reasoning_effort(_reasoning_effort), do: {:error, :reasoning_effort_invalid}

  defp validate_integer(value, minimum, maximum, _error)
       when is_integer(value) and value >= minimum and value <= maximum,
       do: :ok

  defp validate_integer(_value, _minimum, _maximum, error), do: {:error, error}

  defp validate_boolean(value, _error) when is_boolean(value), do: :ok
  defp validate_boolean(_value, error), do: {:error, error}

  defp validate_event_sink(event_sink) when is_function(event_sink, 1), do: :ok
  defp validate_event_sink(_event_sink), do: {:error, :event_sink_invalid}

  defp validate_repository_revision(nil), do: :ok

  defp validate_repository_revision(revision) when is_binary(revision) do
    if Regex.match?(~r/\A[0-9a-f]{40}\z/, revision),
      do: :ok,
      else: {:error, :repository_revision_invalid}
  end

  defp validate_repository_revision(_revision), do: {:error, :repository_revision_invalid}

  defp validate_context(context, error) when is_map(context) and map_size(context) <= 16 do
    allowed = MapSet.new([:driver, :environment, :runner, :capabilities])

    if context |> Map.keys() |> MapSet.new() |> MapSet.subset?(allowed),
      do: :ok,
      else: {:error, error}
  end

  defp validate_context(_context, error), do: {:error, error}

  defp validate_permissions(permissions) when permissions in [:read_only, :workspace_write],
    do: :ok

  defp validate_permissions(_permissions), do: {:error, :permissions_invalid}

  defp validate_run_id(run_id) when is_binary(run_id) do
    case Ecto.UUID.cast(run_id) do
      {:ok, ^run_id} -> :ok
      _invalid -> {:error, :run_id_invalid}
    end
  end

  defp validate_run_id(_run_id), do: {:error, :run_id_invalid}

  defp validate_output_root(output_root) when is_binary(output_root) do
    if Path.type(output_root) == :absolute,
      do: {:ok, Path.expand(output_root)},
      else: {:error, :output_root_not_absolute}
  end

  defp validate_output_root(_output_root), do: {:error, :output_root_invalid}

  # The OpenAI key is a per-provider credential, not a precondition of running
  # OpenCode. An `openai/...` model cannot answer without it; a model served by
  # any other provider (the OpenCode Zen gateway, for instance) must not be
  # blocked on a credential it never reads.
  defp validate_api_key("openai/" <> _model, api_key), do: validate_provider_key(api_key)
  defp validate_api_key(_model, nil), do: :ok
  defp validate_api_key(_model, api_key), do: validate_provider_key(api_key)

  defp validate_provider_key(api_key)
       when is_binary(api_key) and byte_size(api_key) in 8..16_384,
       do: :ok

  defp validate_provider_key(_api_key), do: {:error, :openai_api_key_missing}

  defp validate_optional_api_key(nil), do: :ok

  defp validate_optional_api_key(api_key)
       when is_binary(api_key) and byte_size(api_key) in 8..16_384,
       do: :ok

  defp validate_optional_api_key(_api_key), do: {:error, :opencode_api_key_invalid}

  defp validate_config_seed(nil), do: {:ok, nil}

  defp validate_config_seed(config_seed) when is_binary(config_seed) do
    expanded = Path.expand(config_seed)

    cond do
      Path.type(config_seed) != :absolute ->
        {:error, :config_seed_not_absolute}

      not File.dir?(expanded) ->
        {:error, :config_seed_not_found}

      not File.dir?(Path.join(expanded, "node_modules")) ->
        {:error, :config_seed_dependencies_missing}

      true ->
        {:ok, expanded}
    end
  end

  defp validate_config_seed(_config_seed), do: {:error, :config_seed_invalid}

  defp validate_executable(executable) when is_binary(executable) do
    expanded = Path.expand(executable)

    with true <- Path.type(executable) == :absolute or {:error, :executable_not_absolute},
         {:ok, %{type: :regular, mode: mode}} <- File.stat(expanded),
         true <- Bitwise.band(mode, 0o111) != 0 or {:error, :executable_not_executable} do
      {:ok, expanded}
    else
      {:error, _reason} = error -> error
      _invalid -> {:error, :executable_invalid}
    end
  end

  defp validate_executable(_executable), do: {:error, :executable_missing}

  defp default_executable do
    System.get_env("OPENCODE_BIN") || System.find_executable("opencode")
  end

  defp default_output_root do
    Path.expand("_build/scv/runs", File.cwd!())
  end

  defp emit_event(input, type, data) do
    event =
      %{
        schema: "openagents.scv.event.v1",
        run_id: input.run_id,
        type: type,
        emitted_at: DateTime.utc_now() |> DateTime.to_iso8601()
      }
      |> Map.merge(input.event_context)
      |> Map.merge(data)

    try do
      input.event_sink.(event)
    rescue
      _error -> :ok
    catch
      _kind, _reason -> :ok
    end

    try do
      :telemetry.execute([:openagents, :scv, :event], %{count: 1}, event)
    rescue
      _error -> :ok
    end

    :ok
  end

  defp bounded_value(value, maximum_bytes, _fallback)
       when is_binary(value) and byte_size(value) >= 1 and byte_size(value) <= maximum_bytes,
       do: value

  defp bounded_value(_value, _maximum_bytes, fallback), do: fallback

  defp byte_count(value) when is_binary(value), do: byte_size(value)
  defp byte_count(_value), do: 0

  defp redact(binary, redactions) do
    Enum.reduce(redactions, binary, fn
      value, output when is_binary(value) and value != "" ->
        :binary.replace(output, value, "[REDACTED]", [:global])

      _value, output ->
        output
    end)
  end

  defp git_sha(repository) do
    case System.find_executable("git") do
      nil ->
        nil

      executable ->
        case System.cmd(executable, ["-C", repository, "rev-parse", "HEAD"],
               stderr_to_stdout: true
             ) do
          {sha, 0} -> String.trim(sha)
          _error -> nil
        end
    end
  end

  defp digest(bytes), do: "sha256:" <> Base.encode16(:crypto.hash(:sha256, bytes), case: :lower)
  defp monotonic_ms, do: System.monotonic_time(:millisecond)
end