lib/openagents/tools/repository.ex

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Tools.Repository do
  @moduledoc """
  Shared substrate for the repository tool family (#122, SELF-EDIT-001).

  Two distinct roots, disclosed distinctly:

  - **Baked source** (`source_dir/0`): the source tree of the code this node
    is actually running, baked into the image at `/app/src`. Read-only truth
    for "what am I running" — read tools default here.
  - **Per-job clone** (`workspace_dir/1`): a working tree cloned from the
    local forge bare repo (origin is never GitHub) under the job's own
    directory. The ONLY place mutation tools may act, and pushes from it go
    only to that job's `openagents/job-<id>` branch. Removed when the job ends.

  Path handling fails closed: every path is resolved and verified to stay
  inside its root before any filesystem call.
  """

  alias OpenAgents.Forge.RepoRef
  alias OpenAgents.Forge.Repos
  alias OpenAgents.Forge.Sync

  @repo "openagents.com"

  @doc "The baked source root (the running code's own tree)."
  def source_dir do
    Application.get_env(:openagents, :source_repo_dir, File.cwd!())
  end

  @doc "The workspace root that holds all per-job clones."
  def jobs_dir do
    Application.get_env(
      :openagents,
      :coding_jobs_dir,
      "/var/lib/openagents/coding-jobs"
    )
  end

  @doc """
  The *name* of the forge repository the coding lane edits.

  A name, not a storage key: receipts and targets record this string, and
  anything that reaches the disk resolves it first with `storage_key/0`.
  """
  def repo, do: @repo

  @doc """
  The storage key the coding lane's repository is stored under.

  `repo/0` is a name and the forge keys durable state by
  `Repository.storage_key`, so the two are only the same string for a
  repository old enough to predate the split. Cloning from the name's path
  instead of the key's is how a workspace came to be cut from a bare
  repository projecting nothing (issue #190).
  """
  def storage_key, do: RepoRef.storage_key_or_ref(@repo)

  @doc "This job's clone directory (may not exist yet)."
  def workspace_dir("work-job:" <> job_id), do: Path.join(jobs_dir(), "job-" <> job_id)

  @doc "This job's push branch — the only ref it may ever push."
  def job_branch("work-job:" <> job_id), do: "openagents/job-" <> job_id

  @doc """
  Ensure the per-job clone exists, cloning from the LOCAL forge bare repo
  (WAL-fresh). Returns `{:ok, dir}` or a typed error.
  """
  def ensure_workspace(job_ref) when is_binary(job_ref) do
    dir = workspace_dir(job_ref)

    if File.dir?(Path.join(dir, ".git")) do
      {:ok, dir}
    else
      storage_key = storage_key()
      Sync.ensure_fresh(storage_key)
      bare = Repos.bare_path(storage_key)
      File.mkdir_p!(jobs_dir())

      case System.cmd("git", ["clone", "--quiet", bare, dir], stderr_to_stdout: true) do
        {_, 0} -> {:ok, dir}
        {output, _} -> {:error, {:workspace_clone_failed, String.slice(output, 0, 500)}}
      end
    end
  end

  def ensure_workspace(_job_ref), do: {:error, :repository_workspace_unavailable}

  @doc "Remove a job's clone. Idempotent; called when the job ends."
  def cleanup_workspace(job_ref) when is_binary(job_ref) do
    case job_ref do
      "work-job:" <> _id -> File.rm_rf(workspace_dir(job_ref))
      _other -> :ok
    end

    :ok
  end

  @doc """
  The root a read tool acts on: `"image"` is the baked source of the running
  code; `"workspace"` is this job's clone (requires a job context and
  creates the clone on first use).
  """
  def tool_root("image", _context), do: {:ok, source_dir()}

  def tool_root("workspace", %{job_ref: job_ref}) when is_binary(job_ref),
    do: ensure_workspace(job_ref)

  def tool_root("workspace", _context), do: {:error, :repository_workspace_unavailable}
  def tool_root(_from, _context), do: {:error, :invalid_repository_path}

  @doc """
  Resolve a repo-relative path inside `root`, refusing traversal outside it.
  """
  def safe_path(root, relative) when is_binary(relative) do
    cond do
      not is_binary(root) or root == "" ->
        {:error, :repository_unavailable}

      String.starts_with?(relative, "/") or String.contains?(relative, "\0") ->
        {:error, :invalid_repository_path}

      true ->
        expanded = Path.expand(relative, root)
        rootward = Path.expand(root)

        if expanded == rootward or String.starts_with?(expanded, rootward <> "/") do
          {:ok, expanded}
        else
          {:error, :invalid_repository_path}
        end
    end
  end

  def safe_path(_root, _relative), do: {:error, :invalid_repository_path}

  @doc """
  The exact-match edit policy (probe's semantics re-expressed in Elixir):
  LF-normalized matching; zero matches is a typed error; more than one match
  without `replace_all` asks for more context; the caller re-reads the file
  immediately before writing so stale expectations fail honestly.

  Returns `{:ok, new_content, replaced_count}` or a typed error.
  """
  def apply_edit(content, old_string, new_string, replace_all)
      when is_binary(content) and is_binary(old_string) and is_binary(new_string) do
    normalized = normalize(content)
    old = normalize(old_string)
    new = normalize(new_string)

    cond do
      old == "" ->
        {:error, :empty_match_string}

      old == new ->
        {:error, :edit_is_noop}

      true ->
        case count_matches(normalized, old) do
          0 -> {:error, :no_match}
          1 -> {:ok, String.replace(normalized, old, new), 1}
          count when replace_all -> {:ok, String.replace(normalized, old, new), count}
          _many -> {:error, :ambiguous_match}
        end
    end
  end

  defp normalize(string), do: String.replace(string, "\r\n", "\n")

  defp count_matches(content, pattern) do
    content |> String.split(pattern) |> length() |> Kernel.-(1)
  end

  @doc """
  Approval receipts for the repository mutation modules, minted for a coding
  job. Starting the coding job is the person's exact current consent for the
  reversible writes inside the job's own clone; the operator posture for the
  one external effect (a push to the job's own branch on the OpenAgents forge)
  is host policy: the branch is sandboxed and promotion stays operator-only
  (SELF-EDIT-001).
  """
  def approval_receipts(scope_ref, job_ref)
      when is_binary(scope_ref) and is_binary(job_ref) do
    reversible =
      for module_id <- ["sarah.tool.repo_edit.v1", "sarah.tool.repo_write.v1"] do
        %{
          "schema" => "sarah.module_approval.v1",
          "approval_class" => "exact_current_user_consent",
          "module_id" => module_id,
          "version" => 1,
          "scope_ref" => scope_ref,
          "explicit" => true,
          "actor_type" => "person",
          "receipt_ref" => job_ref
        }
      end

    push = %{
      "schema" => "sarah.module_approval.v1",
      "approval_class" => "explicit_operator_approval",
      "module_id" => "sarah.tool.repo_commit_push.v1",
      "version" => 1,
      "scope_ref" => scope_ref,
      "explicit" => true,
      "actor_type" => "operator",
      "receipt_ref" => job_ref
    }

    reversible ++ [push]
  end

  def approval_receipts(_scope_ref, _job_ref), do: []

  @doc "Where `repo_commit_push` pushes: the forge's own endpoint, never GitHub."
  def push_url do
    Application.get_env(:openagents, :forge_self_push_url)
  end

  @doc "Run git in `dir` with prompts disabled; returns {output, status}."
  def git(dir, args) do
    System.cmd("git", ["-C", dir | args],
      stderr_to_stdout: true,
      env: [{"GIT_TERMINAL_PROMPT", "0"}]
    )
  end
end