From c2ef73f325b67c391cbbc1c5fe7fefa64f311114 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 3 Jun 2026 21:13:20 +0000 Subject: [PATCH] Release v0.10.0 --- RELEASE_NOTES.md | 2 +- dispatch_cli/commands/agent.py | 240 +++++++++++++++++++++++++++++ dispatch_cli/mcp/operator/tools.py | 170 ++++++++++++++++++-- pyproject.toml | 2 +- tests/test_clone.py | 102 ++++++++++++ tests/test_mcp_operator.py | 157 +++++++++++++++++++ uv.lock | 2 +- 7 files changed, 662 insertions(+), 13 deletions(-) create mode 100644 tests/test_clone.py diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 29073e3..e136f38 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1 +1 @@ -Add improvements/clarity to user facing CLI output. \ No newline at end of file +New command: dispatch agent clone {agent_name} --namespace {namespace} to download agent source code. Dispatch agent deploy now required --overwrite flag to be passed before deploying an agent that would overwrite an agent created by another user. \ No newline at end of file diff --git a/dispatch_cli/commands/agent.py b/dispatch_cli/commands/agent.py index 3dc2ee7..67c0223 100644 --- a/dispatch_cli/commands/agent.py +++ b/dispatch_cli/commands/agent.py @@ -35,6 +35,7 @@ from watchfiles import PythonFilter, watch from dispatch_cli.auth import get_auth_headers, handle_auth_error +from dispatch_cli.auth_provider import default_credential_provider from dispatch_cli.commands.router import ( get_active_router, start_router_background, @@ -508,6 +509,76 @@ def build_namespaced_url(endpoint: str, namespace: str) -> str: raise ValueError(f"Unmapped endpoint prefix: {endpoint!r}") +def _current_user_email() -> str | None: + """Best-effort current user email for the local ownership preflight. + + Returns ``None`` when identity can't be determined client-side (e.g. + API-key auth), in which case the caller defers the ownership decision + to the backend, which enforces it authoritatively. + """ + try: + credential = default_credential_provider().resolve() + except Exception: + return None + return credential.user_email + + +def _confirm_overwrite_if_exists(*, agent_name: str, namespace: str) -> None: + """Block a deploy from overwriting an agent owned by someone else. + + Calls ``GET /agents/{name}``: 404 means new agent (proceed). If the agent + exists and the current user created it, the deploy proceeds without a flag. + If it's owned by a different user, exit non-zero and tell them to pass + ``--overwrite``. When the current identity can't be resolved locally (e.g. + API-key auth), defer to the backend, which enforces ownership too. Any + non-200/404 status is treated as transient and the deploy continues so we + don't gate releases on a flaky existence probe. + """ + logger = get_logger() + try: + response = requests.get( + build_namespaced_url(f"/agents/{agent_name}", namespace), + headers=get_auth_headers(), + timeout=15, + ) + except requests.exceptions.RequestException as exc: + logger.warning( + f"Could not verify whether agent '{agent_name}' already exists " + f"({exc}). Proceeding with deploy." + ) + return + + if response.status_code == 404: + return + if response.status_code != 200: + logger.warning( + f"Unexpected status {response.status_code} checking for existing " + f"agent '{agent_name}'. Proceeding with deploy." + ) + return + + # Agent exists. Owners may overwrite their own agent without a flag. + owner = (response.json().get("metadata") or {}).get("created_by") + current_user = _current_user_email() + if current_user is None: + # Can't determine identity locally (e.g. API key). Let the backend + # decide — it returns 403 if this isn't the owner and --overwrite + # wasn't passed. + return + if owner == current_user: + return + + logger.error( + f"Agent '{agent_name}' already exists in namespace '{namespace}' and " + f"is owned by {owner or 'another user'}. Pass --overwrite to overwrite it." + ) + logger.info( + "To deploy this as a separate agent instead, change `agent_name` " + "or `namespace` in dispatch.yaml and deploy again." + ) + raise typer.Exit(1) + + def uv_is_installed() -> bool: """Check if 'uv' CLI is installed.""" @@ -1905,6 +1976,17 @@ def deploy( ), ), ] = False, + overwrite: Annotated[ + bool, + typer.Option( + "--overwrite", + help=( + "Overwrite an existing agent of the same name that was " + "created by a different user. You can always overwrite your " + "own agents without this flag." + ), + ), + ] = False, no_wait: Annotated[ bool, typer.Option( @@ -1976,6 +2058,14 @@ def deploy( raise typer.Exit(1) agent_name = get_agent_name_from_project(abs_path, config) + # Ownership check: block overwriting an agent owned by another user + # unless --overwrite is passed. Owners may overwrite their own agents + # freely. --force skips this local probe (the established "I know what + # I'm doing" escape hatch), but only --overwrite is propagated to the + # server, so the backend ownership gate still applies to a --force deploy. + if not (overwrite or force): + _confirm_overwrite_if_exists(agent_name=agent_name, namespace=namespace) + # Check SDK version (every deploy) detected_sdk_version = get_sdk_version_from_agent(abs_path) if detected_sdk_version: @@ -2139,6 +2229,7 @@ def deploy( "agent_name": agent_name, "namespace": namespace, "force": "true" if allow_egress_drop else "false", + "overwrite": "true" if overwrite else "false", }, headers=auth_headers, timeout=600, @@ -2147,6 +2238,21 @@ def deploy( except requests.exceptions.HTTPError as e: if e.response.status_code == 401: # Unauthorized handle_auth_error("Invalid or expired API key") + if e.response.status_code == 403: # Owned by another user + # Surface the backend detail verbatim — it names the owner and + # tells the user to pass --overwrite. + try: + detail = e.response.json().get("detail") + except ValueError: + detail = None + logger.error( + detail + or ( + f"Agent '{agent_name}' is owned by another user. " + "Pass --overwrite to overwrite it." + ) + ) + raise typer.Exit(1) if e.response.status_code == 409: logger.error( f"A deployment is already in progress for agent '{agent_name}'. " @@ -2260,6 +2366,140 @@ def deploy( raise typer.Exit(1) +def _strip_agent_prefix_filter(member: tarfile.TarInfo, dest_path: str): + """Extraction filter that flattens the packager's ``agent/`` wrapper. + + ``create_source_package`` nests everything under a top-level ``agent/`` + directory, so a naive extract produces ``/agent/``. This + filter first runs the stdlib ``data`` safety filter (path-traversal / + device-file protection) and then strips the leading ``agent/`` segment + so a clone lands as ``/``. Bare directory entries for the + root and ``agent`` itself are dropped (return ``None``). + """ + safe = tarfile.data_filter(member, dest_path) + if safe is None: + return None + name = safe.name + while name.startswith("./"): + name = name[2:] + if name in ("", "agent"): + return None + if name.startswith("agent/"): + name = name[len("agent/") :] + if not name: + return None + safe.name = name + return safe + + +@agent_app.command("clone") +def clone( + agent_name: Annotated[ + str, + typer.Argument(help="Name of the agent to clone from the remote server."), + ], + namespace: Annotated[ + str | None, + typer.Option( + help="Namespace the agent lives in.", + envvar="DISPATCH_NAMESPACE", + ), + ] = None, + path: Annotated[ + str | None, + typer.Option( + "--path", + help="Destination directory. Defaults to .//.", + ), + ] = None, + force: Annotated[ + bool, + typer.Option( + "--force", + help="Extract into the destination directory even if it already exists and is non-empty.", + ), + ] = False, +): + """Download an agent's source bundle from the remote server.""" + logger = get_logger() + + if not namespace: + logger.error( + "Namespace is required. Pass --namespace or set DISPATCH_NAMESPACE." + ) + raise typer.Exit(1) + + dest_dir = Path(path) if path else Path.cwd() / agent_name + if dest_dir.exists() and any(dest_dir.iterdir()) and not force: + logger.error( + f"Destination '{dest_dir}' already exists and is non-empty. " + "Pass --force to extract into it anyway." + ) + raise typer.Exit(1) + + url = build_namespaced_url(f"/agents/{agent_name}/source", namespace) + logger.info( + f"Downloading source for agent '{agent_name}' from namespace '{namespace}'..." + ) + + try: + response = requests.get( + url, headers=get_auth_headers(), stream=True, timeout=120 + ) + response.raise_for_status() + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + detail = "" + if exc.response is not None: + try: + detail = exc.response.json().get("detail", "") + except Exception: + detail = exc.response.text + if status in (401, 403): + handle_auth_error("Invalid or expired credential") # exits + logger.error(f"Failed to download agent source (HTTP {status}): {detail}") + raise typer.Exit(1) + except requests.exceptions.RequestException as exc: + logger.error(f"Failed to download agent source: {exc}") + raise typer.Exit(1) + + dest_dir.mkdir(parents=True, exist_ok=True) + try: + with tarfile.open(fileobj=BytesIO(response.content), mode="r:gz") as tar: + tar.extractall(path=dest_dir, filter=_strip_agent_prefix_filter) + except tarfile.TarError as exc: + logger.error(f"Failed to extract agent source: {exc}") + raise typer.Exit(1) + + # The packager always creates a `dependencies/` directory even when the + # agent bundles no local/git deps, leaving an empty folder in the clone. + # Drop it so a fresh clone isn't littered with an empty directory; it's + # regenerated on the next deploy if needed. + deps_dir = dest_dir / "dependencies" + if deps_dir.is_dir() and not any(deps_dir.iterdir()): + deps_dir.rmdir() + + # Overlay the authoritative dispatch.yaml. Fork and config edits update + # the dispatch.yaml stored alongside the archive without rewriting the + # archive's embedded copy, so the extracted one can be stale. A 204 means + # there's no metadata file (legacy agent) — keep the embedded copy. + config_url = build_namespaced_url(f"/agents/{agent_name}/source/config", namespace) + try: + config_resp = requests.get(config_url, headers=get_auth_headers(), timeout=30) + config_resp.raise_for_status() + except requests.exceptions.RequestException as exc: + logger.warning( + f"Could not fetch the current dispatch.yaml ({exc}); the cloned " + "copy may be out of date." + ) + else: + if config_resp.status_code != 204 and config_resp.content: + (dest_dir / "dispatch.yaml").write_bytes(config_resp.content) + logger.debug("Overlaid current dispatch.yaml onto the clone.") + + logger.success(f"Cloned agent '{agent_name}' into {dest_dir}") + + @agent_app.command("unregister") def unregister_agent(): """Unregister current agent project from the local registry.""" diff --git a/dispatch_cli/mcp/operator/tools.py b/dispatch_cli/mcp/operator/tools.py index 7b0755f..e8630fd 100644 --- a/dispatch_cli/mcp/operator/tools.py +++ b/dispatch_cli/mcp/operator/tools.py @@ -77,6 +77,42 @@ class DeployAgentRequest(BaseModel): """Request payload for deploying an agent.""" agent_directory: str = Field(description="Path to the agent directory") + overwrite: bool = Field( + default=False, + description=( + "Set true to overwrite an existing agent that was created by a " + "different user. You can always overwrite your own agents without " + "this flag. When false (default), deploying over an agent owned by " + "someone else is blocked so a downloaded/cloned agent isn't " + "clobbered by accident. To deploy as a NEW agent instead, change " + "`agent_name` in the directory's dispatch.yaml before deploying." + ), + ) + + +class DownloadAgentSourceRequest(BaseModel): + """Request payload for downloading an agent's source.""" + + agent_name: str = Field(description="Name of the agent to download") + destination_directory: str | None = Field( + default=None, + description=( + "Directory to extract the source into. Defaults to " + ".// relative to the current working directory." + ), + ) + namespace: str | None = Field( + default=None, + description=( + "Namespace the agent lives in. Required — pass it explicitly or " + "rely on the server's configured default. Use list_namespaces to " + "discover valid namespaces." + ), + ) + force: bool = Field( + default=False, + description="Extract even if the destination directory already exists and is non-empty.", + ) class GetDeployStatusRequest(BaseModel): @@ -352,6 +388,16 @@ class DeployAgentResponse(BaseModel): ) +class DownloadAgentSourceResponse(BaseModel): + """Response from downloading an agent's source.""" + + agent_name: str = Field(description="Name of the downloaded agent") + destination_directory: str = Field( + description="Absolute path the source was extracted into" + ) + message: str = Field(description="Human-readable result message") + + class DeployStageInfo(BaseModel): """Information about a deployment stage.""" @@ -972,12 +1018,23 @@ async def deploy_agent( ) -> DeployAgentResponse: """Deploy an agent from directory (auto-discovers namespace from dispatch.yaml). + If an agent with the same name already exists and was created by a + different user, the deploy is blocked unless `overwrite=true` — this + stops a downloaded/cloned agent from accidentally overwriting someone + else's original. You can always overwrite your own agents. Two ways to + proceed when blocked: + - To OVERWRITE another user's existing agent, set `overwrite=true`. + - To deploy as a NEW, separate agent, change `agent_name` in the + directory's dispatch.yaml first, then deploy. + Args: - request: DeployAgentRequest with agent_directory + request: DeployAgentRequest with agent_directory and optional overwrite ctx: MCP context for logging Returns: - DeployAgentResponse with agent_name, status, and deployment message + DeployAgentResponse with agent_name, status, and deployment message. + status is "blocked" (and no job_id) when the agent is owned by + another user and overwrite was not set. """ # Convert to absolute path for consistency abs_agent_dir = os.path.abspath(request.agent_directory) @@ -988,15 +1045,17 @@ async def deploy_agent( await ctx.info(f"Starting deployment of agent '{agent_name}'") - # Run dispatch agent deploy with --force --no-wait so it returns - # after uploading the image without blocking on the full deployment. - # --force avoids typer.confirm prompts (stdin is DEVNULL). + # Run dispatch agent deploy with --force --no-wait so it returns after + # uploading the image without blocking on the full deployment. --force + # suppresses typer prompts (stdin is DEVNULL) and skips pre-deploy + # validation. Ownership is enforced server-side: --overwrite is only + # passed through when the caller explicitly sets overwrite=true, so a + # deploy over another user's agent is blocked unless requested. + deploy_args = ["dispatch", "agent", "deploy", "--force", "--no-wait"] + if request.overwrite: + deploy_args.append("--overwrite") process = await asyncio.create_subprocess_exec( - "dispatch", - "agent", - "deploy", - "--force", - "--no-wait", + *deploy_args, cwd=abs_agent_dir, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, @@ -1027,6 +1086,23 @@ async def read_stderr() -> None: if process.returncode != 0: error_msg = "\n".join(stderr_lines) or "\n".join(stdout_lines) + # The ownership gate (CLI preflight or backend 403) surfaces a + # message naming the owner and telling the user to pass --overwrite. + # Return actionable guidance instead of a hard error so the caller + # can re-run with overwrite=true or rename the agent. + if "--overwrite" in error_msg: + check_ns = agent_config.get("namespace") or config.namespace + return DeployAgentResponse( + agent_name=agent_name, + status="blocked", + message=( + f"{error_msg}\n\nRe-run with overwrite=true to overwrite " + "it, or change `agent_name` in the directory's " + "dispatch.yaml to deploy it as a new agent." + ), + job_id=None, + namespace=str(check_ns) if check_ns else None, + ) await ctx.error(f"Deployment failed: {error_msg}") raise RuntimeError(f"Failed to deploy agent: {error_msg}") @@ -1055,6 +1131,80 @@ async def read_stderr() -> None: namespace=deploy_namespace, ) + @mcp.tool() + async def download_agent_source( + request: DownloadAgentSourceRequest, + ctx: Context[ServerSession, None], + ) -> DownloadAgentSourceResponse: + """Download a deployed agent's source into a local directory. + + Fetches the agent's source bundle and extracts it (flattening the + packaging wrapper) into `destination_directory`, defaulting to + .//. The current dispatch.yaml is included even if it was + edited after the last deploy (e.g. via the UI or a fork). + + A namespace is required (pass `namespace` or rely on the configured + default). After downloading, to deploy this source: + - as the SAME agent (overwriting it), call deploy_agent — add + overwrite=true if it was created by a different user; + - as a NEW agent, change `agent_name` in the downloaded + dispatch.yaml first, then call deploy_agent. + + Args: + request: DownloadAgentSourceRequest with agent_name, optional + destination_directory, namespace, and force + ctx: MCP context for logging + + Returns: + DownloadAgentSourceResponse with the destination path. + """ + ns = _get_namespace(request.namespace) + dest = ( + os.path.abspath(request.destination_directory) + if request.destination_directory + else os.path.join(os.getcwd(), request.agent_name) + ) + + await ctx.info( + f"Downloading source for agent '{request.agent_name}' into {dest}" + ) + + cmd = [ + "dispatch", + "agent", + "clone", + request.agent_name, + "--namespace", + ns, + "--path", + dest, + ] + if request.force: + cmd.append("--force") + + process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_b, stderr_b = await process.communicate() + if process.returncode != 0: + error_msg = stderr_b.decode().strip() or stdout_b.decode().strip() + await ctx.error(f"Download failed: {error_msg}") + raise RuntimeError(f"Failed to download agent source: {error_msg}") + + return DownloadAgentSourceResponse( + agent_name=request.agent_name, + destination_directory=dest, + message=( + f"Downloaded '{request.agent_name}' into {dest}. To deploy as a " + "new agent, change agent_name in dispatch.yaml first; to " + "overwrite an agent created by another user, deploy with " + "overwrite=true." + ), + ) + @mcp.tool() async def get_deploy_status( request: GetDeployStatusRequest, diff --git a/pyproject.toml b/pyproject.toml index 0c2093b..5b3ff24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dispatch-cli" -version = "0.9.13" +version = "0.10.0" description = "" authors = [ {name = "Diamond Bishop", email = "diamond.bishop@datadoghq.com"}, diff --git a/tests/test_clone.py b/tests/test_clone.py new file mode 100644 index 0000000..a243b2f --- /dev/null +++ b/tests/test_clone.py @@ -0,0 +1,102 @@ +"""Tests for `dispatch agent clone`. + +Covers the post-download transforms that make a clone usable: flattening +the packager's ``agent/`` wrapper, overlaying the authoritative +dispatch.yaml served alongside the archive, and dropping the empty +``dependencies/`` directory the packager always creates. +""" + +import io +import tarfile +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from dispatch_cli.main import app + + +def _make_source_tar_gz() -> bytes: + """Build a source bundle the way ``create_source_package`` does. + + A ``package/agent/`` tree (with an empty ``dependencies/`` directory) + added via ``tar.add(package_dir, arcname=".")`` — so members look like + ``./agent/dispatch.yaml``, exercising the ``./`` + ``agent/`` stripping. + """ + with tempfile.TemporaryDirectory() as staging: + agent_dir = Path(staging) / "agent" + (agent_dir / "pkg").mkdir(parents=True) + (agent_dir / "dependencies").mkdir() + (agent_dir / "dispatch.yaml").write_bytes( + b"agent_name: test987\nvars:\n stale: true\n" + ) + (agent_dir / "agent.py").write_bytes(b"print('hi')\n") + (agent_dir / "pkg" / "mod.py").write_bytes(b"x = 1\n") + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + tar.add(staging, arcname=".") + return buf.getvalue() + + +def _responses(tar_gz: bytes, config_body: bytes | None, config_status: int = 200): + """Build a requests.get side_effect serving the source then the config.""" + + def _side_effect(url: str, *args: object, **kwargs: object) -> MagicMock: + resp = MagicMock() + resp.raise_for_status = MagicMock() + if url.endswith("/source/config"): + resp.status_code = config_status + resp.content = config_body or b"" + else: + resp.status_code = 200 + resp.content = tar_gz + return resp + + return _side_effect + + +@patch("dispatch_cli.commands.agent.get_auth_headers", return_value={}) +@patch("dispatch_cli.commands.agent.requests.get") +def test_clone_flattens_overlays_and_drops_empty_deps( + mock_get: MagicMock, _mock_auth: MagicMock +) -> None: + fresh_config = b"agent_name: test987\nvars:\n stale: false\n" + mock_get.side_effect = _responses(_make_source_tar_gz(), fresh_config) + + with tempfile.TemporaryDirectory() as tmp: + dest = Path(tmp) / "out" + result = CliRunner().invoke( + app, + ["agent", "clone", "test987", "--namespace", "ns", "--path", str(dest)], + ) + + assert result.exit_code == 0, result.output + # agent/ wrapper is gone — files land at the destination root. + assert (dest / "agent.py").is_file() + assert (dest / "pkg" / "mod.py").is_file() + assert not (dest / "agent").exists() + # Authoritative dispatch.yaml overlaid over the stale embedded copy. + assert (dest / "dispatch.yaml").read_bytes() == fresh_config + # Empty dependencies/ dir removed. + assert not (dest / "dependencies").exists() + + +@patch("dispatch_cli.commands.agent.get_auth_headers", return_value={}) +@patch("dispatch_cli.commands.agent.requests.get") +def test_clone_keeps_embedded_config_on_204( + mock_get: MagicMock, _mock_auth: MagicMock +) -> None: + # 204 => no metadata file alongside the archive; keep the embedded copy. + mock_get.side_effect = _responses(_make_source_tar_gz(), None, config_status=204) + + with tempfile.TemporaryDirectory() as tmp: + dest = Path(tmp) / "out" + result = CliRunner().invoke( + app, + ["agent", "clone", "test987", "--namespace", "ns", "--path", str(dest)], + ) + + assert result.exit_code == 0, result.output + assert b"stale: true" in (dest / "dispatch.yaml").read_bytes() diff --git a/tests/test_mcp_operator.py b/tests/test_mcp_operator.py index d3dbbe7..4657b2a 100644 --- a/tests/test_mcp_operator.py +++ b/tests/test_mcp_operator.py @@ -475,3 +475,160 @@ async def test_cleanup_handles_dead_processes(self): # Verify PID file was still cleaned up assert not pid_file.exists() + + +def _make_operator(client: OperatorBackendClient): + """Build an operator MCP server backed by the given fake client.""" + from dispatch_cli.mcp.config import MCPConfig + from dispatch_cli.mcp.operator.tools import create_operator_mcp + + config = MCPConfig( + credential_provider=StaticCredentialProvider( + ResolvedCredential(auth_mode="api_key", access_token="test-key") + ), + namespace="test-ns", + ) + return create_operator_mcp(client, config) + + +def _write_agent_dir(tmp: str, agent_name: str) -> str: + with open(os.path.join(tmp, "dispatch.yaml"), "w") as f: + f.write(f"agent_name: {agent_name}\nnamespace: test-ns\n") + return tmp + + +class _FakeStream: + """Async-iterable stand-in for a subprocess stdout/stderr stream.""" + + def __init__(self, lines: list[bytes]): + self._lines = lines + + def __aiter__(self): + async def gen(): + for line in self._lines: + yield line + + return gen() + + +class _FakeProcess: + def __init__(self, stdout_lines: list[bytes]): + self.stdout = _FakeStream(stdout_lines) + self.stderr = _FakeStream([]) + self.returncode = 0 + + async def wait(self) -> int: + return self.returncode + + +class _FakeCtx: + """Minimal MCP Context — the tool only calls these logging coroutines.""" + + async def info(self, *args, **kwargs) -> None: + pass + + async def debug(self, *args, **kwargs) -> None: + pass + + async def error(self, *args, **kwargs) -> None: + pass + + +def _tool_fn(mcp, name: str): + """Return a tool's underlying function so it can be called with a fake ctx. + + Going through mcp.call_tool would require an active request context (for + ctx logging); calling the function directly sidesteps that. + """ + return mcp._tool_manager.get_tool(name).fn + + +@pytest.mark.unit +class TestDeployAgentGuard: + """The deploy_agent tool must not silently overwrite another user's agent. + + Ownership is enforced server-side (and pre-checked by the CLI), so the tool + just shells out and surfaces the resulting block as a "blocked" status. + """ + + @pytest.mark.asyncio + async def test_blocks_agent_owned_by_another_user(self): + from dispatch_cli.mcp.operator.tools import DeployAgentRequest + + deploy = _tool_fn(_make_operator(FakeOperatorBackendClient()), "deploy_agent") + # The CLI exits non-zero with a message naming the owner and pointing + # at --overwrite when the agent belongs to someone else. + fake_proc = _FakeProcess([]) + fake_proc.stderr = _FakeStream( + [ + b"Agent 'existing-agent' already exists in namespace 'test-ns' " + b"and is owned by alice@example.com. Pass --overwrite to " + b"overwrite it.\n" + ] + ) + fake_proc.returncode = 1 + + async def fake_exec(*args, **kwargs): + return fake_proc + + with tempfile.TemporaryDirectory() as tmp: + _write_agent_dir(tmp, "existing-agent") + with patch("asyncio.create_subprocess_exec", side_effect=fake_exec): + result = await deploy( + DeployAgentRequest(agent_directory=tmp), _FakeCtx() + ) + + assert result.status == "blocked" + assert result.job_id is None + assert "alice@example.com" in result.message + assert "overwrite=true" in result.message + + @pytest.mark.asyncio + async def test_overwrite_true_passes_flag_to_cli(self): + from dispatch_cli.mcp.operator.tools import DeployAgentRequest + + deploy = _tool_fn(_make_operator(FakeOperatorBackendClient()), "deploy_agent") + fake_proc = _FakeProcess( + [b"DEPLOY_JOB_ID=job-123\n", b"DEPLOY_NAMESPACE=test-ns\n"] + ) + captured_args: list = [] + + async def fake_exec(*args, **kwargs): + captured_args.extend(args) + return fake_proc + + with tempfile.TemporaryDirectory() as tmp: + _write_agent_dir(tmp, "existing-agent") + with patch("asyncio.create_subprocess_exec", side_effect=fake_exec): + result = await deploy( + DeployAgentRequest(agent_directory=tmp, overwrite=True), _FakeCtx() + ) + + assert "--overwrite" in captured_args + assert result.status == "submitted" + assert result.job_id == "job-123" + + @pytest.mark.asyncio + async def test_owner_deploys_without_overwrite_flag(self): + from dispatch_cli.mcp.operator.tools import DeployAgentRequest + + deploy = _tool_fn(_make_operator(FakeOperatorBackendClient()), "deploy_agent") + fake_proc = _FakeProcess( + [b"DEPLOY_JOB_ID=job-9\n", b"DEPLOY_NAMESPACE=test-ns\n"] + ) + captured_args: list = [] + + async def fake_exec(*args, **kwargs): + captured_args.extend(args) + return fake_proc + + with tempfile.TemporaryDirectory() as tmp: + _write_agent_dir(tmp, "brand-new-agent") + with patch("asyncio.create_subprocess_exec", side_effect=fake_exec): + result = await deploy( + DeployAgentRequest(agent_directory=tmp), _FakeCtx() + ) + + assert "--overwrite" not in captured_args + assert result.status == "submitted" + assert result.job_id == "job-9" diff --git a/uv.lock b/uv.lock index 2b6f7db..3f9e9bb 100644 --- a/uv.lock +++ b/uv.lock @@ -565,7 +565,7 @@ wheels = [ [[package]] name = "dispatch-cli" -version = "0.9.13" +version = "0.10.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },