test/openagents/scv/open_code_executor_test.exs

main at 58e6347eeb72 · 8 KB

defmodule OpenAgents.SCV.OpenCodeExecutorTest do
  use ExUnit.Case, async: false

  alias OpenAgents.SCV.Executor.OpenCode

  setup do
    root = Path.join(System.tmp_dir!(), "scv-opencode-#{System.unique_integer([:positive])}")
    repository = Path.join(root, "repository")
    output = Path.join(root, "runs")
    executable = Path.join(root, "fake-opencode")
    File.mkdir_p!(repository)
    File.write!(Path.join(repository, "README.md"), "fixture repository")
    File.write!(executable, fake_executable())
    File.chmod!(executable, 0o700)

    previous_probe = System.get_env("SCV_PRIVATE_PROBE")
    System.put_env("SCV_PRIVATE_PROBE", "must-not-leak")

    on_exit(fn ->
      restore_env("SCV_PRIVATE_PROBE", previous_probe)
      File.rm_rf(root)
    end)

    %{executable: executable, output: output, repository: repository}
  end

  test "runs OpenCode with isolated state and writes redacted evidence", context do
    test_pid = self()

    sample_fun = fn os_pid ->
      assert is_integer(os_pid) and os_pid > 0
      {:ok, %{rss_bytes: 12_345, cpu_percent: 4.5}}
    end

    assert {:ok, result} =
             OpenCode.run(
               context.repository,
               "READ_ONLY",
               shared_options(context) ++
                 [
                   sample_interval_ms: 10,
                   sample_fun: sample_fun,
                   event_sink: fn event -> send(test_pid, {:scv_event, event}) end
                 ]
             )

    assert result.status == "succeeded"
    assert result.exit_status == 0
    assert result.runtime.permission_profile == "read_only"
    assert result.runtime.model == "openai/test-model"
    assert result.runtime.reasoning_effort == "low"
    assert result.events.event_count == 4
    assert result.events.diagnostic_line_count == 0
    assert result.events.invalid_event_count == 0
    assert result.events.session_ids == ["ses_fixture"]
    assert result.events.tool_calls == %{"read" => 1}
    assert result.events.tool_outcomes == %{"read:completed" => 1}
    assert result.events.text_event_count == 1

    assert result.report == %{
             schema: "openagents.scv.report.v1",
             text: "done [REDACTED]",
             bytes: 15,
             truncated: false
           }

    assert result.events.usage == %{
             input_tokens: 13,
             output_tokens: 8,
             reasoning_tokens: 2,
             cache_read_tokens: 3,
             cache_write_tokens: 1,
             cost_usd: 0.00125
           }

    assert result.resources.sample_count > 0
    assert result.resources.peak_rss_bytes == 12_345
    assert result.resources.maximum_cpu_percent == 4.5
    assert File.exists?(result.artifacts.events_path)
    assert File.exists?(result.summary_path)
    refute File.read!(result.artifacts.events_path) =~ "fixture-secret-key"
    assert File.read!(result.artifacts.events_path) =~ "[REDACTED]"
    refute File.read!(result.summary_path) =~ "READ_ONLY"
    refute File.read!(result.summary_path) =~ "fixture-secret-key"
    refute File.exists?(Path.join([Path.dirname(result.summary_path), "scratch"]))

    assert {:ok, %{mode: event_mode}} = File.stat(result.artifacts.events_path)
    assert {:ok, %{mode: summary_mode}} = File.stat(result.summary_path)
    assert Bitwise.band(event_mode, 0o777) == 0o600
    assert Bitwise.band(summary_mode, 0o777) == 0o600

    assert_receive {:scv_event, %{type: "run_preparing"}}, 5_000
    assert_receive {:scv_event, %{type: "process_starting"}}, 5_000
    assert_receive {:scv_event, %{type: "process_started", os_pid: os_pid}}, 5_000
    assert is_integer(os_pid) and os_pid > 0

    for event_type <- ["step_start", "tool_use", "step_finish", "text"] do
      assert_receive {:scv_event,
                      %{
                        type: "opencode_event",
                        event_type: ^event_type,
                        session_id: "ses_fixture"
                      }},
                     5_000
    end

    assert_receive {:scv_event, %{type: "process_finished", status: "succeeded"}}, 5_000
    assert_receive {:scv_event, %{type: "run_finished", status: "succeeded"}}, 5_000
  end

  test "fails closed at the wall-clock limit", context do
    assert {:ok, result} =
             OpenCode.run(
               context.repository,
               "TIMEOUT",
               context
               |> shared_options()
               |> Keyword.put(:timeout_ms, 500)
               |> Keyword.put(:sample_interval_ms, 10)
             )

    assert result.status == "timeout"
    assert result.error_code == "command_timeout"
    assert result.exit_status == nil
    assert result.duration_ms < 5_000

    timeout_pid = context.repository |> Path.join("timeout.pid") |> File.read!() |> String.trim()
    {_output, status} = System.cmd("kill", ["-0", timeout_pid], stderr_to_stdout: true)
    assert status != 0
  end

  test "stops capture at the output bound", context do
    assert {:ok, result} =
             OpenCode.run(
               context.repository,
               "OUTPUT_LIMIT",
               shared_options(context) ++ [maximum_output_bytes: 64]
             )

    assert result.status == "output_limit"
    assert result.error_code == "output_limit"
    assert result.resources.captured_output_bytes == 64
    assert result.resources.output_truncated
    assert File.stat!(result.artifacts.events_path).size <= 64
  end

  test "validates authority and execution bounds before creating a run", context do
    assert {:error, :repository_not_absolute} =
             OpenCode.run("relative", "prompt", shared_options(context))

    assert {:error, :permissions_invalid} =
             OpenCode.run(
               context.repository,
               "prompt",
               shared_options(context) ++ [permissions: :unbounded]
             )

    assert {:error, :openai_api_key_missing} =
             OpenCode.run(
               context.repository,
               "prompt",
               Keyword.put(shared_options(context), :api_key, nil)
             )

    # SCV-001: an OpenAI key is a per-provider credential, so a model served by
    # another provider is not blocked on one it never reads. This gets past the
    # credential check and fails on the missing binary instead.
    assert {:error, :enoent} =
             OpenCode.run(
               context.repository,
               "prompt",
               shared_options(context)
               |> Keyword.put(:api_key, nil)
               |> Keyword.put(:model, "opencode/x-preview-f-free")
               |> Keyword.put(:executable, Path.join(context.output, "absent-binary"))
             )

    assert {:error, :opencode_api_key_invalid} =
             OpenCode.run(
               context.repository,
               "prompt",
               Keyword.put(shared_options(context), :opencode_api_key, "short")
             )

    assert {:error, :reasoning_effort_invalid} =
             OpenCode.run(
               context.repository,
               "prompt",
               Keyword.put(shared_options(context), :reasoning_effort, "medium")
             )
  end

  defp shared_options(context) do
    [
      api_key: "fixture-secret-key",
      executable: context.executable,
      model: "openai/test-model",
      output_root: context.output,
      timeout_ms: 5_000
    ]
  end

  defp fake_executable do
    """
    #!/bin/sh
    if [ "${OPENAI_API_KEY:-}" != "fixture-secret-key" ]; then exit 21; fi
    if [ "${OPENCODE_DISABLE_PROJECT_CONFIG:-}" != "1" ]; then exit 22; fi
    if [ "${OPENCODE_DISABLE_LSP_DOWNLOAD:-}" != "1" ]; then exit 23; fi
    if [ "${OPENCODE_PURE:-}" != "1" ]; then exit 24; fi
    if [ "${OPENCODE_DISABLE_SHARE:-}" != "1" ]; then exit 29; fi
    if [ "${SCV_PRIVATE_PROBE+x}" = "x" ]; then exit 25; fi
    prompt=$(cat)
    if [ -z "$prompt" ]; then exit 30; fi
    if [ "$1" != "run" ]; then exit 26; fi
    case " $* " in *" --variant low "*) :;; *) exit 32;; esac
    case " $* " in *" --auto "*) exit 27;; esac
    case " $* " in *" $prompt "*) exit 31;; esac
    if [ "${OPENCODE_CONFIG_DIR:-}" != "${XDG_CONFIG_HOME:-}/opencode" ]; then exit 28; fi

    case "$prompt" in
      *TIMEOUT*) echo $$ > "$PWD/timeout.pid"; while :; do :; done;;
      *OUTPUT_LIMIT*) printf '%0200d' 0; exit 0;;
    esac

    printf '%s\n' '{"type":"step_start","timestamp":1,"sessionID":"ses_fixture","part":{"type":"step-start"}}'
    printf '%s\n' '{"type":"tool_use","timestamp":2,"sessionID":"ses_fixture","part":{"tool":"read","state":{"status":"completed","output":"fixture-secret-key"}}}'
    printf '%s\n' '{"type":"step_finish","timestamp":3,"sessionID":"ses_fixture","part":{"type":"step-finish","cost":0.00125,"tokens":{"input":13,"output":8,"reasoning":2,"cache":{"read":3,"write":1}}}}'
    printf '%s\n' '{"type":"text","timestamp":4,"sessionID":"ses_fixture","part":{"type":"text","text":"done fixture-secret-key"}}'
    """
  end

  defp restore_env(key, nil), do: System.delete_env(key)
  defp restore_env(key, value), do: System.put_env(key, value)
end