Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions lib/monkey_claw/agent_bridge/session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,21 @@ defmodule MonkeyClaw.AgentBridge.Session do
alias MonkeyClaw.AgentBridge.CliResolver
alias MonkeyClaw.AgentBridge.Telemetry, as: BridgeTelemetry
alias MonkeyClaw.Sessions
alias MonkeyClaw.Vault
alias MonkeyClaw.Workspaces

# Maps backend atoms to upstream provider strings for vault secret
# discovery in the session model hook. When a session starts, we need
# the provider name to find the matching vault secret so the hook can
# pass credentials to `backend.list_models/1`.
@backend_to_provider %{
claude: "anthropic",
codex: "openai",
gemini: "google",
opencode: "anthropic",
copilot: "github_copilot"
}
Comment thread
beardedeagle marked this conversation as resolved.
Outdated

# Default query timeout: 2 minutes (LLM queries can be slow)
@default_query_timeout 120_000

Expand Down Expand Up @@ -431,7 +444,7 @@ defmodule MonkeyClaw.AgentBridge.Session do
if Process.whereis(MonkeyClaw.ModelRegistry) do
hook_token = make_ref()
:ok = MonkeyClaw.ModelRegistry.register_hook_token(self(), hook_token)
fire_model_hook(backend, session_opts, self(), hook_token)
fire_model_hook(backend, session_opts, id, self(), hook_token)
end

{:ok, active_state}
Expand Down Expand Up @@ -890,12 +903,14 @@ defmodule MonkeyClaw.AgentBridge.Session do
# was registered with ModelRegistry during session init. Only the
# session that registered the token can authorize writes — this
# prevents forged casts from arbitrary processes on the node.
@spec fire_model_hook(module(), map(), pid(), reference()) :: :ok
defp fire_model_hook(backend, session_opts, session_pid, hook_token) do
@spec fire_model_hook(module(), map(), String.t(), pid(), reference()) :: :ok
defp fire_model_hook(backend, session_opts, workspace_id, session_pid, hook_token) do
_ =
Task.Supervisor.start_child(MonkeyClaw.TaskSupervisor, fn ->
try do
case backend.list_models(session_opts) do
enriched_opts = enrich_model_hook_opts(session_opts, workspace_id)

case backend.list_models(enriched_opts) do
{:ok, model_attrs_list} when is_list(model_attrs_list) ->
backend_name = resolve_backend_name(session_opts, backend)
now = DateTime.utc_now()
Expand Down Expand Up @@ -953,6 +968,41 @@ defmodule MonkeyClaw.AgentBridge.Session do
end
end

# Inject `:workspace_id` and `:secret_name` into session_opts so
# `BeamAgent.list_models/1` can resolve API credentials via the vault.
#
# `Scope.session_opts/1` strips these keys (they aren't persona config),
# but the session's `:id` IS the workspace_id, and the matching vault
# secret is discoverable from the backend's provider mapping.
defp enrich_model_hook_opts(session_opts, workspace_id) do
opts = Map.put(session_opts, :workspace_id, workspace_id)

case discover_secret_for_backend(workspace_id, Map.get(session_opts, :backend)) do
{:ok, secret_name} -> Map.put(opts, :secret_name, secret_name)
:error -> opts
end
end

# Query the vault for a secret matching this backend's upstream provider.
# Returns `{:ok, secret_name}` or `:error`. Raises are intentionally
# NOT rescued here — the caller's try/rescue in `fire_model_hook`
# handles all exceptions so supervision stays clean.
defp discover_secret_for_backend(workspace_id, backend_atom)
when is_binary(workspace_id) and is_atom(backend_atom) and not is_nil(backend_atom) do
case Map.get(@backend_to_provider, backend_atom) do
nil ->
:error

provider ->
case Enum.find(Vault.list_secrets(workspace_id), &(&1.provider == provider)) do
%{name: name} -> {:ok, name}
nil -> :error
end
end
end

defp discover_secret_for_backend(_, _), do: :error

# Kill an active stream task and clean up its state.
# Safe to call when no stream is active (returns state unchanged).
defp kill_stream_task(%{stream_task_pid: pid, stream_task_ref: ref} = state)
Expand Down
149 changes: 148 additions & 1 deletion lib/monkey_claw/model_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ defmodule MonkeyClaw.ModelRegistry do
alias MonkeyClaw.ModelRegistry.EtsHeir
alias MonkeyClaw.ModelRegistry.Provider
alias MonkeyClaw.Repo
alias MonkeyClaw.Vault
alias MonkeyClaw.Workspaces

@ets_table :monkey_claw_model_registry
@default_interval_ms :timer.hours(24)
Expand Down Expand Up @@ -160,6 +162,26 @@ defmodule MonkeyClaw.ModelRegistry do
GenServer.call(__MODULE__, :refresh_all, :infinity)
end

@doc """
Probe upstream APIs using vault secrets from the given workspace.

Discovers which vault secrets map to known backends via a static
`provider → backend` mapping, then probes each backend synchronously
with the corresponding secret name and workspace ID. On success,
auto-configures `state.backends`, `state.backend_configs`, and
`state.workspace_id` so future tick probes continue without manual
intervention.

Returns `:ok` when at least one backend was probed (individual probe
failures are logged but do not fail the call), or
`{:error, :no_backends_discovered}` when no vault secrets map to a
known backend.
"""
@spec refresh_for_workspace(Ecto.UUID.t()) :: :ok | {:error, :no_backends_discovered}
def refresh_for_workspace(workspace_id) when is_binary(workspace_id) do
GenServer.call(__MODULE__, {:refresh_for_workspace, workspace_id}, :infinity)
end

@doc """
Register a capability token for session-hook writes.

Expand Down Expand Up @@ -314,6 +336,30 @@ defmodule MonkeyClaw.ModelRegistry do
{:reply, :ok, state}
end

def handle_call({:refresh_for_workspace, workspace_id}, _from, %State{} = state) do
case discover_workspace_backends(workspace_id) do
[] ->
{:reply, {:error, :no_backends_discovered}, state}

backend_specs ->
state = bootstrap_workspace_config(backend_specs, workspace_id, state)

state =
Enum.reduce(backend_specs, state, fn {backend, secret_name}, acc ->
{_result, new_state} =
do_synchronous_probe(
backend,
probe_opts_for_workspace(backend, workspace_id, secret_name),
acc
)

new_state
end)

{:reply, :ok, state}
end
end

def handle_call({:configure, opts}, _from, %State{} = state) do
case validate_configure_opts(opts, state) do
:ok ->
Expand Down Expand Up @@ -358,6 +404,7 @@ defmodule MonkeyClaw.ModelRegistry do
@impl true
def handle_info(:tick, %State{} = state) do
state = maybe_retry_sqlite_load(state)
state = maybe_auto_discover(state)
state = Enum.reduce(state.backends, state, &maybe_dispatch_probe/2)
state = schedule_next_tick(state)
{:noreply, state}
Comment on lines 411 to 416
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe_auto_discover/1 only runs on :tick, but when state.backends is empty, schedule_next_tick/1 computes the next tick delay as state.default_interval (24h by default). This means if no CLI auth is present at startup, newly authenticated backends won’t be auto-discovered until the next daily tick unless an explicit refresh is triggered. Consider scheduling a much shorter retry cadence while backends == [] (or a separate discovery timer) so “auto-discovery” reacts promptly to users logging in after boot.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -524,8 +571,10 @@ defmodule MonkeyClaw.ModelRegistry do
# ── Private — Synchronous probe ─────────────────────────────

defp do_synchronous_probe(backend, state) do
{adapter, opts} = probe_opts(backend, state)
do_synchronous_probe(backend, probe_opts(backend, state), state)
end

defp do_synchronous_probe(backend, {adapter, opts}, state) do
task =
Task.Supervisor.async_nolink(MonkeyClaw.TaskSupervisor, fn ->
adapter.list_models(opts)
Expand Down Expand Up @@ -561,6 +610,104 @@ defmodule MonkeyClaw.ModelRegistry do
end
end

# ── Private — Workspace-aware probing ──────────────────────

# Static reverse mapping from vault secret provider to ModelRegistry
# backend name. Mirrors the forward mapping in BeamAgent.backend_to_provider/1.
@provider_to_backend %{
"anthropic" => "claude",
"openai" => "codex",
"google" => "gemini",
"github_copilot" => "copilot"
Comment thread
beardedeagle marked this conversation as resolved.
}
Comment thread
beardedeagle marked this conversation as resolved.

# Discovers which vault secrets in a workspace map to known backends.
# Returns [{backend, secret_name}] — one entry per discoverable backend.
@spec discover_workspace_backends(Ecto.UUID.t()) :: [{String.t(), String.t()}]
defp discover_workspace_backends(workspace_id) do
workspace_id
|> Vault.list_secrets()
|> Enum.flat_map(fn secret ->
case Map.get(@provider_to_backend, secret.provider) do
nil -> []
backend -> [{backend, secret.name}]
end
end)
|> Enum.uniq_by(fn {backend, _} -> backend end)
end

# Builds {adapter, opts} for a workspace-derived probe — bypasses
# state.backend_configs and injects workspace_id + secret_name directly.
defp probe_opts_for_workspace(backend, workspace_id, secret_name) do
{MonkeyClaw.AgentBridge.Backend.BeamAgent,
%{backend: backend, workspace_id: workspace_id, secret_name: secret_name}}
end

# Merges discovered backends into GenServer state so future tick
# probes and refresh_all/0 calls work without reconfiguration.
# When no backends are configured, discover them from the first
# workspace's vault secrets. Single-user, single-instance: the first
# workspace is the only workspace. Fires once per tick when backends
# is empty — if no workspace or secrets exist yet, returns state
# unchanged and retries on the next tick.
defp maybe_auto_discover(%State{backends: []} = state) do
case auto_discover_backends() do
{[_ | _] = specs, workspace_id} ->
Logger.info(
"ModelRegistry: auto-discovered #{length(specs)} backend(s) from workspace vault"
)

bootstrap_workspace_config(specs, workspace_id, state)

_ ->
state
end
end

defp maybe_auto_discover(state), do: state

defp auto_discover_backends do
case Workspaces.list_workspaces() do
[workspace | _] ->
{discover_workspace_backends(workspace.id), workspace.id}

[] ->
{[], nil}
end
rescue
e ->
Logger.debug("ModelRegistry: auto-discovery skipped: #{Exception.message(e)}")
{[], nil}
end

defp bootstrap_workspace_config(backend_specs, workspace_id, state) do
new_backends =
backend_specs
|> Enum.map(fn {backend, _} -> backend end)
|> Enum.concat(state.backends)
|> Enum.uniq()

new_configs =
Enum.reduce(backend_specs, state.backend_configs, fn {backend, secret_name}, acc ->
Map.update(acc, backend, %{secret_name: secret_name}, fn config ->
Map.put(config, :secret_name, secret_name)
end)
end)

new_last_probe =
Enum.reduce(new_backends, state.last_probe_at, fn backend, acc ->
Map.put_new(acc, backend, nil)
end)

%{
state
| backends: new_backends,
backend_configs: new_configs,
workspace_id: workspace_id,
last_probe_at: new_last_probe
}
end

# ── Private — Probe result handling ─────────────────────────

defp handle_probe_result(backend, {:ok, []}, state) do
Expand Down
23 changes: 14 additions & 9 deletions lib/monkey_claw_web/live/vault_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ defmodule MonkeyClawWeb.VaultLive do
* **Tokens tab** — List and delete OAuth tokens with
active/expired status indicators.
* **Models tab** — Browse cached models grouped by provider
with per-model backend tags, trigger on-demand refresh.
with per-model backend tags. Refresh probes upstream APIs
using vault secrets from the current workspace via
`ModelRegistry.refresh_for_workspace/1`, which also
auto-configures the registry for future tick probes.
Comment thread
beardedeagle marked this conversation as resolved.
Outdated

## Security Invariant

Expand Down Expand Up @@ -207,7 +210,7 @@ defmodule MonkeyClawWeb.VaultLive do
# ── Model Events ───────────────────────────────────────────

def handle_event("refresh_models", _params, socket) do
case spawn_refresh_task() do
case spawn_refresh_task(socket.assigns.workspace_id) do
{:ok, _child} ->
{:noreply, assign(socket, :refreshing_models, true)}

Expand Down Expand Up @@ -618,8 +621,8 @@ defmodule MonkeyClawWeb.VaultLive do
%{}
end

defp safe_refresh_models do
ModelRegistry.refresh_all()
defp safe_refresh_models(workspace_id) do
ModelRegistry.refresh_for_workspace(workspace_id)
rescue
e -> {:error, Exception.message(e)}
end
Expand All @@ -628,21 +631,23 @@ defmodule MonkeyClawWeb.VaultLive do
# success or {:error, reason} if the registry or supervisor is
# unavailable. Split into two functions to satisfy Credo's max
# nesting depth of 2.
@spec spawn_refresh_task() :: {:ok, pid()} | {:error, String.t()}
defp spawn_refresh_task do
@spec spawn_refresh_task(Ecto.UUID.t() | nil) :: {:ok, pid()} | {:error, String.t()}
defp spawn_refresh_task(nil), do: {:error, "No workspace available"}

defp spawn_refresh_task(workspace_id) do
case Process.whereis(ModelRegistry) do
nil -> {:error, "Model registry is not running"}
_pid -> start_refresh_child()
_pid -> start_refresh_child(workspace_id)
end
end

defp start_refresh_child do
defp start_refresh_child(workspace_id) do
lv = self()

case Task.Supervisor.start_child(MonkeyClaw.TaskSupervisor, fn ->
result =
try do
safe_refresh_models()
safe_refresh_models(workspace_id)
catch
kind, reason -> {:error, "#{kind}: #{inspect(reason)}"}
end
Expand Down
Loading