Compare commits

...

2 Commits

Author SHA1 Message Date
Kecheng Cao cb548c5e81 refactor(claude-launcher): make ClaudeLauncher an ABC interface
Replace the `Callable[[str, list[str]], tuple[str, list[str]]]` alias with a
`ClaudeLauncher` abstract base class exposing a `launch()` method. Plugins now
register a subclass as their entry point; Omnigent loads the class,
instantiates it (no-arg constructor), and rejects anything that is not a
`ClaudeLauncher` instance. New failure modes (instantiation error, wrong type)
fall back to the default launch like the rest. Tests updated accordingly.
2026-06-28 21:29:27 +00:00
Kecheng Cao c1f1100ffe feat(claude-launcher): discover launcher plugins via setuptools entry points
Switch native-Claude launcher plugin discovery from `module.path:callable`
references to setuptools entry points (the mechanism MLflow uses for its
plugins). A launcher is now any installed package registering a callable in
the `omnigent.claude_launcher` entry-point group; `OMNIGENT_CLAUDE_LAUNCHER`
selects which one by entry-point name (e.g. `isaac`).

This lets a caller attach a launcher purely by `pip install`-ing a package
into the runner's environment -- no in-tree import path, no Omnigent code
change. All failure modes (unknown name, load error, raised exception,
malformed return) still fall back to the default launch so a broken or
missing plugin can never block a Claude launch.

Update the runner env-allowlist comment for OMNIGENT_CLAUDE_LAUNCHER to
describe the new entry-point-name semantics, and rework the launcher tests
to stub `importlib.metadata.entry_points` instead of injecting fake modules.
2026-06-28 21:16:31 +00:00
4 changed files with 204 additions and 82 deletions
+96 -43
View File
@@ -12,16 +12,32 @@ tooling; running ``isaac claude`` instead of bare ``claude`` keeps it in force.
Rather than hardcode the binary at each site, both paths route the
``(command, args)`` pair through :func:`resolve_claude_launch`. By default this
is the identity, so behaviour is unchanged. When ``OMNIGENT_CLAUDE_LAUNCHER``
names a plugin, that plugin decides the final command and args. The argv handed
to it is already fully augmented (MCP config, hook settings and skill flags
injected by :func:`augment_claude_args`), so a plugin that merely wraps the
command -- e.g. ``("isaac", ["claude", "--omni-internal", "--", *args])`` --
preserves the Omnigent bridge unchanged.
is the identity, so behaviour is unchanged.
Plugin reference format is ``module.path:callable`` resolving to::
Launcher plugins follow the same shape as MLflow's plugins: a plugin is a normal
installed Python package whose class implements the :class:`ClaudeLauncher`
interface and registers it as a setuptools entry point in the
:data:`CLAUDE_LAUNCHER_ENTRY_POINT_GROUP` group::
def launch(command: str, args: list[str]) -> tuple[str, list[str]]: ...
# the plugin package's pyproject.toml
[project.entry-points."omnigent.claude_launcher"]
isaac = "isaac_omni_launcher:IsaacClaudeLauncher"
# isaac_omni_launcher.py
from omnigent.claude_launcher import ClaudeLauncher
class IsaacClaudeLauncher(ClaudeLauncher):
def launch(self, command, args):
return "isaac", ["claude", "--", *args]
Any caller attaches a plugin by ``pip install``-ing such a package into the
environment the runner runs in -- no Omnigent code change, no in-tree import
path. At launch time, the ``OMNIGENT_CLAUDE_LAUNCHER`` environment variable
selects *which* registered launcher to use, by entry-point name (e.g.
``OMNIGENT_CLAUDE_LAUNCHER=isaac``). Unset -> default launch. The selected
launcher receives the fully-augmented argv (MCP config, hook settings and skill
flags injected by :func:`augment_claude_args`), so a launcher that merely wraps
the command preserves the Omnigent bridge unchanged.
Selection is per-process via the environment so the runner (which spawns the
terminal on managed hosts) and the local CLI each opt in independently; the
@@ -30,29 +46,57 @@ bootstrapping integration sets the env var before the launching process starts.
from __future__ import annotations
import importlib
import abc
import importlib.metadata
import logging
import os
from collections.abc import Callable
#: Environment variable naming the launcher plugin as ``module.path:callable``.
#: Environment variable selecting a launcher plugin by entry-point name.
CLAUDE_LAUNCHER_ENV_VAR = "OMNIGENT_CLAUDE_LAUNCHER"
#: setuptools entry-point group launcher plugins register themselves in.
CLAUDE_LAUNCHER_ENTRY_POINT_GROUP = "omnigent.claude_launcher"
_logger = logging.getLogger(__name__)
#: Signature a registered launcher plugin must implement.
ClaudeLauncher = Callable[[str, list[str]], tuple[str, list[str]]]
class ClaudeLauncher(abc.ABC):
"""
Interface a native-Claude launcher plugin implements.
A plugin subclasses this and registers the subclass as an entry point in the
:data:`CLAUDE_LAUNCHER_ENTRY_POINT_GROUP` group (see the module docstring).
Omnigent instantiates the subclass (no-arg constructor) and calls
:meth:`launch` to decide the final spawn command.
"""
@abc.abstractmethod
def launch(self, command: str, args: list[str]) -> tuple[str, list[str]]:
"""
Return the ``(command, args)`` to actually spawn for this Claude launch.
:param command: Default terminal command Omnigent would otherwise spawn,
e.g. ``"claude"``.
:param args: Fully-augmented Claude CLI args (MCP config, hook settings
and skill flags already injected by :func:`augment_claude_args`).
Forward these unchanged (e.g. after a ``--`` separator) to preserve
the Omnigent bridge.
:returns: The ``(command, args)`` Omnigent should spawn instead.
"""
raise NotImplementedError
def resolve_claude_launch(command: str, args: list[str]) -> tuple[str, list[str]]:
"""
Resolve the final launch command/args for the native Claude terminal.
Delegates to the plugin named by :data:`CLAUDE_LAUNCHER_ENV_VAR` when set;
otherwise returns the inputs unchanged. Any failure to load or run the
plugin -- bad reference, import error, raised exception, malformed return
value -- is logged and falls back to the default ``(command, args)`` so a
broken plugin can never block a Claude launch.
Selects the launcher plugin named by :data:`CLAUDE_LAUNCHER_ENV_VAR` from the
:data:`CLAUDE_LAUNCHER_ENTRY_POINT_GROUP` entry-point group when set;
otherwise returns the inputs unchanged. Any failure to find, load or run the
plugin -- unknown name, load/instantiate error, wrong type, raised exception,
malformed return value -- is logged and falls back to the default
``(command, args)`` so a broken or missing plugin can never block a Claude
launch.
:param command: Default terminal command, e.g. ``"claude"``.
:param args: Fully-augmented Claude CLI args (MCP/hooks/skills already
@@ -60,56 +104,65 @@ def resolve_claude_launch(command: str, args: list[str]) -> tuple[str, list[str]
:returns: The ``(command, args)`` to spawn. ``args`` is always a fresh list.
"""
default = (command, list(args))
spec = os.environ.get(CLAUDE_LAUNCHER_ENV_VAR, "").strip()
if not spec:
name = os.environ.get(CLAUDE_LAUNCHER_ENV_VAR, "").strip()
if not name:
return default
launcher = _load_launcher(spec)
launcher = _load_launcher(name)
if launcher is None:
return default
try:
result = launcher(command, list(args))
result = launcher.launch(command, list(args))
except Exception:
_logger.exception("Claude launcher plugin %r raised; falling back to default launch", spec)
_logger.exception("Claude launcher plugin %r raised; falling back to default launch", name)
return default
return _validated_result(result, spec, default)
return _validated_result(result, name, default)
def _load_launcher(spec: str) -> ClaudeLauncher | None:
def _load_launcher(name: str) -> ClaudeLauncher | None:
"""
Import the launcher callable from a ``module.path:callable`` reference.
Resolve and instantiate the launcher registered under *name* via entry points.
:param spec: Plugin reference, e.g. ``"isaac_omni.launcher:launch_claude"``.
:returns: The resolved callable, or ``None`` when the reference is malformed
or cannot be imported.
:param name: Entry-point name from :data:`CLAUDE_LAUNCHER_ENV_VAR`, e.g.
``"isaac"``.
:returns: A :class:`ClaudeLauncher` instance, or ``None`` when no matching
entry point is registered, it fails to load/instantiate, or it does not
implement :class:`ClaudeLauncher`.
"""
module_path, sep, attr = spec.partition(":")
if not sep or not module_path or not attr:
try:
entry_points = importlib.metadata.entry_points(group=CLAUDE_LAUNCHER_ENTRY_POINT_GROUP)
except Exception:
_logger.exception("Failed to enumerate %r entry points", CLAUDE_LAUNCHER_ENTRY_POINT_GROUP)
return None
matches = [entry_point for entry_point in entry_points if entry_point.name == name]
if not matches:
_logger.error(
"Ignoring %s=%r: expected 'module.path:callable'",
CLAUDE_LAUNCHER_ENV_VAR,
spec,
"No Claude launcher named %r registered in entry-point group %r",
name,
CLAUDE_LAUNCHER_ENTRY_POINT_GROUP,
)
return None
if len(matches) > 1:
_logger.warning("Multiple Claude launchers named %r registered; using the first", name)
try:
module = importlib.import_module(module_path)
launcher = getattr(module, attr)
except (ImportError, AttributeError):
_logger.exception("Could not load Claude launcher plugin %r", spec)
launcher_cls = matches[0].load()
launcher = launcher_cls() if isinstance(launcher_cls, type) else launcher_cls
except Exception:
_logger.exception("Could not load Claude launcher plugin %r", name)
return None
if not callable(launcher):
_logger.error("Claude launcher plugin %r is not callable", spec)
if not isinstance(launcher, ClaudeLauncher):
_logger.error("Claude launcher plugin %r does not implement ClaudeLauncher", name)
return None
return launcher
def _validated_result(
result: object, spec: str, default: tuple[str, list[str]]
result: object, name: str, default: tuple[str, list[str]]
) -> tuple[str, list[str]]:
"""
Coerce and validate a plugin's return value to ``(str, list[str])``.
:param result: Raw plugin return value.
:param spec: Plugin reference, for diagnostics.
:param name: Launcher entry-point name, for diagnostics.
:param default: Fallback ``(command, args)`` when ``result`` is malformed.
:returns: A validated ``(command, args)`` tuple, or ``default``.
"""
@@ -125,7 +178,7 @@ def _validated_result(
_logger.error(
"Claude launcher plugin %r returned %r; expected (str, list[str]); "
"falling back to default launch",
spec,
name,
result,
)
return default
+5 -4
View File
@@ -280,13 +280,14 @@ _RUNNER_ENV_ALLOWLIST: frozenset[str] = frozenset(
# ``OMNIGENT_RUNNER_ENV_PASSTHROUGH=OMNIGENT_CLAUDE_SDK_NO_SANDBOX``).
# Safe to propagate: not a secret.
"OMNIGENT_CLAUDE_SDK_NO_SANDBOX",
# Native-Claude launcher plugin selector (``module.path:callable``).
# Read by omnigent.claude_launcher.resolve_claude_launch in the
# managed-host runner (``_auto_create_claude_terminal``) to wrap the
# Native-Claude launcher plugin selector: the entry-point NAME of a
# launcher registered in the ``omnigent.claude_launcher`` group (e.g.
# ``isaac``). Read by omnigent.claude_launcher.resolve_claude_launch in
# the managed-host runner (``_auto_create_claude_terminal``) to wrap the
# Claude launch through a downstream binary (e.g. Databricks' isaac).
# The daemon→runner env strip would otherwise drop it, leaving the
# runner on the default launch. Safe to propagate: not a secret, just a
# plugin reference string.
# plugin name.
"OMNIGENT_CLAUDE_LAUNCHER",
# Testing knob: override the context window size for compaction
# trigger threshold. Not a secret — a plain integer.
+92 -29
View File
@@ -2,20 +2,51 @@
from __future__ import annotations
import sys
import types
import importlib.metadata
import pytest
from omnigent.claude_launcher import CLAUDE_LAUNCHER_ENV_VAR, resolve_claude_launch
from omnigent.claude_launcher import (
CLAUDE_LAUNCHER_ENTRY_POINT_GROUP,
CLAUDE_LAUNCHER_ENV_VAR,
ClaudeLauncher,
resolve_claude_launch,
)
def _register_plugin(monkeypatch, value, *, attr="launch", module="fake_launcher_mod"):
"""Inject a fake plugin module and point the env var at it."""
mod = types.ModuleType(module)
setattr(mod, attr, value)
monkeypatch.setitem(sys.modules, module, mod)
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, f"{module}:{attr}")
class _FakeEntryPoint:
"""Minimal stand-in for :class:`importlib.metadata.EntryPoint`."""
def __init__(self, name, value):
self.name = name
self._value = value
def load(self):
if isinstance(self._value, BaseException):
raise self._value
return self._value
def _register(monkeypatch, *entry_points, raise_on_enumerate=None):
"""Make ``importlib.metadata.entry_points(group=...)`` return *entry_points*."""
def fake_entry_points(*, group):
assert group == CLAUDE_LAUNCHER_ENTRY_POINT_GROUP
if raise_on_enumerate is not None:
raise raise_on_enumerate
return list(entry_points)
monkeypatch.setattr(importlib.metadata, "entry_points", fake_entry_points)
def _launcher_cls(fn):
"""Build a :class:`ClaudeLauncher` subclass whose ``launch`` delegates to *fn*."""
class _Launcher(ClaudeLauncher):
def launch(self, command, args):
return fn(command, args)
return _Launcher
def test_identity_when_env_unset(monkeypatch):
@@ -32,47 +63,77 @@ def test_identity_returns_fresh_list(monkeypatch):
def test_plugin_wraps_command(monkeypatch):
def wrap(command, args):
return "isaac", ["claude", "--omni-internal", "--", *args]
_register_plugin(monkeypatch, wrap)
cls = _launcher_cls(lambda command, args: ("isaac", ["claude", "--omni", "--", *args]))
_register(monkeypatch, _FakeEntryPoint("isaac", cls))
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
command, args = resolve_claude_launch("claude", ["--mcp-config", "{}"])
assert command == "isaac"
assert args == ["claude", "--omni-internal", "--", "--mcp-config", "{}"]
assert args == ["claude", "--omni", "--", "--mcp-config", "{}"]
def test_plugin_selected_by_name_among_several(monkeypatch):
_register(
monkeypatch,
_FakeEntryPoint("other", _launcher_cls(lambda command, args: ("nope", []))),
_FakeEntryPoint("isaac", _launcher_cls(lambda command, args: ("isaac", ["--", *args]))),
)
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
command, args = resolve_claude_launch("claude", ["--x"])
assert command == "isaac"
assert args == ["--", "--x"]
def test_plugin_receives_default_command_and_args(monkeypatch):
seen = {}
def wrap(command, args):
def record(command, args):
seen["command"], seen["args"] = command, args
return command, args
_register_plugin(monkeypatch, wrap)
_register(monkeypatch, _FakeEntryPoint("isaac", _launcher_cls(record)))
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
resolve_claude_launch("claude", ["--x"])
assert seen == {"command": "claude", "args": ["--x"]}
@pytest.mark.parametrize("spec", ["nocolon", ":nomod", "mod:", "", " "])
def test_malformed_spec_falls_back(monkeypatch, spec):
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, spec)
def test_unknown_name_falls_back(monkeypatch):
_register(
monkeypatch,
_FakeEntryPoint("isaac", _launcher_cls(lambda command, args: ("isaac", []))),
)
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "nonexistent")
assert resolve_claude_launch("claude", ["--x"]) == ("claude", ["--x"])
def test_import_error_falls_back(monkeypatch):
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "no_such_module_xyz:launch")
def test_enumerate_error_falls_back(monkeypatch):
_register(monkeypatch, raise_on_enumerate=RuntimeError("boom"))
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
assert resolve_claude_launch("claude", ["--x"]) == ("claude", ["--x"])
def test_missing_attr_falls_back(monkeypatch):
mod = types.ModuleType("fake_launcher_mod2")
monkeypatch.setitem(sys.modules, "fake_launcher_mod2", mod)
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "fake_launcher_mod2:missing")
def test_load_error_falls_back(monkeypatch):
_register(monkeypatch, _FakeEntryPoint("isaac", ImportError("missing dep")))
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
assert resolve_claude_launch("claude", ["--x"]) == ("claude", ["--x"])
def test_not_callable_falls_back(monkeypatch):
_register_plugin(monkeypatch, "not-callable")
def test_instantiate_error_falls_back(monkeypatch):
class _Bad(ClaudeLauncher):
def __init__(self):
raise RuntimeError("ctor boom")
def launch(self, command, args):
return "isaac", ["--", *args]
_register(monkeypatch, _FakeEntryPoint("isaac", _Bad))
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
assert resolve_claude_launch("claude", ["--x"]) == ("claude", ["--x"])
def test_not_a_claude_launcher_falls_back(monkeypatch):
# A class that does NOT implement ClaudeLauncher must be rejected.
_register(monkeypatch, _FakeEntryPoint("isaac", object))
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
assert resolve_claude_launch("claude", ["--x"]) == ("claude", ["--x"])
@@ -80,7 +141,8 @@ def test_plugin_raises_falls_back(monkeypatch):
def boom(command, args):
raise RuntimeError("boom")
_register_plugin(monkeypatch, boom)
_register(monkeypatch, _FakeEntryPoint("isaac", _launcher_cls(boom)))
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
assert resolve_claude_launch("claude", ["--x"]) == ("claude", ["--x"])
@@ -96,5 +158,6 @@ def test_plugin_raises_falls_back(monkeypatch):
],
)
def test_malformed_return_falls_back(monkeypatch, bad):
_register_plugin(monkeypatch, lambda command, args: bad)
_register(monkeypatch, _FakeEntryPoint("isaac", _launcher_cls(lambda command, args: bad)))
monkeypatch.setenv(CLAUDE_LAUNCHER_ENV_VAR, "isaac")
assert resolve_claude_launch("claude", ["--x"]) == ("claude", ["--x"])
+11 -6
View File
@@ -4,11 +4,10 @@ from __future__ import annotations
import asyncio
import contextlib
import importlib.metadata
import io
import json
import os
import sys
import types
from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace
@@ -134,10 +133,16 @@ def test_claude_terminal_request_launcher_plugin_wraps(tmp_path, monkeypatch) ->
(``--mcp-config`` / ``--settings``) survives intact in the passed-through
argv.
"""
launcher_mod = types.ModuleType("fake_isaac_launcher")
launcher_mod.launch = lambda command, args: ("isaac", ["--", *args])
monkeypatch.setitem(sys.modules, "fake_isaac_launcher", launcher_mod)
monkeypatch.setenv("OMNIGENT_CLAUDE_LAUNCHER", "fake_isaac_launcher:launch")
from omnigent.claude_launcher import ClaudeLauncher
class _IsaacLauncher(ClaudeLauncher):
def launch(self, command, args):
return "isaac", ["--", *args]
entry_point = SimpleNamespace(name="isaac", load=lambda: _IsaacLauncher)
monkeypatch.setattr(importlib.metadata, "entry_points", lambda *, group: [entry_point])
monkeypatch.setenv("OMNIGENT_CLAUDE_LAUNCHER", "isaac")
monkeypatch.chdir(tmp_path)
body = claude_native._claude_terminal_request(
("--resume", "s"),