test/openagents/tools/repository_mutation_tools_test.exs

58e6347eeb72 · 7 KB

defmodule OpenAgents.Tools.RepositoryMutationToolsTest do
  @moduledoc """
  The repository mutation family (#122, SELF-EDIT-001): edits confined to a
  job's own clone of the forge with the exact-match policy, and a real
  commit+push over real HTTP to the forge with the WAL receipt and branch
  discipline — plus every refusal the invariant promises.
  """

  use OpenAgents.DataCase, async: false

  alias OpenAgents.Forge
  alias OpenAgents.Forge.Repos
  alias OpenAgents.Tools.{ExecutionContext, Registry, RepoCommitPush, RepoEdit, RepoWrite, Runner}

  defmodule TestPipeline do
    @moduledoc false
    use Plug.Builder

    plug OpenAgentsWeb.Plugs.ForgeGitAuth
    plug OpenAgents.Forge.GitHTTP
  end

  @operator_token "forge_test_operator_token_0123456789"
  @job_ref "work-job:11111111-2222-3333-4444-555555555555"

  setup do
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})

    base = Path.join(System.tmp_dir!(), "repo-mut-#{System.unique_integer([:positive])}")
    File.mkdir_p!(base)

    previous =
      for key <- [:forge_data_dir, :forge_wal_dir, :coding_jobs_dir, :forge_self_push_url] do
        {key, Application.get_env(:openagents, key)}
      end

    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
    Application.put_env(:openagents, :coding_jobs_dir, Path.join(base, "jobs"))

    port = free_port()
    start_supervised!({Bandit, plug: TestPipeline, port: port, ip: {127, 0, 0, 1}})

    Application.put_env(
      :openagents,
      :forge_self_push_url,
      "http://x:#{@operator_token}@127.0.0.1:#{port}/openagents.com.git"
    )

    # Seed the forge's "openagents.com" repo with one commit so clones have a base.
    seed_repo!()

    on_exit(fn ->
      for {key, value} <- previous do
        if value,
          do: Application.put_env(:openagents, key, value),
          else: Application.delete_env(:openagents, key)
      end

      File.rm_rf(base)
    end)

    {:ok, snapshot} = Registry.build([RepoEdit, RepoWrite, RepoCommitPush])
    %{snapshot: snapshot}
  end

  defp free_port do
    {:ok, socket} = :gen_tcp.listen(0, [])
    {:ok, port} = :inet.port(socket)
    :gen_tcp.close(socket)
    port
  end

  defp seed_repo! do
    path = Repos.ensure_repo!("openagents.com")

    {blob, 0} = plumb(path, ["hash-object", "-w", "--stdin"], "original content\n")
    {tree, 0} = plumb(path, ["mktree"], "100644 blob #{String.trim(blob)}\tnote.txt\n")

    {commit, 0} =
      plumb(path, ["commit-tree", String.trim(tree), "-m", "seed"], "",
        env: [
          {"GIT_AUTHOR_NAME", "t"},
          {"GIT_AUTHOR_EMAIL", "t@t"},
          {"GIT_COMMITTER_NAME", "t"},
          {"GIT_COMMITTER_EMAIL", "t@t"}
        ]
      )

    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", String.trim(commit)])
    :ok
  end

  defp plumb(path, args, stdin, opts \\ []) do
    input = Path.join(System.tmp_dir!(), "plumb-#{System.unique_integer([:positive])}")
    File.write!(input, stdin)

    try do
      System.cmd(
        "sh",
        ["-c", ~s(exec git --git-dir "$GD" "$@" < "$IN"), "sh"] ++ args,
        env: [{"GD", path}, {"IN", input}] ++ Keyword.get(opts, :env, [])
      )
    after
      File.rm(input)
    end
  end

  defp context(job_ref \\ @job_ref) do
    scope_ref = "conversation:test"

    %ExecutionContext{
      scope: "browser_conversation",
      scope_ref: scope_ref,
      authorities: MapSet.new(["repository.write"]),
      job_ref: job_ref,
      approval_receipts:
        if(job_ref,
          do: OpenAgents.Tools.Repository.approval_receipts(scope_ref, job_ref),
          else: []
        )
    }
  end

  defp run(snapshot, name, arguments, ctx) do
    {:ok, outcome} =
      Runner.run(
        snapshot,
        %{
          call_id: "call-#{name}-#{System.unique_integer([:positive])}",
          name: name,
          version: 1,
          raw_arguments: Jason.encode!(arguments)
        },
        ctx
      )

    outcome
  end

  test "the exact-match edit policy: no match, ambiguity, replace_all", %{snapshot: snapshot} do
    ctx = context()

    write =
      run(snapshot, "repo_write", %{"path" => "sample.txt", "content" => "aaa\nbbb\naaa\n"}, ctx)

    assert write["status"] == "succeeded"

    no_match =
      run(
        snapshot,
        "repo_edit",
        %{"path" => "sample.txt", "old_string" => "zzz", "new_string" => "q"},
        ctx
      )

    assert no_match["error"]["code"] == "no_match"

    ambiguous =
      run(
        snapshot,
        "repo_edit",
        %{"path" => "sample.txt", "old_string" => "aaa", "new_string" => "q"},
        ctx
      )

    assert ambiguous["error"]["code"] == "ambiguous_match"
    assert ambiguous["error"]["message"] =~ "more surrounding context"

    all =
      run(
        snapshot,
        "repo_edit",
        %{
          "path" => "sample.txt",
          "old_string" => "aaa",
          "new_string" => "ccc",
          "replace_all" => true
        },
        ctx
      )

    assert all["status"] == "succeeded"
    assert all["result"]["replacements"] == 2
  end

  test "mutation tools refuse outside a coding job's workspace", %{snapshot: snapshot} do
    outcome =
      run(
        snapshot,
        "repo_write",
        %{"path" => "x.txt", "content" => "boo"},
        context(nil)
      )

    # Outside a coding job there is no job approval receipt, so the surface
    # policy refuses before the tool even runs — mutation is doubly gated
    # (receipt here, workspace check inside the executor).
    assert outcome["status"] == "refused"
    assert outcome["error"]["code"] == "module_approval_required"
  end

  test "commit+push lands on the job branch with a WAL-backed receipt; other branches refused",
       %{snapshot: snapshot} do
    ctx = context()

    _write =
      run(snapshot, "repo_write", %{"path" => "feature.txt", "content" => "new feature\n"}, ctx)

    refused =
      run(
        snapshot,
        "repo_commit_push",
        %{"message" => "nope", "branch" => "main"},
        ctx
      )

    assert refused["error"]["code"] == "branch_refused"

    pushed = run(snapshot, "repo_commit_push", %{"message" => "Job edit"}, ctx)
    assert pushed["status"] == "succeeded"
    sha = pushed["result"]["sha"]
    assert sha =~ ~r/^[0-9a-f]{40}$/
    branch = pushed["result"]["branch"]
    assert branch == "openagents/job-11111111-2222-3333-4444-555555555555"

    # The commit SHA is in the outcome receipt refs (SELF-EDIT-001). The
    # middle segment is the forge repo the coding lane edits
    # (`OpenAgents.Tools.Repository.repo/0`), which is now `openagents.com` — the
    # same repository this test uses for the branch, the push receipt, and the
    # bare path below. Renaming the forge repo is a separate whole-repo
    # change (config `forge_repos`, visibility, public paths, git URL).
    assert "forge-commit:openagents.com:#{sha}" in pushed["target_receipt_refs"]

    # The push is receipted with a WAL sequence, and the ref exists on the
    # forge with exactly that sha.
    assert [receipt | _rest] = Forge.recent_pushes("openagents.com")
    assert is_integer(receipt.wal_seq)
    assert %{"new" => ^sha} = Map.get(receipt.refs, "refs/heads/" <> branch)

    path = Repos.bare_path("openagents.com")
    {out, 0} = Repos.git(path, ["rev-parse", "refs/heads/" <> branch])
    assert String.trim(out) == sha

    # Nothing further to commit is a typed outcome, not a crash.
    empty = run(snapshot, "repo_commit_push", %{"message" => "again"}, ctx)
    assert empty["error"]["code"] == "nothing_to_commit"

    # The push URL (which carries the forge credential) appears nowhere in
    # the outcome.
    refute inspect(pushed) =~ @operator_token
  end
end