test/openagents/box_test.exs

58e6347eeb72 · 12 KB

defmodule OpenAgents.BoxTest do
  use OpenAgents.DataCase

  alias OpenAgents.Box
  alias OpenAgents.Box.Client
  alias OpenAgents.Box.ConversationBox
  alias OpenAgents.Conversations
  alias OpenAgents.Repo

  @api_key "box_test_0000000000000000000000000000000000000000000000000000000000000"
  @box_id "bx_8bhkse3n"

  setup {Req.Test, :verify_on_exit!}

  setup do
    original_api = Application.get_env(:openagents, :box_api)
    original_key = Application.get_env(:openagents, :box_api_key)

    Application.put_env(:openagents, :box_api,
      base_url: "https://box-api.internal",
      poll_interval_ms: 0,
      poll_attempts: 3,
      request_options: [plug: {Req.Test, __MODULE__}, retry_delay: 0]
    )

    Application.put_env(:openagents, :box_api_key, @api_key)

    on_exit(fn ->
      restore_env(:box_api, original_api)
      restore_env(:box_api_key, original_key)
    end)

    {:ok, conversation} = Conversations.ensure_conversation("box-pool-test")
    %{conversation_id: conversation.id}
  end

  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)

  # The setup script is private, and the provider payload is where it becomes
  # observable, so read it back the way the provider does.
  defp captured_setup_script(conversation_id) do
    owner = self()

    Req.Test.stub(__MODULE__, fn conn ->
      {:ok, raw, conn} = Plug.Conn.read_body(conn)

      case Jason.decode(raw) do
        {:ok, %{"setupScript" => script}} -> send(owner, {:setup_script, script})
        _other -> :ok
      end

      Req.Test.json(conn, box_body())
    end)

    assert {:ok, _record} = Box.create_box(conversation_id)
    assert_received {:setup_script, script}
    script
  end

  defp index_of(haystack, needle) do
    assert [index | _rest] = :binary.match(haystack, needle) |> Tuple.to_list()
    index
  end

  defp box_body(overrides \\ %{}) do
    %{
      "box" =>
        Map.merge(
          %{
            "id" => @box_id,
            "state" => "ready",
            "setupStatus" => "done",
            "name" => Box.provider_ownership_marker()
          },
          overrides
        )
    }
  end

  describe "create_box/1" do
    test "provisions, records ownership, and polls to runnable", %{conversation_id: cid} do
      Req.Test.expect(__MODULE__, fn conn ->
        assert conn.method == "POST"
        assert conn.request_path == "/boxes"
        assert ["Bearer " <> _key] = Plug.Conn.get_req_header(conn, "authorization")
        assert [idempotency_key] = Plug.Conn.get_req_header(conn, "idempotency-key")
        assert byte_size(idempotency_key) > 0

        {:ok, raw, conn} = Plug.Conn.read_body(conn)
        payload = Jason.decode!(raw)
        assert payload["noEnv"] == true
        assert payload["setupScript"] =~ "openrouter/z-ai/glm-5.3-flash"

        Req.Test.json(conn, box_body(%{"state" => "provisioning", "setupStatus" => "pending"}))
      end)

      Req.Test.expect(__MODULE__, fn conn ->
        assert conn.method == "PATCH"
        assert conn.request_path == "/boxes/#{@box_id}"
        {:ok, raw, conn} = Plug.Conn.read_body(conn)
        assert Jason.decode!(raw)["name"] == Box.provider_ownership_marker()
        Req.Test.json(conn, box_body(%{"state" => "provisioning", "setupStatus" => "pending"}))
      end)

      Req.Test.expect(__MODULE__, fn conn ->
        assert conn.method == "GET"
        assert conn.request_path == "/boxes/#{@box_id}"
        Req.Test.json(conn, box_body())
      end)

      assert {:ok, record} = Box.create_box(cid)
      assert record.box_id == @box_id
      assert record.conversation_id == cid
      assert record.state == "ready"
      assert record.setup_status == "done"
      assert record.stopped_at == nil
    end

    test "writes the OpenCode configuration before installing", %{conversation_id: cid} do
      script = captured_setup_script(cid)

      configuration_at = index_of(script, "opencode.json")
      install_at = index_of(script, "releases/download")

      assert configuration_at < install_at,
             "the configuration write must precede the install so a failed fetch cannot cost both"
    end

    test "installs a pinned release without the unauthenticated GitHub API", %{
      conversation_id: cid
    } do
      script = captured_setup_script(cid)

      assert script =~
               ~r{https://github\.com/anomalyco/opencode/releases/download/v\d+\.\d+\.\d+/}

      refute script =~ "api.github.com"
      refute script =~ "opencode.ai/install"
      refute script =~ "releases/latest"
    end

    test "links the binary onto the PATH a non-interactive run gets", %{conversation_id: cid} do
      script = captured_setup_script(cid)

      assert script =~ ~s(ln -sf "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode")
    end

    test "retries a transient fetch a bounded number of times", %{conversation_id: cid} do
      script = captured_setup_script(cid)

      assert script =~ "until curl"
      assert script =~ ~r/if \[ "\$opencode_attempt" -ge \d+ \]/
      assert script =~ "exit 1"
    end

    test "injects the OpenRouter key through the box environment only", %{conversation_id: cid} do
      original = Application.get_env(:openagents, :openrouter_api_key)
      Application.put_env(:openagents, :openrouter_api_key, "sk-or-v1-test0000000000000000")
      on_exit(fn -> restore_env(:openrouter_api_key, original) end)

      Req.Test.expect(__MODULE__, fn conn ->
        {:ok, raw, conn} = Plug.Conn.read_body(conn)
        payload = Jason.decode!(raw)
        assert payload["env"] == %{"OPENROUTER_API_KEY" => "sk-or-v1-test0000000000000000"}
        refute payload["setupScript"] =~ "sk-or-v1"
        Req.Test.json(conn, box_body())
      end)

      Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, box_body()) end)

      assert {:ok, record} = Box.create_box(cid)
      assert record.setup_status == "done"
    end

    test "surfaces a failed setup honestly", %{conversation_id: cid} do
      Req.Test.expect(__MODULE__, fn conn ->
        Req.Test.json(conn, box_body(%{"setupStatus" => "pending"}))
      end)

      Req.Test.expect(__MODULE__, fn conn ->
        assert conn.method == "PATCH"
        Req.Test.json(conn, box_body(%{"setupStatus" => "pending"}))
      end)

      Req.Test.expect(__MODULE__, fn conn ->
        Req.Test.json(conn, box_body(%{"setupStatus" => "failed"}))
      end)

      assert {:ok, record} = Box.create_box(cid)
      assert record.setup_status == "failed"
    end

    test "refuses past the per-conversation cap without a remote call", %{conversation_id: cid} do
      for index <- 1..Box.maximum_active_boxes() do
        insert_box(cid, "bx_aaaaaaa#{Enum.at(~w(2 3 4 5 6 7 8 9 a b), index - 1)}")
      end

      assert {:error, :box_quota_reached} = Box.create_box(cid)
    end

    test "a stopped box frees its quota slot", %{conversation_id: cid} do
      for index <- 1..Box.maximum_active_boxes() do
        insert_box(cid, "bx_aaaaaaa#{Enum.at(~w(2 3 4 5 6 7 8 9 a b), index - 1)}")
      end

      Req.Test.expect(__MODULE__, fn conn ->
        assert conn.request_path == "/boxes/bx_aaaaaaa2/stop"
        Req.Test.json(conn, box_body(%{"id" => "bx_aaaaaaa2", "state" => "archiving"}))
      end)

      assert {:ok, stopped} = Box.stop_box(cid, "bx_aaaaaaa2")
      assert %DateTime{} = stopped.stopped_at
      assert stopped.state == "archiving"

      Req.Test.expect(__MODULE__, fn conn ->
        assert conn.request_path == "/boxes"
        Req.Test.json(conn, box_body())
      end)

      Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, box_body()) end)

      assert {:ok, _record} = Box.create_box(cid)
    end

    test "another conversation's boxes do not count against this quota", %{conversation_id: cid} do
      {:ok, other} = Conversations.ensure_conversation("box-pool-other")

      for index <- 1..Box.maximum_active_boxes() do
        insert_box(other.id, "bx_aaaaaaa#{Enum.at(~w(2 3 4 5 6 7 8 9 a b), index - 1)}")
      end

      Req.Test.expect(__MODULE__, fn conn ->
        Req.Test.json(conn, box_body())
      end)

      Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, box_body()) end)

      assert {:ok, _record} = Box.create_box(cid)
    end

    test "a provider refusal rolls back and stores nothing", %{conversation_id: cid} do
      Req.Test.expect(__MODULE__, fn conn ->
        conn
        |> Plug.Conn.put_status(429)
        |> Req.Test.json(%{"code" => "rate_limited"})
      end)

      assert {:error, :box_rate_limited} = Box.create_box(cid)
      assert Repo.aggregate(ConversationBox, :count) == 0
    end
  end

  describe "run_command/4" do
    test "runs a command on an owned box", %{conversation_id: cid} do
      insert_box(cid, @box_id)

      Req.Test.expect(__MODULE__, fn conn ->
        assert conn.method == "POST"
        assert conn.request_path == "/boxes/#{@box_id}/commands"
        {:ok, raw, conn} = Plug.Conn.read_body(conn)
        assert %{"command" => "echo hi", "timeoutSeconds" => 60} = Jason.decode!(raw)

        Req.Test.json(conn, %{
          "exitCode" => 0,
          "stdout" => "hi\n",
          "stderr" => "",
          "success" => true,
          "timedOut" => false
        })
      end)

      assert {:ok, body} = Box.run_command(cid, @box_id, "echo hi", 60)
      assert body["exitCode"] == 0
    end

    test "refuses a box owned by another conversation", %{conversation_id: cid} do
      {:ok, other} = Conversations.ensure_conversation("box-owner-other")
      insert_box(other.id, @box_id)

      assert {:error, :box_not_owned} = Box.run_command(cid, @box_id, "id", 60)
    end

    test "refuses a stopped box", %{conversation_id: cid} do
      insert_box(cid, @box_id, stopped_at: DateTime.utc_now())

      assert {:error, :box_stopped} = Box.run_command(cid, @box_id, "id", 60)
      assert {:error, :box_stopped} = Box.stop_box(cid, @box_id)
    end
  end

  describe "client" do
    test "fails closed without a configured credential", %{conversation_id: cid} do
      Application.delete_env(:openagents, :box_api_key)

      assert {:error, :box_not_configured} = Box.create_box(cid)
      assert {:error, :box_not_configured} = Client.get_box(@box_id)
    end

    test "maps provider statuses to typed errors" do
      for {status, expected} <- [
            {401, :box_unauthorized},
            {402, :box_billing_required},
            {404, :box_not_found},
            {429, :box_rate_limited}
          ] do
        Req.Test.expect(__MODULE__, fn conn ->
          conn |> Plug.Conn.put_status(status) |> Req.Test.json(%{})
        end)

        assert Client.stop_box(@box_id) == {:error, expected}
      end

      Req.Test.expect(__MODULE__, fn conn ->
        conn |> Plug.Conn.put_status(409) |> Req.Test.json(%{"code" => "provider_not_configured"})
      end)

      assert Client.stop_box(@box_id) ==
               {:error, {:box_request_refused, 409, "provider_not_configured"}}
    end

    test "a transport failure is unreachable, not a crash" do
      Req.Test.expect(__MODULE__, fn conn ->
        Req.Test.transport_error(conn, :econnrefused)
      end)

      assert Client.command(@box_id, %{"command" => "id"}) == {:error, :box_unreachable}
    end

    test "retries a transient read failure" do
      Req.Test.expect(__MODULE__, fn conn ->
        conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{})
      end)

      Req.Test.expect(__MODULE__, fn conn ->
        Req.Test.json(conn, box_body())
      end)

      assert {:ok, %{"box" => %{"id" => @box_id}}} = Client.get_box(@box_id)
    end

    test "rejects a malformed box id before any request leaves the host" do
      for bad <- ["", "bx_", "bx_UPPERCASE", "../boxes", "bx_8bhkse3n/desktop", "bx_11111111"] do
        refute Client.valid_box_id?(bad)
        assert Client.get_box(bad) == {:error, :box_not_found}
      end

      assert Client.valid_box_id?(@box_id)
    end
  end

  defp insert_box(conversation_id, box_id, attributes \\ []) do
    %ConversationBox{}
    |> ConversationBox.changeset(
      Enum.into(attributes, %{
        conversation_id: conversation_id,
        box_id: box_id,
        state: "ready",
        setup_status: "done"
      })
    )
    |> Repo.insert!()
  end
end