Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb548c5e81 | |||
| c1f1100ffe |
+96
-43
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user