test/openagents_web/controllers/box_controller_test.exs

58e6347eeb72 · 17 KB

defmodule OpenAgentsWeb.BoxControllerTest do
  use OpenAgentsWeb.ConnCase, async: false

  alias OpenAgents.{Agents, Conversations, Issues, Repo}
  alias OpenAgents.Box.ConversationBox
  alias OpenAgents.Issues.Comment

  setup {Req.Test, :verify_on_exit!}

  setup do
    Req.Test.set_req_test_to_shared()

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

    Application.put_env(
      :openagents,
      :box_api,
      Keyword.merge(original_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, "box_api_test_credential")
    Req.Test.stub(__MODULE__, fn request -> Req.Test.json(request, box_body()) end)

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

    :ok
  end

  test "human box control token completes the create, command, list, show, and stop cycle", %{
    conn: conn
  } do
    user = github_user("api-token-box-api-cycle")
    {:ok, conversation} = Conversations.ensure_conversation(user)

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

    create_response =
      conn
      |> put_box_api_token("box-api-cycle")
      |> post(box_path(conversation.id), %{})
      |> json_response(201)

    box_id = create_response["box"]["box_id"]
    assert create_response["box"]["state"] == "ready"
    refute Map.has_key?(create_response["box"], "desktopUrl")

    Req.Test.expect(__MODULE__, fn request ->
      assert request.method == "POST"
      assert request.request_path == "/boxes/#{box_id}/commands"
      Req.Test.json(request, command_body())
    end)

    command_response =
      conn
      |> put_box_api_token("box-api-cycle")
      |> post("#{box_path(conversation.id)}/#{box_id}/commands", %{"command" => "echo hi"})
      |> json_response(200)

    assert command_response["result"]["exit_code"] == 0
    assert command_response["result"]["stdout"] == "hi\n"

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

    assert %{"boxes" => [%{"box_id" => ^box_id}]} =
             conn
             |> put_box_api_token("box-api-cycle")
             |> get(box_path(conversation.id))
             |> json_response(200)

    assert %{"box" => %{"box_id" => ^box_id}} =
             conn
             |> put_box_api_token("box-api-cycle")
             |> get("#{box_path(conversation.id)}/#{box_id}")
             |> json_response(200)

    Req.Test.expect(__MODULE__, fn request ->
      assert request.method == "POST"
      assert request.request_path == "/boxes/#{box_id}/stop"
      Req.Test.json(request, box_body(%{"state" => "archiving"}))
    end)

    assert %{"box" => %{"box_id" => ^box_id, "state" => "archiving"}} =
             conn
             |> put_box_api_token("box-api-cycle")
             |> post("#{box_path(conversation.id)}/#{box_id}/stop", %{})
             |> json_response(200)
  end

  test "a token without box control is refused on every route", %{conn: conn} do
    user = github_user("api-token-box-api-auth")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    box_id = "bx_8bhkse3n"

    paths = [
      {:get, box_path(conversation.id), %{}},
      {:post, box_path(conversation.id), %{}},
      {:get, "#{box_path(conversation.id)}/#{box_id}", %{}},
      {:post, "#{box_path(conversation.id)}/#{box_id}/commands", %{"command" => "id"}},
      {:post, "#{box_path(conversation.id)}/#{box_id}/stop", %{}}
    ]

    for {method, path, params} <- paths do
      response =
        conn
        |> put_forge_api_token("box-api-auth")
        |> request(method, path, params)

      assert response.status == 401

      assert json_response(response, 401) == %{
               "error" => %{"code" => "invalid_api_token"}
             }
    end
  end

  test "an agent credential receives a typed refusal on every route", %{conn: conn} do
    {:ok, _agent, credential} =
      OpenAgents.Agents.register(%{
        "handle" => "box-agent-auth",
        "display_name" => "Box agent",
        "registration_ip" => "198.51.100.21"
      })

    {:ok, conversation} = Conversations.ensure_conversation(github_user("box-agent-owner"))
    authorization = "Bearer " <> credential

    for {method, path, params} <- [
          {:get, box_path(conversation.id), %{}},
          {:post, box_path(conversation.id), %{}},
          {:get, "#{box_path(conversation.id)}/bx_8bhkse3n", %{}},
          {:post, "#{box_path(conversation.id)}/bx_8bhkse3n/commands", %{"command" => "id"}},
          {:post, "#{box_path(conversation.id)}/bx_8bhkse3n/stop", %{}}
        ] do
      response =
        conn
        |> put_req_header("authorization", authorization)
        |> request(method, path, params)

      assert response.status == 403

      assert json_response(response, 403) == %{
               "error" => %{"code" => "agent_box_control_forbidden"}
             }
    end
  end

  test "a linked agent can list and stop a Box, then loses access when revoked", %{conn: conn} do
    owner = github_user("box-agent-control-owner")
    {:ok, conversation} = Conversations.ensure_conversation(owner)
    box = insert_box(conversation.id, "bx_8bhkse3n")

    {:ok, agent, credential} =
      Agents.register(%{
        "handle" => "box-agent-control",
        "display_name" => "Box control agent",
        "registration_ip" => "198.51.100.61"
      })

    {:ok, link} = Agents.request_link(agent, owner)
    {:ok, _linked} = Agents.accept_link(owner, link.id)
    assert {:ok, _grant} = Agents.grant_box_control(owner, agent)
    authorization = "Bearer " <> credential

    assert %{"boxes" => [%{"box_id" => "bx_8bhkse3n"}]} =
             conn
             |> put_req_header("authorization", authorization)
             |> get(box_path(conversation.id))
             |> json_response(200)

    Req.Test.expect(__MODULE__, fn request ->
      assert request.method == "POST"
      assert request.request_path == "/boxes/#{box.box_id}/stop"
      Req.Test.json(request, box_body(%{"state" => "archiving"}))
    end)

    assert %{"box" => %{"box_id" => "bx_8bhkse3n", "state" => "archiving"}} =
             conn
             |> put_req_header("authorization", authorization)
             |> post("#{box_path(conversation.id)}/#{box.box_id}/stop", %{})
             |> json_response(200)

    assert {:ok, _revoked} = Agents.revoke_box_control(owner, agent)

    assert conn
           |> put_req_header("authorization", authorization)
           |> get(box_path(conversation.id))
           |> json_response(403) == %{
             "error" => %{"code" => "agent_box_control_forbidden"}
           }
  end

  test "a linked agent assignment uses the granting human's conversation owner", %{conn: conn} do
    owner = repository_user_fixture("box-assignment-agent-owner")
    {:ok, conversation} = Conversations.ensure_conversation(owner)
    box = insert_box(conversation.id, "bx_8bhkse3n")
    repository = repository_with_member_fixture(owner)
    {:ok, issue} = Issues.create_issue(repository, %{title: "Agent assignment target"})

    {:ok, agent, credential} =
      Agents.register(%{
        "handle" => "box-assignment-agent",
        "display_name" => "Box assignment agent",
        "registration_ip" => "198.51.100.62"
      })

    {:ok, link} = Agents.request_link(agent, owner)
    {:ok, _linked} = Agents.accept_link(owner, link.id)
    assert {:ok, _grant} = Agents.grant_box_control(owner, agent)

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

    Req.Test.expect(__MODULE__, fn request ->
      assert request.method == "POST"
      assert request.request_path == "/boxes/#{box.box_id}/commands"
      Req.Test.json(request, %{"stdout" => "123\n"})
    end)

    Req.Test.stub(__MODULE__, fn request ->
      Req.Test.json(request, %{"stdout" => "OA_PRESENT=0\n"})
    end)

    response =
      conn
      |> put_req_header("authorization", "Bearer " <> credential)
      |> post(
        "/api/v1/conversations/#{conversation.id}/boxes/#{box.box_id}/assignments",
        %{
          "repository_id" => repository.id,
          "issue_number" => issue.number,
          "branch" => "agent/issue-#{issue.number}",
          "command" => "echo assigned"
        }
      )
      |> json_response(202)

    assignment_id = response["assignment"]["id"]
    assignment = Repo.get!(OpenAgents.Forge.Assignment, assignment_id)
    assert assignment.requesting_principal["type"] == "agent"
    assert assignment.requesting_principal["id"] == agent.id
    assert Agents.box_control_owner(agent).id == owner.id

    # The agent that asked is on the attempt, which is where it belongs. It
    # used to be read from the author of a narration comment; that comment is
    # retired (#147) and the issue carries none.
    assert is_nil(Repo.get_by(Comment, issue_id: issue.id))
  end

  test "a foreign box returns 404 without an outbound provider request", %{conn: conn} do
    owner = github_user("api-token-box-api-owner")
    {:ok, conversation} = Conversations.ensure_conversation(owner)

    {:ok, foreign_conversation} =
      Conversations.ensure_conversation(github_user("box-api-foreign"))

    insert_box(foreign_conversation.id, "bx_8bhkse3n")

    Req.Test.stub(__MODULE__, fn request ->
      send(self(), :unexpected_box_provider_request)
      Req.Test.json(request, box_body())
    end)

    assert conn
           |> put_box_api_token("box-api-owner")
           |> post("#{box_path(conversation.id)}/bx_8bhkse3n/commands", %{"command" => "id"})
           |> json_response(404) == %{"error" => %{"code" => "box_not_found"}}

    refute_receive :unexpected_box_provider_request
  end

  test "provider failures use typed status and error mappings", %{conn: conn} do
    user_key = "box-api-provider-errors"
    user = github_user("api-token-" <> user_key)
    {:ok, conversation} = Conversations.ensure_conversation(user)

    for {status, error_code, response_status} <- [
          {402, "box_billing_required", 402},
          {429, "box_provider_rate_limited", 429},
          {409, "box_provider_request_refused", 502}
        ] do
      Req.Test.expect(__MODULE__, fn request ->
        request |> Plug.Conn.put_status(status) |> Req.Test.json(%{"code" => "provider detail"})
      end)

      response =
        conn
        |> put_box_api_token(user_key)
        |> post(box_path(conversation.id), %{})

      assert json_response(response, response_status) == %{"error" => %{"code" => error_code}}
      refute response.resp_body =~ "provider detail"
    end

    Req.Test.expect(__MODULE__, fn request -> Req.Test.transport_error(request, :econnrefused) end)

    assert conn
           |> put_box_api_token(user_key)
           |> post(box_path(conversation.id), %{})
           |> json_response(503) == %{"error" => %{"code" => "box_unreachable"}}

    Req.Test.expect(__MODULE__, fn request -> Req.Test.json(request, []) end)

    assert conn
           |> put_box_api_token(user_key)
           |> post(box_path(conversation.id), %{})
           |> json_response(502) == %{
             "error" => %{"code" => "box_provider_response_invalid"}
           }

    Application.delete_env(:openagents, :box_api_key)

    assert conn
           |> put_box_api_token(user_key)
           |> post(box_path(conversation.id), %{})
           |> json_response(503) == %{"error" => %{"code" => "box_not_configured"}}
  end

  test "quota, redaction, bounds, timeout, and provider URLs stay safe", %{conn: conn} do
    user_key = "box-api-safe-output"
    user = github_user("api-token-" <> user_key)
    {:ok, conversation} = Conversations.ensure_conversation(user)

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

    assert conn
           |> put_box_api_token(user_key)
           |> post(box_path(conversation.id), %{})
           |> json_response(409) == %{"error" => %{"code" => "box_quota_reached"}}

    insert_box(conversation.id, "bx_8bhkse3n")
    large_output = String.duplicate("x", 30_000)

    Req.Test.expect(__MODULE__, fn request ->
      Req.Test.json(request, %{
        "exitCode" => 124,
        "stdout" => "sk-or-v1-abcdefghijklmnop " <> large_output,
        "stderr" =>
          "clone https://openagents.com/OpenAgentsInc/openagents.com\n" <>
            "https://viewer.ascii.dev/desktop?access_token=secret",
        "timedOut" => true,
        "desktopUrl" => "https://viewer.ascii.dev/desktop?token=secret"
      })
    end)

    response =
      conn
      |> put_box_api_token(user_key)
      |> post("#{box_path(conversation.id)}/bx_8bhkse3n/commands", %{"command" => "id"})
      |> json_response(200)

    result = response["result"]
    assert result["exit_code"] == 124
    assert result["timed_out"]
    assert result["stdout_truncated"]

    assert result["stderr"] ==
             "clone https://openagents.com/OpenAgentsInc/openagents.com\n[REDACTED_URL]"

    refute response["result"]["stdout"] =~ "sk-or-v1"
    refute inspect(response) =~ "desktopUrl"
    refute inspect(response) =~ "viewer.ascii.dev"
    refute inspect(response) =~ "access_token"
  end

  test "a foreign conversation returns 404 without an outbound provider request", %{
    conn: conn
  } do
    {:ok, _owner_conversation} =
      Conversations.ensure_conversation(github_user("api-token-box-api-isolation"))

    {:ok, foreign_conversation} =
      Conversations.ensure_conversation(github_user("box-api-foreign-conversation"))

    insert_box(foreign_conversation.id, "bx_8bhkse3n")

    Req.Test.stub(__MODULE__, fn request ->
      send(self(), :unexpected_box_provider_request)
      Req.Test.json(request, box_body())
    end)

    paths = [
      {:get, box_path(foreign_conversation.id), %{}},
      {:post, box_path(foreign_conversation.id), %{}},
      {:get, "#{box_path(foreign_conversation.id)}/bx_8bhkse3n", %{}},
      {:post, "#{box_path(foreign_conversation.id)}/bx_8bhkse3n/commands",
       %{
         "command" => "id"
       }},
      {:post, "#{box_path(foreign_conversation.id)}/bx_8bhkse3n/stop", %{}}
    ]

    for {method, path, params} <- paths do
      response =
        conn
        |> put_box_api_token("box-api-isolation")
        |> request(method, path, params)

      assert response.status == 404

      assert json_response(response, 404) == %{
               "error" => %{"code" => "conversation_not_found"}
             }
    end

    refute_receive :unexpected_box_provider_request
  end

  test "creates and commands are rate limited per human principal", %{conn: conn} do
    original_api = Application.get_env(:openagents, :box_api)

    Application.put_env(
      :openagents,
      :box_api,
      Keyword.merge(original_api || [], create_rate_limit: 1, command_rate_limit: 1)
    )

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

    user_key = "box-api-rate-limit"
    user = github_user("api-token-" <> user_key)
    {:ok, conversation} = Conversations.ensure_conversation(user)

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

    assert conn
           |> put_box_api_token(user_key)
           |> post(box_path(conversation.id), %{})
           |> json_response(201)

    assert conn
           |> put_box_api_token(user_key)
           |> post(box_path(conversation.id), %{})
           |> json_response(429) == %{"error" => %{"code" => "box_api_rate_limited"}}

    Req.Test.expect(__MODULE__, fn request -> Req.Test.json(request, command_body()) end)

    command_path = "#{box_path(conversation.id)}/bx_8bhkse3n/commands"

    assert conn
           |> put_box_api_token(user_key)
           |> post(command_path, %{"command" => "id"})
           |> json_response(200)

    assert conn
           |> put_box_api_token(user_key)
           |> post(command_path, %{"command" => "id"})
           |> json_response(429) == %{"error" => %{"code" => "box_api_rate_limited"}}
  end

  test "rate-limit buckets are shared across request processes" do
    original_api = Application.get_env(:openagents, :box_api)

    Application.put_env(
      :openagents,
      :box_api,
      Keyword.merge(original_api || [], create_rate_limit: 1)
    )

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

    first = Task.async(fn -> OpenAgentsWeb.BoxRateLimiter.allow?("shared-principal", :create) end)
    assert Task.await(first) == :ok

    second =
      Task.async(fn -> OpenAgentsWeb.BoxRateLimiter.allow?("shared-principal", :create) end)

    assert Task.await(second) == {:error, :rate_limited}
  end

  defp box_path(conversation_id), do: "/api/v1/conversations/#{conversation_id}/boxes"

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

  defp command_body do
    %{
      "exitCode" => 0,
      "stdout" => "hi\n",
      "stderr" => "",
      "timedOut" => false
    }
  end

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

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

  defp request(conn, :get, path, _params), do: get(conn, path)
  defp request(conn, :post, path, params), do: post(conn, path, params)
end