test/openagents/tools/box_tools_test.exs

58e6347eeb72 · 10 KB

defmodule OpenAgents.Tools.BoxToolsTest do
  use OpenAgents.DataCase

  alias OpenAgents.Box.ConversationBox
  alias OpenAgents.Conversations
  alias OpenAgents.Repo
  alias OpenAgents.Tools.{ConversationExecutionContext, Registry, Runner}

  @tools [
    OpenAgents.Tools.BoxNew,
    OpenAgents.Tools.BoxList,
    OpenAgents.Tools.BoxExec,
    OpenAgents.Tools.BoxStop
  ]

  @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, "box_test_credential_value")

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

    assert {:ok, snapshot} = Registry.build(@tools)

    {:ok, conversation} = Conversations.ensure_conversation("box-tools-test")
    owner = Conversations.get_conversation_owner!(conversation)

    context =
      ConversationExecutionContext.build(%{
        surface: "text",
        conversation_id: conversation.id,
        owner_visitor_id: owner.id,
        owner_user_id: owner.user_id,
        module_registry_snapshot: snapshot
      })

    %{snapshot: snapshot, context: context, 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)

  defp call(name, arguments) do
    %{
      call_id: "call-#{System.unique_integer([:positive])}",
      name: name,
      version: 1,
      raw_arguments: Jason.encode!(arguments)
    }
  end

  defp box_body(overrides \\ %{}) do
    %{
      "box" =>
        Map.merge(
          %{
            "id" => @box_id,
            "state" => "ready",
            "setupStatus" => "done",
            "name" => OpenAgents.Box.provider_ownership_marker()
          },
          overrides
        )
    }
  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

  test "box_new provisions a box and returns safe metadata", %{
    snapshot: snapshot,
    context: context
  } do
    Req.Test.expect(__MODULE__, fn conn ->
      assert conn.method == "POST"
      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, outcome} = Runner.run(snapshot, call("box_new", %{}), context)
    assert outcome["status"] == "succeeded"
    assert outcome["result"]["box_id"] == @box_id
    assert outcome["result"]["state"] == "ready"
    assert outcome["result"]["setup_status"] == "done"
    assert outcome["target_receipt_refs"] == ["box:#{@box_id}"]
    refute inspect(outcome) =~ "box_test_credential_value"
  end

  test "box_new reports a failed OpenCode setup as a failure", %{
    snapshot: snapshot,
    context: context
  } 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"
      assert conn.request_path == "/boxes/#{@box_id}"
      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, outcome} = Runner.run(snapshot, call("box_new", %{}), context)
    assert outcome["status"] == "failed"
    assert outcome["error"]["code"] == "box_setup_failed"
    assert outcome["result"]["box_id"] == @box_id
  end

  test "box_new refuses past the quota with a typed error", %{
    snapshot: snapshot,
    context: context,
    conversation_id: cid
  } do
    for index <- 1..OpenAgents.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 {:ok, outcome} = Runner.run(snapshot, call("box_new", %{}), context)
    assert outcome["status"] == "refused"
    assert outcome["error"]["code"] == "box_quota_reached"
  end

  test "box_new fails closed without a Box credential", %{
    snapshot: snapshot,
    context: context
  } do
    Application.delete_env(:openagents, :box_api_key)

    assert {:ok, outcome} = Runner.run(snapshot, call("box_new", %{}), context)
    assert outcome["status"] == "failed"
    assert outcome["error"]["code"] == "box_not_configured"
    refute inspect(outcome) =~ "box_test_credential_value"
  end

  test "box_list returns only this conversation's boxes", %{
    snapshot: snapshot,
    context: context,
    conversation_id: cid
  } do
    {:ok, other} = Conversations.ensure_conversation("box-tools-other")
    insert_box(other.id, "bx_aaaaaaa2")
    insert_box(cid, @box_id, stopped_at: DateTime.utc_now(), state: "archived")

    assert {:ok, outcome} = Runner.run(snapshot, call("box_list", %{}), context)
    assert outcome["status"] == "succeeded"
    assert [box] = outcome["result"]["boxes"]
    assert box["box_id"] == @box_id
    assert box["state"] == "archived"
    assert is_binary(box["stopped_at"])
  end

  test "box_exec runs a command and reports the exit status", %{
    snapshot: snapshot,
    context: context,
    conversation_id: cid
  } do
    insert_box(cid, @box_id)

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

      Req.Test.json(conn, %{
        "exitCode" => 0,
        "stdout" => "clone https://openagents.com/OpenAgentsInc/openagents.com\n",
        "stderr" => "",
        "timedOut" => false
      })
    end)

    assert {:ok, outcome} =
             Runner.run(
               snapshot,
               call("box_exec", %{"command" => "opencode --version", "box_id" => @box_id}),
               context
             )

    assert outcome["status"] == "succeeded"
    assert outcome["result"]["exit_code"] == 0

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

    assert outcome["target_receipt_refs"] == ["box:#{@box_id}"]
  end

  test "box_exec redacts credential-shaped output", %{
    snapshot: snapshot,
    context: context,
    conversation_id: cid
  } do
    insert_box(cid, @box_id)

    Req.Test.expect(__MODULE__, fn conn ->
      Req.Test.json(conn, %{
        "exitCode" => 0,
        "stdout" =>
          "key is sk-or-v1-abcdefghijklmnop1234 done\n" <>
            "clone https://openagents.com/OpenAgentsInc/openagents.com\n" <>
            "https://viewer.ascii.dev/desktop?access_token=secret\n",
        "stderr" => "",
        "timedOut" => false
      })
    end)

    assert {:ok, outcome} =
             Runner.run(
               snapshot,
               call("box_exec", %{"command" => "env", "box_id" => @box_id}),
               context
             )

    refute outcome["result"]["stdout"] =~ "sk-or-v1"
    refute outcome["result"]["stdout"] =~ "viewer.ascii.dev"
    assert outcome["result"]["stdout"] =~ "[REDACTED]"

    assert outcome["result"]["stdout"] =~
             "clone https://openagents.com/OpenAgentsInc/openagents.com"
  end

  test "box_exec reports a timed-out command as failed", %{
    snapshot: snapshot,
    context: context,
    conversation_id: cid
  } do
    insert_box(cid, @box_id)

    Req.Test.expect(__MODULE__, fn conn ->
      Req.Test.json(conn, %{
        "exitCode" => nil,
        "stdout" => "",
        "stderr" => "",
        "timedOut" => true
      })
    end)

    assert {:ok, outcome} =
             Runner.run(
               snapshot,
               call("box_exec", %{
                 "command" => "sleep 999",
                 "box_id" => @box_id,
                 "timeout_seconds" => 1
               }),
               context
             )

    assert outcome["status"] == "failed"
    assert outcome["error"]["code"] == "command_timed_out"
    assert outcome["result"]["timed_out"] == true
  end

  test "box_exec refuses a box this conversation does not own", %{
    snapshot: snapshot,
    context: context
  } do
    {:ok, other} = Conversations.ensure_conversation("box-tools-foreign")
    insert_box(other.id, @box_id)

    assert {:ok, outcome} =
             Runner.run(
               snapshot,
               call("box_exec", %{"command" => "id", "box_id" => @box_id}),
               context
             )

    assert outcome["status"] == "refused"
    assert outcome["error"]["code"] == "box_not_owned"
  end

  test "box_exec rejects an out-of-range timeout", %{
    snapshot: snapshot,
    context: context,
    conversation_id: cid
  } do
    insert_box(cid, @box_id)

    assert {:ok, outcome} =
             Runner.run(
               snapshot,
               call("box_exec", %{
                 "command" => "id",
                 "box_id" => @box_id,
                 "timeout_seconds" => 601
               }),
               context
             )

    assert outcome["status"] in ["failed", "refused"]
    refute outcome["error"] == nil
  end

  test "box_stop archives the box and frees the slot", %{
    snapshot: snapshot,
    context: context,
    conversation_id: cid
  } do
    insert_box(cid, @box_id)

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

    assert {:ok, outcome} =
             Runner.run(snapshot, call("box_stop", %{"box_id" => @box_id}), context)

    assert outcome["status"] == "succeeded"
    assert outcome["result"]["state"] == "archiving"
    assert is_binary(outcome["result"]["stopped_at"])

    assert {:ok, refused} =
             Runner.run(snapshot, call("box_stop", %{"box_id" => @box_id}), context)

    assert refused["status"] == "refused"
    assert refused["error"]["code"] == "box_stopped"
  end

  test "box tools require the box.control authority", %{snapshot: snapshot, context: context} do
    stripped = %{context | authorities: MapSet.delete(context.authorities, "box.control")}

    for {name, arguments} <- [
          {"box_new", %{}},
          {"box_list", %{}},
          {"box_exec", %{"command" => "id", "box_id" => @box_id}},
          {"box_stop", %{"box_id" => @box_id}}
        ] do
      assert {:ok, outcome} = Runner.run(snapshot, call(name, arguments), stripped)
      assert outcome["status"] == "refused"
      assert outcome["error"]["code"] == "authority_refused"
    end
  end
end