diff --git a/pyproject.toml b/pyproject.toml index d09d65d89..259d8082d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,6 +212,7 @@ dev-crewai = [ [project.scripts] band-acp = "band.integrations.acp.cli:entry_point" +band-register-agent = "band.cli.register_agent:main" band-trigger = "band.cli.trigger:main" [tool.setuptools.packages.find] @@ -225,6 +226,7 @@ include-package-data = true [tool.setuptools.package-data] "band.integrations.slack" = ["templates/*.yaml"] +"band.cli" = ["*.sh"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/band/cli/register-agent.sh b/src/band/cli/register-agent.sh new file mode 100644 index 000000000..d43be3783 --- /dev/null +++ b/src/band/cli/register-agent.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Register a Band external agent from a user API key, using only curl + a JSON +# parser. A portable, dependency-light alternative to the Hermes plugin's +# hermes_band_platform/skills/add-band/scripts/register_agent.py: no Python SDK, +# no hermes_cli, no cloned repo — so a bootstrap can mint a Band agent before it +# installs anything (the same shape openclaw/bootstrap.sh uses). +# +# Security: the user key is read from $BAND_USER_API_KEY (never an argument) and +# handed to curl through a --config heredoc on stdin, so it never appears in any +# process's argv (`ps`). Only the returned agent-scoped id + key are printed; the +# user key is never echoed. +# +# Output (stdout — capture- / eval-able): +# BAND_AGENT_ID= +# BAND_API_KEY= +# +# Usage (bundled with the SDK as the `band-register-agent` command): +# export BAND_USER_API_KEY=... # required +# eval "$(band-register-agent)" # sets BAND_AGENT_ID + BAND_API_KEY +# +# Env knobs: BAND_BASE_URL (default https://app.band.ai), +# BAND_AGENT_NAME, BAND_AGENT_DESCRIPTION. +set -euo pipefail + +base="${BAND_BASE_URL:-https://app.band.ai}"; base="${base%/}" +name="${BAND_AGENT_NAME:-Band agent}" +desc="${BAND_AGENT_DESCRIPTION:-Agent on Band}" +: "${BAND_USER_API_KEY:?set BAND_USER_API_KEY to a Band user API key with agent-create scope}" + +req_body=$(printf '{"agent":{"name":"%s","description":"%s"}}' "$name" "$desc") + +# Only the secret X-API-Key header goes through stdin (-K -), never argv. +resp=$(curl -sS -X POST "$base/api/v1/me/agents/register" \ + -H "Content-Type: application/json" -d "$req_body" -w $'\n%{http_code}' -K - <&2; exit 1 ;; +esac + +# Pull the agent id + key from the response shapes Band may return. +if command -v jq >/dev/null 2>&1; then + id=$(printf '%s' "$out" | jq -r '.data.agent.id // .agent.id // .data.id // .agent_id // .id // empty') + key=$(printf '%s' "$out" | jq -r '.data.credentials.api_key // .credentials.api_key // .data.api_key // .api_key // .key // .token // empty') +elif command -v python3 >/dev/null 2>&1; then + read -r id key < <(printf '%s' "$out" | python3 -c ' +import sys, json +d = json.load(sys.stdin) +def g(*path): + cur = d + for k in path: + if not isinstance(cur, dict): + return None + cur = cur.get(k) + return cur +i = g("data","agent","id") or g("agent","id") or g("data","id") or d.get("agent_id") or d.get("id") or "" +k = g("data","credentials","api_key") or g("credentials","api_key") or g("data","api_key") or d.get("api_key") or d.get("key") or d.get("token") or "" +print(str(i).strip(), str(k).strip()) +') +else + echo "band: need jq or python3 to parse the registration response" >&2 + exit 1 +fi + +[ -n "${id:-}" ] && [ -n "${key:-}" ] || { + echo "band: registration response missing agent id/key" >&2 + exit 1 +} + +printf 'BAND_AGENT_ID=%s\nBAND_API_KEY=%s\n' "$id" "$key" diff --git a/src/band/cli/register_agent.py b/src/band/cli/register_agent.py new file mode 100644 index 000000000..3fd81c3ca --- /dev/null +++ b/src/band/cli/register_agent.py @@ -0,0 +1,103 @@ +""" +Band agent registration — bundled bootstrap wrapper. + +Thin wrapper around the bundled ``register-agent.sh`` shell script, exposed as +the ``band-register-agent`` console entry point. Any project that installs the +SDK can register a Band external agent from a user API key without vendoring the +script — sharing collapses to installing the SDK and running one command. + +The shell script is bundled verbatim and prints eval-able output to stdout:: + + BAND_AGENT_ID= + BAND_API_KEY= + +Usage:: + + # Preferred — key from the environment, kept out of the process list: + export BAND_USER_API_KEY=... + eval "$(band-register-agent)" + + # Convenience — pass the user key as an optional positional argument: + eval "$(band-register-agent )" + +When the key is passed as an argument it is exported into the script's +environment as ``BAND_USER_API_KEY``. Note this makes the key visible in the +process list (``ps``); prefer the environment variable on shared machines. + +Other knobs are read by the bundled script from the environment: ``BAND_BASE_URL``, +``BAND_AGENT_NAME``, ``BAND_AGENT_DESCRIPTION``. + +Exit code mirrors the shell script (0 success, non-zero failure); 127 if ``bash`` +is not available. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import subprocess +import sys +from importlib.resources import as_file, files + +logger = logging.getLogger(__name__) + +SCRIPT_NAME = "register-agent.sh" + + +def build_parser() -> argparse.ArgumentParser: + """Build the CLI argument parser.""" + parser = argparse.ArgumentParser( + prog="band-register-agent", + description=( + "Register a Band external agent from a user API key " + "(wraps the bundled register-agent.sh)." + ), + epilog=( + "Output is eval-able: BAND_AGENT_ID=... and BAND_API_KEY=... are " + 'printed to stdout, e.g. eval "$(band-register-agent)".' + ), + ) + parser.add_argument( + "api_key", + nargs="?", + help=( + "Band user API key with agent-create scope. If omitted, the script " + "reads BAND_USER_API_KEY from the environment (preferred). Passing " + "the key here exposes it in the process list (ps); use the env var " + "on shared machines." + ), + ) + return parser + + +def main() -> None: + """CLI entry point: run the bundled registration script.""" + args = build_parser().parse_args() + + env = os.environ.copy() + if args.api_key: + env["BAND_USER_API_KEY"] = args.api_key + + script = files("band.cli").joinpath(SCRIPT_NAME) + try: + with as_file(script) as script_path: + logger.debug("Running bundled registration script: %s", script_path) + completed = subprocess.run( + ["bash", str(script_path)], + env=env, + check=False, + ) + except FileNotFoundError: + # bash is not on PATH. + sys.stderr.write( + "Error: 'bash' is required to run band-register-agent but was not " + "found on PATH.\n" + ) + sys.exit(127) + + sys.exit(completed.returncode) + + +if __name__ == "__main__": + main() diff --git a/tests/cli/test_register_agent.py b/tests/cli/test_register_agent.py new file mode 100644 index 000000000..13809a936 --- /dev/null +++ b/tests/cli/test_register_agent.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from importlib.resources import as_file, files +from unittest.mock import MagicMock, patch + +import pytest + +from band.cli.register_agent import SCRIPT_NAME, build_parser, main + + +# --- Parser --- + + +def test_api_key_is_optional() -> None: + """The positional API key is optional (falls back to the env var).""" + args = build_parser().parse_args([]) + assert args.api_key is None + + args = build_parser().parse_args(["user-key-123"]) + assert args.api_key == "user-key-123" + + +# --- Bundled script resolution --- + + +def test_bundled_script_is_packaged() -> None: + """register-agent.sh ships inside band.cli and is resolvable at runtime.""" + with as_file(files("band.cli").joinpath(SCRIPT_NAME)) as script_path: + assert script_path.exists() + assert script_path.name == SCRIPT_NAME + assert script_path.read_text().startswith("#!/usr/bin/env bash") + + +# --- main() wiring --- + + +def _run_main(argv: list[str], *, returncode: int = 0) -> MagicMock: + """Invoke main() with argv, mocking the subprocess call. Returns the mock.""" + completed = MagicMock(returncode=returncode) + with ( + patch("band.cli.register_agent.subprocess.run", return_value=completed) as run, + patch("sys.argv", ["band-register-agent", *argv]), + pytest.raises(SystemExit) as exc, + ): + main() + run.exit_code = exc.value.code # type: ignore[attr-defined] + return run + + +def test_runs_bash_against_bundled_script() -> None: + """main() invokes bash on the bundled script and mirrors its exit code.""" + run = _run_main([], returncode=0) + assert run.exit_code == 0 # type: ignore[attr-defined] + + cmd = run.call_args.args[0] + assert cmd[0] == "bash" + assert cmd[1].endswith(SCRIPT_NAME) + + +def test_positional_key_populates_env() -> None: + """A positional API key is exported as BAND_USER_API_KEY for the script.""" + run = _run_main(["user-key-xyz"]) + env = run.call_args.kwargs["env"] + assert env["BAND_USER_API_KEY"] == "user-key-xyz" + + +def test_no_key_leaves_env_untouched() -> None: + """Without an argument, the script reads BAND_USER_API_KEY from the inherited env.""" + with patch.dict("os.environ", {"BAND_USER_API_KEY": "from-env"}, clear=False): + run = _run_main([]) + env = run.call_args.kwargs["env"] + assert env["BAND_USER_API_KEY"] == "from-env" + + +def test_nonzero_exit_code_is_propagated() -> None: + """A failing script surfaces its exit code (so callers/CI see the failure).""" + run = _run_main([], returncode=1) + assert run.exit_code == 1 # type: ignore[attr-defined] + + +def test_missing_bash_exits_127() -> None: + """If bash is not on PATH, exit 127 with a clear message.""" + with ( + patch( + "band.cli.register_agent.subprocess.run", + side_effect=FileNotFoundError, + ), + patch("sys.argv", ["band-register-agent"]), + pytest.raises(SystemExit) as exc, + ): + main() + assert exc.value.code == 127