From 86a412aa9d8ba306582542549f7ed0453a93799a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 14 Oct 2026 08:18:03 -0700 Subject: [PATCH 1/5] feat(bundle): an artifacts// folder can be a CLI, installed into agents like a generated one A folder whose artifact.toml says type = "cli", with command_name and entrypoint, is written as a CliArtifact over the files its walk lists, and checked before any write: the entrypoint is one of its files, command_name is a plain name, and no .env sits beside the entrypoint, where installing writes the gateway URL. CliArtifact.put takes a file listing as SkillArtifact.put does and uploads under a prefix of its own per attempt, so a retry after a failed upload works. The test echo agent runs a prompt's "run " lines. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/artifact/artifacts/cli.py | 81 +++++++++--- src/agent_env/bundle/authoring.py | 7 + src/agent_env/bundle/plan.py | 3 +- tst/data/a2a_agent/agent.py | 17 +++ .../cli/run_bundle_cli_folder_test.py | 120 ++++++++++++++++++ tst/unit/artifact/cli_artifact_test.py | 49 +++++++ tst/unit/bundle/cli_folder_test.py | 108 ++++++++++++++++ .../store/local_backends_composition_test.py | 3 +- 8 files changed, 366 insertions(+), 22 deletions(-) create mode 100644 tst/integration/cli/run_bundle_cli_folder_test.py create mode 100644 tst/unit/artifact/cli_artifact_test.py create mode 100644 tst/unit/bundle/cli_folder_test.py diff --git a/src/agent_env/artifact/artifacts/cli.py b/src/agent_env/artifact/artifacts/cli.py index 18c2935e..e54d826c 100644 --- a/src/agent_env/artifact/artifacts/cli.py +++ b/src/agent_env/artifact/artifacts/cli.py @@ -1,24 +1,31 @@ """CliArtifact — packages a CLI tool (entrypoint + supporting files) as a versioned artifact. A CliArtifact references one FileArtifactUniverse bundling every file in the CLI -directory tree, colocated under a single object-store prefix (`artifacts/cli///`). +directory tree, colocated under one object-store prefix per write (`ArtifactStore.attempt_prefix`). The `entrypoint` field is a relative path within that prefix that consumers (e.g. an agent gateway) chmod +x and symlink onto PATH. """ from __future__ import annotations +import posixpath +import re from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Mapping, Optional from pydantic import ConfigDict, Field, model_serializer from agent_env.artifact.artifact import Artifact, _write_twin -from agent_env.config import get_config -from agent_env.store.ids import derive_id, key_segment +from agent_env.store.ids import derive_id if TYPE_CHECKING: from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse + from agent_env.bundle.authoring import AuthoringContext + +# A command a bundle's CLI is installed as, at /opt/cli//. +_COMMAND_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*") +# Where installing a CLI writes the gateway URL, beside its entrypoint. +_GATEWAY_ENV_FILE = ".env" class CliArtifact(Artifact): @@ -34,6 +41,8 @@ class CliArtifact(Artifact): env_id: Optional[str] = Field(default=None, description="Source env id this CLI was generated from, if any") env_version: Optional[int] = Field(default=None, description="Source env version this CLI was generated from, if any") + toml_keys: ClassVar[dict[str, type]] = {"command_name": str, "entrypoint": str} # what an artifact.toml sets + # No return annotation: pydantic builds the serialization schema from one, and a dict drops the fields. @model_serializer(mode="wrap") def _serialize(self, handler: Any): @@ -49,32 +58,32 @@ def put( *, command_name: str, entrypoint: str, - cli_dir: Path, + cli_dir: Optional[Path] = None, + files: Optional[Mapping[str, Path]] = None, env_id: Optional[str] = None, env_version: Optional[int] = None, ) -> "CliArtifact": + """Write the CLI in ``cli_dir``, or the ``files`` given by their paths in the CLI, ``entrypoint`` among them.""" from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse from agent_env.artifact.store import get_artifact_store - if not cli_dir.is_dir(): - raise ValueError(f"cli_dir {cli_dir} is not a directory") - entrypoint_path = cli_dir / entrypoint - if not entrypoint_path.is_file(): - raise ValueError(f"entrypoint {entrypoint!r} not found at {entrypoint_path}") - - files: dict[str, Path] = {} - for p in sorted(cli_dir.rglob("*")): - if p.is_file(): - files[p.relative_to(cli_dir).as_posix()] = p - if not files: - raise ValueError(f"cli_dir {cli_dir} contains no files") + if (cli_dir is None) == (files is None): + raise ValueError("specify exactly one of cli_dir or files") + if cli_dir is not None: + if not cli_dir.is_dir(): + raise ValueError(f"cli_dir {cli_dir} is not a directory") + if not (cli_dir / entrypoint).is_file(): + raise ValueError(f"entrypoint {entrypoint!r} not found at {cli_dir / entrypoint}") + files = {p.relative_to(cli_dir).as_posix(): p for p in sorted(cli_dir.rglob("*")) if p.is_file()} + if entrypoint not in files: + raise ValueError(f"entrypoint {entrypoint!r} isn't among the CLI's files") store = get_artifact_store() version = store.next_version(id) - key = f"{get_config().get_artifact_key_prefix()}artifacts/cli/{key_segment(id)}/{version}/" - cli_object_url = get_config().get_object_store_for(id).object_url(key) + cli_object_url = store.attempt_prefix(cls.model_fields["type"].default, id) - universe = FileArtifactUniverse.put_bundled(id=derive_id(id, "files"), files=files, prefix_url=cli_object_url) + universe = FileArtifactUniverse.put_bundled(id=derive_id(id, "files"), files=dict(files), + prefix_url=cli_object_url) instance = cls( id=id, @@ -88,6 +97,38 @@ def put( ) return store.put_document(instance) + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an artifact.toml, a CLI takes, checked against the folder's files the way installing + it reads them. Raises BundleError listing every problem.""" + fields, problems = ctx.accepted(data, **cls.toml_keys) + for key in cls.toml_keys: + if key not in data: + problems.append(ctx.config_problem(f"{key} is required")) + command_name, entrypoint = fields.get("command_name"), fields.get("entrypoint") + if command_name is not None and not _COMMAND_NAME.fullmatch(command_name): + problems.append(ctx.config_problem( + f"command_name must be a plain name, of letters, digits, '.', '_' and '-' and not starting with '.' or " + f"'-', since the CLI is installed at /opt/cli/; not {command_name!r}")) + files = ctx.try_files() + if entrypoint is not None and files is not None: + gateway_env = posixpath.join(posixpath.dirname(entrypoint), _GATEWAY_ENV_FILE) + if entrypoint not in files: + problems.append(ctx.config_problem(f"entrypoint {entrypoint!r} isn't one of this folder's files")) + elif gateway_env in files: + problems.append(f"{gateway_env}: installing the CLI writes the gateway URL to this file, beside its " + "entrypoint; rename or remove the folder's own") + if problems: + ctx.refuse(problems) + return fields + + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> "CliArtifact": + """Write the CLI in the bundle folder: every file its folder walk lists.""" + fields = cls.accept_toml(data, ctx) + return cls.put(ctx.id, command_name=fields["command_name"], entrypoint=fields["entrypoint"], + files=ctx.files()) + def get_cli_files(self) -> "FileArtifactUniverse": from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse return FileArtifactUniverse.get(self.cli_files_id) diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index 5b220790..dba27711 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -62,6 +62,13 @@ def dir(self) -> Path: def files(self) -> dict[str, Path]: return entry_files(self.bundle, self.entry) + def try_files(self) -> dict[str, Path] | None: + """``files``, or None when the folder can't be listed, which the plan's walk reports.""" + try: + return self.files() + except BundleError: + return None + def file(self) -> tuple[str, Path]: return entry_file(self.bundle, self.entry) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 8856191f..bf2016eb 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -18,6 +18,7 @@ from agent_env.a2a_agent import A2AAgent from agent_env.artifact import ( Artifact, + CliArtifact, EnvironmentArtifact, EnvironmentUniverseArtifact, FileArtifact, @@ -68,7 +69,7 @@ # The artifact types written from their folders, and what each one's write reads there, which the ledger hashes. _FOLDER_WALKS = {FileArtifact: one_file, FileArtifactUniverse: entry_files, EnvironmentArtifact: environment_files, - EnvironmentUniverseArtifact: universe_files, SkillArtifact: entry_files} + EnvironmentUniverseArtifact: universe_files, SkillArtifact: entry_files, CliArtifact: entry_files} # The env types whose from_toml reads only the toml and what it names, so a type built by one of them is written from # its env.toml and the ledger can list what it's made from. _ENV_FROM_TOMLS = (Env, MCPServerEnv, WebsiteEnv, MultiEnv) diff --git a/tst/data/a2a_agent/agent.py b/tst/data/a2a_agent/agent.py index dffa6dd5..05cb985e 100644 --- a/tst/data/a2a_agent/agent.py +++ b/tst/data/a2a_agent/agent.py @@ -12,6 +12,7 @@ It installs skills under ``~/skills//``, a bundle's files downloaded through their read grants, and a ``list-skills`` line in its prompt adds a ``skill : =, ...`` line for each one. +A ``run `` line has it run that command in its container and add a ``ran : `` line. """ import asyncio @@ -47,6 +48,8 @@ SEND_FILE = "send-file " LIST_SKILLS = "list-skills" +RUN = "run " +RUN_TIMEOUT_SECONDS = 60 SKILLS_DIR = Path.home() / "skills" LIVE_STEPS = re.compile(r"live-steps (\d+)") LIVE_STEP_SECONDS = 1 @@ -79,6 +82,18 @@ async def _read(part: FilePart) -> str: return f"read {part.name} over {scheme}: {data.decode(errors='replace').strip()}" +async def _ran(command: str) -> str: + """``command``'s output, stdout and stderr together, run by the shell.""" + process = await asyncio.create_subprocess_shell(command, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT) + try: + output, _ = await asyncio.wait_for(process.communicate(), RUN_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + process.kill() + return f"ran {command}: timed out after {RUN_TIMEOUT_SECONDS}s" + return f"ran {command}: {output.decode(errors='replace').strip()}" + + def _skills() -> list[str]: """One line per installed skill: each file's path and the first 12 hex digits of its sha256.""" if not SKILLS_DIR.is_dir(): @@ -109,6 +124,8 @@ async def run(self, request: TaskRequest[EchoAgentConfig]) -> TaskResult: reply = "\n".join([reply, *[await _read(part) for part in request.parts if isinstance(part, FilePart)]]) if LIST_SKILLS in prompt.splitlines(): reply = "\n".join([reply, *_skills()]) + runs = [line.removeprefix(RUN).strip() for line in prompt.splitlines() if line.startswith(RUN)] + reply = "\n".join([reply, *[await _ran(command) for command in runs]]) sends = [line.removeprefix(SEND_FILE).strip() for line in prompt.splitlines() if line.startswith(SEND_FILE)] files = [FilePart(uri=uri, name=uri.rsplit("/", 1)[-1], mime_type="text/plain") for uri in sends] request.trajectory.append({"type": "echo", "input": prompt, "output": reply}) diff --git a/tst/integration/cli/run_bundle_cli_folder_test.py b/tst/integration/cli/run_bundle_cli_folder_test.py new file mode 100644 index 00000000..b91fd798 --- /dev/null +++ b/tst/integration/cli/run_bundle_cli_folder_test.py @@ -0,0 +1,120 @@ +"""``agent-env run`` on a bundle with a CLI folder, ``artifacts//`` with ``type = "cli"``: a task deploys an MCP +server env through the gateway and the echo agent, loads the CLI into the agent and attaches it as a skill, and the +agent runs it. The CLI reads a file of its own and the gateway URL installing it wrote beside its entrypoint. An edit +to one of its files writes it again, and the next run installs the new one. Needs a Docker daemon and the local +registry.""" + +import json +import logging +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.store import reset_artifact_store +from agent_env.cli import cli +from agent_env.config import configure, reset_config +from agent_env.store.object_store.local.grant_server import grant_server +from tst.util.a2a_test_agent import AGENT_DIR, PROTOCOL_DIR + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +REPO = Path(__file__).resolve().parents[3] +ITEMS = REPO / "tst" / "data" / "agentenv_mcp" # an in-memory MCP server whose card names it items +NO_MODEL = 'LITELLM_API_KEY = "unused"\nLITELLM_BASE_URL = "http://unused.invalid"\n' +ENTRYPOINT = "/opt/cli/hello/bin/hello" +# Prints its greeting file and the scheme of the gateway URL install_cli wrote to bin/.env. +HELLO = '''#!/bin/sh +here="$(dirname "$0")" +. "$here/.env" +echo "$(cat "$here/../lib/greeting.txt") from a bundle cli, gateway ${AGENT_ENV_GATEWAY_URL%%://*}" +''' + + +@pytest.fixture +def state(monkeypatch, tmp_path): + """Local stores under this test's folder. Whatever ran, no sandbox work folder may be left behind.""" + sandboxes = tmp_path / "sandboxes" + # HOME stays: docker's credential helper (the macOS keychain, for one) can hang a build's base-image lookup + # when HOME moves. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("AGENT_ENV_DOCUMENT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) + configure() + reset_artifact_store() + logging.disable(logging.CRITICAL) # pytest's live logging would take CliRunner's stdout + try: + yield tmp_path + assert not sandboxes.exists() or not any(sandboxes.iterdir()), "a run left a sandbox work folder" + finally: + logging.disable(logging.NOTSET) + # The agent moved its trajectory through this process's grant server, which serves a certificate from this + # test's state root; a later test's agents trust another root's CA, so it must start afresh. + grant_server(None, None).close() + reset_artifact_store() + reset_config() + + +def _task(greeting): + return json.dumps([ + {"id": "env", "type": "deploy_env", "env_id": "items", "sandbox_type": "local"}, + {"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "echo", "sandbox_type": "local"}, + {"id": "load", "type": "load_artifact", "env_id": "items", "artifact_id": "hello", + "agent_name": "default-agent", "depends_on": ["env", "agent"]}, + {"id": "skill", "type": "add_skills", "cli_artifact_ids": ["hello"], "depends_on": ["load"]}, + {"id": "ask", "type": "prompt_agent", "prompt": f"run {ENTRYPOINT}\nlist-skills", "prompt_id": "ask", + "depends_on": ["skill"]}, + {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "ran", + "criteria": [{"type": "response_contains", + "needles": [f"ran {ENTRYPOINT}: {greeting} from a bundle cli, gateway http", + "skill hello-cli: SKILL.md="]}], + "depends_on": ["ask"]}, + ]) + + +def _bundle(root): + items = root / "envs/items" + shutil.copytree(ITEMS, items, ignore=shutil.ignore_patterns("__pycache__")) + shutil.copytree(PROTOCOL_DIR / "src" / "agentenv_protocol", items / "agentenv_protocol", + ignore=shutil.ignore_patterns("__pycache__")) + (items / "env.toml").write_text('environment_name = "items"\n') + echo = root / "agents/echo" + shutil.copytree(AGENT_DIR, echo, ignore=shutil.ignore_patterns("__pycache__")) + shutil.copytree(PROTOCOL_DIR, echo / "agentenv-protocol", + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "tests", "examples", "*.egg-info")) + (echo / "agent.toml").write_text(f"[default_env_vars]\n{NO_MODEL}") + hello = root / "artifacts/hello" + (hello / "bin").mkdir(parents=True) + (hello / "lib").mkdir() + (hello / "bin/hello").write_text(HELLO) + (hello / "lib/greeting.txt").write_text("hello\n") + (hello / "artifact.toml").write_text('type = "cli"\ncommand_name = "hello"\nentrypoint = "bin/hello"\n') + (root / "tasks").mkdir() + (root / "tasks/hello.json").write_text(_task("hello")) + return root + + +def _run(root): + return CliRunner().invoke(cli, ["run", str(root)]) + + +def test_a_cli_folder_is_installed_into_an_agent_that_runs_it_and_an_edit_installs_the_new_one(state): + root = _bundle(state / "cli-bundle") + + first = _run(root) + + assert first.exit_code == 0, first.output + assert "artifacts/hello: v1 (new)" in first.output, first.output + assert "tasks/hello.json v1: passed (check: 1)" in first.output, first.output + + (root / "artifacts/hello/lib/greeting.txt").write_text("bonjour\n") + (root / "tasks/hello.json").write_text(_task("bonjour")) + + edited = _run(root) + + assert edited.exit_code == 0, edited.output + assert "artifacts/hello: v2 (files changed: lib/greeting.txt)" in edited.output, edited.output + assert "tasks/hello.json v2: passed (check: 1)" in edited.output, edited.output diff --git a/tst/unit/artifact/cli_artifact_test.py b/tst/unit/artifact/cli_artifact_test.py new file mode 100644 index 00000000..13a765b4 --- /dev/null +++ b/tst/unit/artifact/cli_artifact_test.py @@ -0,0 +1,49 @@ +"""Writing a CliArtifact: from a folder or a file listing, its entrypoint among the files, retried safely.""" + +import pytest + +from agent_env.artifact.artifacts.cli import CliArtifact +from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse + + +@pytest.fixture +def cli_files(tmp_path): + (tmp_path / "bin").mkdir() + (tmp_path / "bin/hello").write_text("#!/bin/sh\necho hello\n") + (tmp_path / "lib").mkdir() + (tmp_path / "lib/util.sh").write_text("true\n") + return {"bin/hello": tmp_path / "bin/hello", "lib/util.sh": tmp_path / "lib/util.sh"} + + +def test_a_folder_and_a_listing_write_the_same_files(local_stores, cli_files, tmp_path): + from_folder = CliArtifact.put("hello-cli", command_name="hello", entrypoint="bin/hello", cli_dir=tmp_path) + from_listing = CliArtifact.put("hello-cli", command_name="hello", entrypoint="bin/hello", files=cli_files) + + assert sorted(from_folder.get_cli_files().get_file_artifacts()) == ["bin/hello", "lib/util.sh"] + assert sorted(from_listing.get_cli_files().get_file_artifacts()) == ["bin/hello", "lib/util.sh"] + assert from_folder.cli_object_url != from_listing.cli_object_url + + +@pytest.mark.parametrize("sources", [{}, {"cli_dir": "x", "files": {}}], ids=["neither", "both"]) +def test_a_put_takes_a_folder_or_a_listing(local_stores, sources): + with pytest.raises(ValueError, match="exactly one of cli_dir or files"): + CliArtifact.put("hello-cli", command_name="hello", entrypoint="bin/hello", **sources) + + +def test_a_listing_without_its_entrypoint_is_refused(local_stores, cli_files): + with pytest.raises(ValueError, match="entrypoint 'bin/hi' isn't among the CLI's files"): + CliArtifact.put("hello-cli", command_name="hello", entrypoint="bin/hi", files=cli_files) + + +def test_a_put_that_fails_after_uploading_its_files_can_be_retried(local_stores, cli_files, monkeypatch): + real_put = FileArtifactUniverse.put + + def fail_once(*args, **kwargs): + monkeypatch.setattr(FileArtifactUniverse, "put", real_put) + raise RuntimeError("interrupted") + + monkeypatch.setattr(FileArtifactUniverse, "put", fail_once) + with pytest.raises(RuntimeError, match="interrupted"): + CliArtifact.put("hello-cli", command_name="hello", entrypoint="bin/hello", files=cli_files) + + assert CliArtifact.put("hello-cli", command_name="hello", entrypoint="bin/hello", files=cli_files).version == 1 diff --git a/tst/unit/bundle/cli_folder_test.py b/tst/unit/bundle/cli_folder_test.py new file mode 100644 index 00000000..50d90541 --- /dev/null +++ b/tst/unit/bundle/cli_folder_test.py @@ -0,0 +1,108 @@ +"""CLI folders: an ``artifacts//`` folder whose artifact.toml says ``type = "cli"`` is written as a CliArtifact +over every file its walk lists, under an upload prefix of its own, rewritten when its files or toml change, and +refused before any write when it couldn't be installed as written.""" + +import json + +import pytest + +from agent_env.artifact import CliArtifact +from agent_env.bundle import BundleError +from agent_env.bundle.materialize import materialize +from agent_env.store.routing import namespace_routing +from tst.unit.bundle._support import layout, local_store, plan_of + +ROOT = "@local/~/triage" +CLI = f"{ROOT}/hello" +TOML = 'type = "cli"\ncommand_name = "hello"\nentrypoint = "bin/hello"\n' +FILES = { + "artifacts/hello/artifact.toml": TOML, + "artifacts/hello/bin/hello": "#!/bin/sh\ncat \"$(dirname \"$0\")/../lib/greeting.txt\"\n", + "artifacts/hello/lib/greeting.txt": "hello\n", + "tasks/t.json": json.dumps([{"id": "skills", "type": "add_skills", "cli_artifact_ids": ["hello"]}]), +} + + +@pytest.fixture +def bundle_dir(tmp_path, monkeypatch, local_stores, cli_routing): + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path / "triage" + + +def _run(root): + return materialize(plan_of(root)) + + +def _summary(materialization): + return {done.write.id: (done.version, done.reused, done.reasons) for done in materialization.writes} + + +def _problems(root): + with pytest.raises(BundleError) as caught: + plan_of(root) + return caught.value.problems + + +def test_a_cli_folder_is_written_as_a_cli_over_its_files_under_a_prefix_of_its_own(bundle_dir): + layout(bundle_dir, FILES) + + _run(bundle_dir) + + with namespace_routing(): + cli = CliArtifact.get(CLI) + files = cli.get_cli_files().get_file_artifacts() + assert (cli.command_name, cli.entrypoint, cli.cli_files_id) == ("hello", "bin/hello", f"{CLI}__files") + assert sorted(files) == ["bin/hello", "lib/greeting.txt"] + prefix, _, attempt = cli.cli_object_url.rstrip("/").rpartition("/") + assert "/artifacts/cli/" in prefix and attempt.startswith("1-") + + +def test_a_cli_folder_is_reused_until_its_files_or_toml_change(bundle_dir): + layout(bundle_dir, FILES) + _run(bundle_dir) + + assert _summary(_run(bundle_dir))[CLI] == (1, True, ()) + (bundle_dir / "artifacts/hello/lib/greeting.txt").write_text("hi\n") + assert _summary(_run(bundle_dir))[CLI][:2] == (2, False) + (bundle_dir / "artifacts/hello/artifact.toml").write_text(TOML.replace('"hello"\n', '"greet"\n', 1)) + assert _summary(_run(bundle_dir))[CLI][:2] == (3, False) + with namespace_routing(): + assert CliArtifact.get(CLI).command_name == "greet" + + +def test_a_cli_folder_its_walk_refuses_is_reported_once(bundle_dir, tmp_path): + layout(bundle_dir, FILES) + (tmp_path / "outside.txt").write_text("x\n") + (bundle_dir / "artifacts/hello/bin/link").symlink_to(tmp_path / "outside.txt") + + problems = _problems(bundle_dir) + + assert [problem for problem in problems if "outside the bundle" in problem] == [ + f"artifacts/hello/bin/link: links to {(tmp_path / 'outside.txt').resolve()}, outside the bundle; copy what it " + "points to into the bundle, or put it in a store and refer to it by id"] + + +@pytest.mark.parametrize("toml, extra, problem", [ + ('type = "cli"\nentrypoint = "bin/hello"\n', {}, + "artifacts/hello: artifact.toml: command_name is required"), + ('type = "cli"\ncommand_name = "hello"\n', {}, + "artifacts/hello: artifact.toml: entrypoint is required"), + (TOML.replace('command_name = "hello"', 'command_name = "../hello"'), {}, + "artifacts/hello: artifact.toml: command_name must be a plain name, of letters, digits, '.', '_' and '-' and " + "not starting with '.' or '-', since the CLI is installed at /opt/cli/; not '../hello'"), + (TOML.replace('"bin/hello"', '"bin/helo"'), {}, + "artifacts/hello: artifact.toml: entrypoint 'bin/helo' isn't one of this folder's files"), + (TOML, {"artifacts/hello/bin/.env": "AGENT_ENV_GATEWAY_URL=mine\n"}, + "artifacts/hello: bin/.env: installing the CLI writes the gateway URL to this file, beside its entrypoint; " + "rename or remove the folder's own"), + (TOML + 'env_id = "crm"\n', {}, + "artifacts/hello: artifact.toml: unknown key 'env_id'; a cli artifact takes command_name, entrypoint, type " + "and id"), +], ids=["no command_name", "no entrypoint", "command_name a path", "no such entrypoint", "its own .env", + "unknown key"]) +def test_a_cli_folder_that_couldnt_be_installed_as_written_is_refused_before_any_write(bundle_dir, toml, extra, + problem): + layout(bundle_dir, {**FILES, "artifacts/hello/artifact.toml": toml, **extra}) + + assert problem in _problems(bundle_dir) + assert not local_store().path.exists() diff --git a/tst/unit/store/local_backends_composition_test.py b/tst/unit/store/local_backends_composition_test.py index 60a29aa0..db768b09 100644 --- a/tst/unit/store/local_backends_composition_test.py +++ b/tst/unit/store/local_backends_composition_test.py @@ -208,7 +208,8 @@ def test_a_local_id_cli_bundle_round_trips(local_stores, cli_routing, tmp_path): cli_dir = _cli_dir(tmp_path, "cli", {"bin/tool": b"#!/bin/sh\n", "lib/data.txt": b"d"}) art = CliArtifact.put(id=HOSTILE, command_name="tool", entrypoint="bin/tool", cli_dir=cli_dir) - assert local_stores.get_object_store().get_object_key(art.cli_object_url) == f"artifacts/cli/{HOSTILE_SEGMENT}/1" + assert re.fullmatch(rf"artifacts/cli/{re.escape(HOSTILE_SEGMENT)}/1-[0-9a-f]+", + local_stores.get_object_store().get_object_key(art.cli_object_url)) assert art.cli_files_id == f"{HOSTILE}__files" loaded = {rel: fa.load() for rel, fa in CliArtifact.get(HOSTILE).get_cli_files().get_file_artifacts().items()} assert loaded == {"bin/tool": b"#!/bin/sh\n", "lib/data.txt": b"d"} From b0c3cbf8f221b9d1b2506b356a228203af3ca69d Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 14 Oct 2026 08:49:54 -0700 Subject: [PATCH 2/5] fix(bundle): refuse a .env folder beside a CLI's entrypoint, and stop a test agent's run with all it started Files below the .env install writes beside the entrypoint are refused too, since install writes that path as a file. The echo agent's run lines start their command in a process group of their own and stop the group, then wait for it, when the command times out or the request is cancelled. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/artifact/artifacts/cli.py | 2 +- tst/data/a2a_agent/agent.py | 17 ++++++++++++++--- tst/unit/bundle/cli_folder_test.py | 5 ++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/agent_env/artifact/artifacts/cli.py b/src/agent_env/artifact/artifacts/cli.py index e54d826c..5f32ccfe 100644 --- a/src/agent_env/artifact/artifacts/cli.py +++ b/src/agent_env/artifact/artifacts/cli.py @@ -115,7 +115,7 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: gateway_env = posixpath.join(posixpath.dirname(entrypoint), _GATEWAY_ENV_FILE) if entrypoint not in files: problems.append(ctx.config_problem(f"entrypoint {entrypoint!r} isn't one of this folder's files")) - elif gateway_env in files: + elif any(name == gateway_env or name.startswith(f"{gateway_env}/") for name in files): problems.append(f"{gateway_env}: installing the CLI writes the gateway URL to this file, beside its " "entrypoint; rename or remove the folder's own") if problems: diff --git a/tst/data/a2a_agent/agent.py b/tst/data/a2a_agent/agent.py index 05cb985e..2a5b2b34 100644 --- a/tst/data/a2a_agent/agent.py +++ b/tst/data/a2a_agent/agent.py @@ -17,8 +17,11 @@ import asyncio import base64 +import contextlib import hashlib +import os import re +import signal from pathlib import Path from typing import Any from urllib.parse import urlsplit @@ -83,13 +86,21 @@ async def _read(part: FilePart) -> str: async def _ran(command: str) -> str: - """``command``'s output, stdout and stderr together, run by the shell.""" + """``command``'s output, stdout and stderr together, run by the shell in a process group of its own, which is + stopped with whatever it started if the command times out or the request is cancelled.""" process = await asyncio.create_subprocess_shell(command, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT) + stderr=asyncio.subprocess.STDOUT, start_new_session=True) + output = None try: output, _ = await asyncio.wait_for(process.communicate(), RUN_TIMEOUT_SECONDS) except asyncio.TimeoutError: - process.kill() + pass + finally: + if process.returncode is None: + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + await process.wait() + if output is None: return f"ran {command}: timed out after {RUN_TIMEOUT_SECONDS}s" return f"ran {command}: {output.decode(errors='replace').strip()}" diff --git a/tst/unit/bundle/cli_folder_test.py b/tst/unit/bundle/cli_folder_test.py index 50d90541..0404ffdd 100644 --- a/tst/unit/bundle/cli_folder_test.py +++ b/tst/unit/bundle/cli_folder_test.py @@ -95,11 +95,14 @@ def test_a_cli_folder_its_walk_refuses_is_reported_once(bundle_dir, tmp_path): (TOML, {"artifacts/hello/bin/.env": "AGENT_ENV_GATEWAY_URL=mine\n"}, "artifacts/hello: bin/.env: installing the CLI writes the gateway URL to this file, beside its entrypoint; " "rename or remove the folder's own"), + (TOML, {"artifacts/hello/bin/.env/settings": "x\n"}, + "artifacts/hello: bin/.env: installing the CLI writes the gateway URL to this file, beside its entrypoint; " + "rename or remove the folder's own"), (TOML + 'env_id = "crm"\n', {}, "artifacts/hello: artifact.toml: unknown key 'env_id'; a cli artifact takes command_name, entrypoint, type " "and id"), ], ids=["no command_name", "no entrypoint", "command_name a path", "no such entrypoint", "its own .env", - "unknown key"]) + "a .env folder", "unknown key"]) def test_a_cli_folder_that_couldnt_be_installed_as_written_is_refused_before_any_write(bundle_dir, toml, extra, problem): layout(bundle_dir, {**FILES, "artifacts/hello/artifact.toml": toml, **extra}) From 78c2b7a0e65c3f364f7f1906abcff73ff60a04ac Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 14 Oct 2026 09:17:47 -0700 Subject: [PATCH 3/5] feat(bundle): refuse a CLI folder's load that couldn't install it, before any write A load_artifact step naming one of the bundle's CLIs must name an agent and an env, whose gateway the CLI is pointed at, and no two of a task's loads may install one command_name into one agent, where the second would replace the first. Both used to fail mid-run, after the deploys. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/plan.py | 41 ++++++++++++++++++++++++++++++ tst/unit/bundle/cli_folder_test.py | 31 ++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index bf2016eb..433d2f63 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -146,6 +146,7 @@ def plan(self, tasks: Sequence[str], evals: Sequence[str]) -> Plan: store_refs, store_latest = self._check_store_refs(closure) self._build_tasks(closure) self._check_files(closure) + self._check_cli_loads(closure) self._check_env_types(closure) self._check_multi_names(closure, store_latest) self._check_universes(closure, store_latest) @@ -164,6 +165,7 @@ def check(self) -> None: self._check_store_refs(everything, stores=False) self._build_tasks(everything) self._check_files(everything) + self._check_cli_loads(everything) self._check_universes(everything, None) self._check_loaded_environments(everything, None) if self.problems: @@ -371,6 +373,45 @@ def _check_files(self, closure: set[_Key]) -> None: except BundleError as e: self.problems.extend(e.problems) + def _check_cli_loads(self, closure: set[_Key]) -> None: + """Each load of one of the bundle's CLIs names what installing it needs, an agent and an env whose gateway it + reads, and no two of a task's loads install one command into one agent, where the second would replace the + first.""" + registry = get_artifact_registry() + clis = {source.entry.id: source.entry for source in self.nodes.values() + if isinstance(source, ResolvedEntry) and source.entry.kind is BundleKind.ARTIFACT + and issubclass(registry.get(canonical_type(source.entry.type)) or object, CliArtifact)} + for key in sorted(closure, key=self.rank.__getitem__): + source = self.nodes[key] + if not (isinstance(source, ResolvedEntry) and source.entry.kind is BundleKind.TASK): + continue + installed: dict[tuple[str, str], tuple[Any, str]] = {} + for config in source.config: + try: + step = build_step(config) + except Exception: + continue # reported by the task check + if not isinstance(step, LoadArtifactTaskStep): + continue + where = f"{self._path(source.entry)}: step {step.id!r}" + for cli in (clis[ref["id"]] for ref in step.artifacts if ref.get("id") in clis): + missing = [name for name in ("agent_name", "env_id") if not getattr(step, name)] + if missing: + needs = " and ".join(missing) + self.problems.append(f"{where}: loading the CLI {cli.name!r} installs it into an agent and " + f"points it at an env's gateway, so the step needs {needs}") + continue + command = self.accepted.get(_key(cli), {}).get("command_name") + if command is None: + continue # its artifact.toml's problem is reported by the file check + if (step.agent_name, command) in installed: + first, name = installed[step.agent_name, command] + self.problems.append(f"{where}: installs the CLI {cli.name!r} as {command!r} in agent " + f"{step.agent_name!r}, where step {first!r} already installed {name!r} " + "under that command; give one another command_name") + else: + installed[step.agent_name, command] = (step.id, cli.name) + def _env_accept(self, entry: BundleEntry) -> Any: """How ``entry``'s env type checks its env.toml, when it's an env written from one and checks it at all.""" cls = get_env_registry().get(entry.type) if entry.kind is BundleKind.ENV else None diff --git a/tst/unit/bundle/cli_folder_test.py b/tst/unit/bundle/cli_folder_test.py index 0404ffdd..1250f8da 100644 --- a/tst/unit/bundle/cli_folder_test.py +++ b/tst/unit/bundle/cli_folder_test.py @@ -82,6 +82,37 @@ def test_a_cli_folder_its_walk_refuses_is_reported_once(bundle_dir, tmp_path): "points to into the bundle, or put it in a store and refer to it by id"] +def _loads(*steps): + return {"tasks/t.json": json.dumps([{"type": "load_artifact", "env_id": "items", "agent_name": "default-agent", + **step} for step in steps])} + + +@pytest.mark.parametrize("target, missing", [({"env_id": "items"}, "agent_name"), ({"agent_name": "a"}, "env_id")]) +def test_a_load_of_a_cli_folder_missing_what_installing_it_needs_is_refused(bundle_dir, target, missing): + layout(bundle_dir, {**FILES, "tasks/t.json": json.dumps([ + {"id": "load", "type": "load_artifact", "artifact_id": "hello", **target}])}) + + assert ("tasks/t.json: step 'load': loading the CLI 'hello' installs it into an agent and points it at an env's " + f"gateway, so the step needs {missing}") in _problems(bundle_dir) + + +def test_two_cli_folders_installed_as_one_command_in_one_agent_are_refused(bundle_dir): + layout(bundle_dir, {**FILES, "artifacts/hi/artifact.toml": TOML, "artifacts/hi/bin/hello": "#!/bin/sh\n", + **_loads({"id": "load", "artifact_id": "hello"}, {"id": "load-hi", "artifact_id": "hi"})}) + + assert ("tasks/t.json: step 'load-hi': installs the CLI 'hi' as 'hello' in agent 'default-agent', where step " + "'load' already installed 'hello' under that command; give one another command_name") in _problems( + bundle_dir) + + +def test_two_cli_folders_of_one_command_may_go_to_different_agents(bundle_dir): + layout(bundle_dir, {**FILES, "artifacts/hi/artifact.toml": TOML, "artifacts/hi/bin/hello": "#!/bin/sh\n", + **_loads({"id": "load", "artifact_id": "hello"}, + {"id": "load-hi", "artifact_id": "hi", "agent_name": "reviewer"})}) + + assert not [problem for problem in _problems(bundle_dir) if "CLI" in problem] + + @pytest.mark.parametrize("toml, extra, problem", [ ('type = "cli"\nentrypoint = "bin/hello"\n', {}, "artifacts/hello: artifact.toml: command_name is required"), From f2531381d456161b98b8c3a6552d79549414f23d Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 14 Oct 2026 09:33:54 -0700 Subject: [PATCH 4/5] refactor(bundle): keep cli_dir puts as they were, name the gateway file once, reuse the plan's built steps CliArtifact.put checks the entrypoint is among the files only for a listing, so a cli_dir put takes an entrypoint like ./bin/x as before. install_cli and the bundle's check share GATEWAY_ENV_FILE. The plan keeps the steps _build_tasks builds, so the CLI load check reads them rather than building every step again, and checks a step's agent and env once however many CLIs it loads. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/a2a_agent/a2a_agent.py | 3 ++- src/agent_env/artifact/artifacts/cli.py | 8 +++---- src/agent_env/bundle/plan.py | 31 ++++++++++++------------- tst/unit/artifact/cli_artifact_test.py | 6 +++++ tst/unit/bundle/cli_folder_test.py | 11 +++++++++ 5 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index fe5644a6..27527d7f 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -20,6 +20,7 @@ NetworkPolicyUnsupportedError, port_bindings, ) +from agent_env.artifact.artifacts.cli import GATEWAY_ENV_FILE from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.a2a_agent.object_transfer import ( TRANSFER_TIMEOUT_SECONDS, @@ -257,7 +258,7 @@ async def install_cli(deployed: "DeployedA2AAgent", cli_artifact: "CliArtifact", env_dir = posixpath.dirname(entrypoint_path) # write .env first — write_file_from_text creates the parent dir; write_file_from_object does not. - await sandbox.write_file_from_text(f"AGENT_ENV_GATEWAY_URL={gateway_url}\n", f"{env_dir}/.env") + await sandbox.write_file_from_text(f"AGENT_ENV_GATEWAY_URL={gateway_url}\n", f"{env_dir}/{GATEWAY_ENV_FILE}") for rel_path, file_artifact in cli_artifact.get_cli_files().get_file_artifacts().items(): await sandbox.write_file_from_object(file_artifact.object_url, f"{target_root}/{rel_path}") diff --git a/src/agent_env/artifact/artifacts/cli.py b/src/agent_env/artifact/artifacts/cli.py index 5f32ccfe..04df98d2 100644 --- a/src/agent_env/artifact/artifacts/cli.py +++ b/src/agent_env/artifact/artifacts/cli.py @@ -24,8 +24,8 @@ # A command a bundle's CLI is installed as, at /opt/cli//. _COMMAND_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*") -# Where installing a CLI writes the gateway URL, beside its entrypoint. -_GATEWAY_ENV_FILE = ".env" +# The file installing a CLI writes the gateway URL to, beside its entrypoint. +GATEWAY_ENV_FILE = ".env" class CliArtifact(Artifact): @@ -75,7 +75,7 @@ def put( if not (cli_dir / entrypoint).is_file(): raise ValueError(f"entrypoint {entrypoint!r} not found at {cli_dir / entrypoint}") files = {p.relative_to(cli_dir).as_posix(): p for p in sorted(cli_dir.rglob("*")) if p.is_file()} - if entrypoint not in files: + elif entrypoint not in files: raise ValueError(f"entrypoint {entrypoint!r} isn't among the CLI's files") store = get_artifact_store() @@ -112,7 +112,7 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: f"'-', since the CLI is installed at /opt/cli/; not {command_name!r}")) files = ctx.try_files() if entrypoint is not None and files is not None: - gateway_env = posixpath.join(posixpath.dirname(entrypoint), _GATEWAY_ENV_FILE) + gateway_env = posixpath.join(posixpath.dirname(entrypoint), GATEWAY_ENV_FILE) if entrypoint not in files: problems.append(ctx.config_problem(f"entrypoint {entrypoint!r} isn't one of this folder's files")) elif any(name == gateway_env or name.startswith(f"{gateway_env}/") for name in files): diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 433d2f63..fd8ef766 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -47,6 +47,7 @@ from agent_env.store.routing import namespace_routing_enabled from agent_env.task import Task from agent_env.task_step.registry import get_task_step_registry +from agent_env.task_step.task_step import TaskStep from agent_env.task_step.task_steps.load_artifact import LoadArtifactTaskStep from ._fs import fold, relative, with_article @@ -133,6 +134,7 @@ def __init__(self, resolved: ResolvedBundle): self.outputs = {(output.kind.value, output.id): (entry.entry, output) for entry in resolved.entries for output in entry.outputs} self.accepted: dict[_Key, dict] = {} # each toml a type checks, as it accepted it + self.steps: dict[_Key, list[TaskStep]] = {} # each task's steps that build, as a write will build them def _add(self, key: _Key, source: ResolvedEntry | BundleImage, needs: list[_Key]) -> None: """Record a write in bundle order, a built image just before the entry that builds it.""" @@ -382,25 +384,21 @@ def _check_cli_loads(self, closure: set[_Key]) -> None: if isinstance(source, ResolvedEntry) and source.entry.kind is BundleKind.ARTIFACT and issubclass(registry.get(canonical_type(source.entry.type)) or object, CliArtifact)} for key in sorted(closure, key=self.rank.__getitem__): - source = self.nodes[key] - if not (isinstance(source, ResolvedEntry) and source.entry.kind is BundleKind.TASK): - continue installed: dict[tuple[str, str], tuple[Any, str]] = {} - for config in source.config: - try: - step = build_step(config) - except Exception: - continue # reported by the task check + for step in self.steps.get(key, ()): if not isinstance(step, LoadArtifactTaskStep): continue - where = f"{self._path(source.entry)}: step {step.id!r}" - for cli in (clis[ref["id"]] for ref in step.artifacts if ref.get("id") in clis): - missing = [name for name in ("agent_name", "env_id") if not getattr(step, name)] - if missing: - needs = " and ".join(missing) - self.problems.append(f"{where}: loading the CLI {cli.name!r} installs it into an agent and " - f"points it at an env's gateway, so the step needs {needs}") - continue + loaded = [clis[ref["id"]] for ref in step.artifacts if ref.get("id") in clis] + if not loaded: + continue + where = f"{self._path(self.nodes[key].entry)}: step {step.id!r}" + if missing := [name for name in ("agent_name", "env_id") if not getattr(step, name)]: + names, it = " and ".join(repr(cli.name) for cli in loaded), "them" if len(loaded) > 1 else "it" + self.problems.append(f"{where}: loading the CLI{'s' * (it == 'them')} {names} installs {it} into " + f"an agent and points {it} at an env's gateway, so the step needs " + f"{' and '.join(missing)}") + continue + for cli in loaded: command = self.accepted.get(_key(cli), {}).get("command_name") if command is None: continue # its artifact.toml's problem is reported by the file check @@ -613,6 +611,7 @@ def _build_tasks(self, closure: set[_Key]) -> None: failed = True self.problems.append(f"{self._path(source.entry)}: step {step.get('id')!r}: " f"{registry[step['type']].type} can't read it ({type(e).__name__}: {e})") + self.steps[key] = steps if not failed: try: Task(id=source.entry.id, version=None, steps=steps) diff --git a/tst/unit/artifact/cli_artifact_test.py b/tst/unit/artifact/cli_artifact_test.py index 13a765b4..4dd78216 100644 --- a/tst/unit/artifact/cli_artifact_test.py +++ b/tst/unit/artifact/cli_artifact_test.py @@ -30,6 +30,12 @@ def test_a_put_takes_a_folder_or_a_listing(local_stores, sources): CliArtifact.put("hello-cli", command_name="hello", entrypoint="bin/hello", **sources) +def test_a_folders_entrypoint_is_recorded_as_given(local_stores, cli_files, tmp_path): + cli = CliArtifact.put("hello-cli", command_name="hello", entrypoint="./bin/hello", cli_dir=tmp_path) + + assert cli.entrypoint == "./bin/hello" + + def test_a_listing_without_its_entrypoint_is_refused(local_stores, cli_files): with pytest.raises(ValueError, match="entrypoint 'bin/hi' isn't among the CLI's files"): CliArtifact.put("hello-cli", command_name="hello", entrypoint="bin/hi", files=cli_files) diff --git a/tst/unit/bundle/cli_folder_test.py b/tst/unit/bundle/cli_folder_test.py index 1250f8da..228ee7c2 100644 --- a/tst/unit/bundle/cli_folder_test.py +++ b/tst/unit/bundle/cli_folder_test.py @@ -96,6 +96,17 @@ def test_a_load_of_a_cli_folder_missing_what_installing_it_needs_is_refused(bund f"gateway, so the step needs {missing}") in _problems(bundle_dir) +def test_a_step_loading_two_cli_folders_without_an_agent_is_refused_once(bundle_dir): + layout(bundle_dir, {**FILES, "artifacts/hi/artifact.toml": TOML.replace('"hello"\n', '"hi"\n', 1), + "artifacts/hi/bin/hello": "#!/bin/sh\n", "tasks/t.json": json.dumps([ + {"id": "load", "type": "load_artifact", "env_id": "items", + "artifacts": [{"id": "hello"}, {"id": "hi"}]}])}) + + assert [problem for problem in _problems(bundle_dir) if "CLI" in problem] == [ + "tasks/t.json: step 'load': loading the CLIs 'hello' and 'hi' installs them into an agent and points them at " + "an env's gateway, so the step needs agent_name"] + + def test_two_cli_folders_installed_as_one_command_in_one_agent_are_refused(bundle_dir): layout(bundle_dir, {**FILES, "artifacts/hi/artifact.toml": TOML, "artifacts/hi/bin/hello": "#!/bin/sh\n", **_loads({"id": "load", "artifact_id": "hello"}, {"id": "load-hi", "artifact_id": "hi"})}) From af15f2c06749fe33727bde9a440bab36b4367f99 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 14 Oct 2026 10:05:47 -0700 Subject: [PATCH 5/5] test(bundle): cover build_mcp_cli's put and install in the CLI folder slow test The task also builds the items env's CLI with build_mcp_cli, as the worker does, through CliArtifact.put's cli_dir path, installs it into the agent beside the folder's CLI, and has the agent add an item through it and list it back. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/run_bundle_cli_folder_test.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tst/integration/cli/run_bundle_cli_folder_test.py b/tst/integration/cli/run_bundle_cli_folder_test.py index b91fd798..f8bdb35a 100644 --- a/tst/integration/cli/run_bundle_cli_folder_test.py +++ b/tst/integration/cli/run_bundle_cli_folder_test.py @@ -1,8 +1,9 @@ """``agent-env run`` on a bundle with a CLI folder, ``artifacts//`` with ``type = "cli"``: a task deploys an MCP server env through the gateway and the echo agent, loads the CLI into the agent and attaches it as a skill, and the -agent runs it. The CLI reads a file of its own and the gateway URL installing it wrote beside its entrypoint. An edit -to one of its files writes it again, and the next run installs the new one. Needs a Docker daemon and the local -registry.""" +agent runs it. The CLI reads a file of its own and the gateway URL installing it wrote beside its entrypoint. The +task also builds the env's own CLI with build_mcp_cli, as the worker does, and the agent adds an item through it and +lists it back. An edit to the folder CLI's files writes it again, and the next run installs the new one. Needs a +Docker daemon and the local registry.""" import json import logging @@ -24,6 +25,7 @@ ITEMS = REPO / "tst" / "data" / "agentenv_mcp" # an in-memory MCP server whose card names it items NO_MODEL = 'LITELLM_API_KEY = "unused"\nLITELLM_BASE_URL = "http://unused.invalid"\n' ENTRYPOINT = "/opt/cli/hello/bin/hello" +ITEMS_CLI = "/opt/cli/items/bin/items" # the CLI build_mcp_cli generates for the items env # Prints its greeting file and the scheme of the gateway URL install_cli wrote to bin/.env. HELLO = '''#!/bin/sh here="$(dirname "$0")" @@ -64,13 +66,19 @@ def _task(greeting): {"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "echo", "sandbox_type": "local"}, {"id": "load", "type": "load_artifact", "env_id": "items", "artifact_id": "hello", "agent_name": "default-agent", "depends_on": ["env", "agent"]}, - {"id": "skill", "type": "add_skills", "cli_artifact_ids": ["hello"], "depends_on": ["load"]}, - {"id": "ask", "type": "prompt_agent", "prompt": f"run {ENTRYPOINT}\nlist-skills", "prompt_id": "ask", - "depends_on": ["skill"]}, + {"id": "build", "type": "build_mcp_cli", "env_id": "items", "command_name": "items", + "cli_artifact_id": "items-cli", "depends_on": ["env"]}, + {"id": "load-built", "type": "load_artifact", "env_id": "items", "artifact_id": "items-cli", + "agent_name": "default-agent", "depends_on": ["build", "agent"]}, + {"id": "skill", "type": "add_skills", "cli_artifact_ids": ["hello"], "depends_on": ["load", "load-built"]}, + {"id": "ask", "type": "prompt_agent", "prompt_id": "ask", "depends_on": ["skill"], + "prompt": f"run {ENTRYPOINT}\nrun {ITEMS_CLI} items-add-item --item from-cli\nrun {ITEMS_CLI} list-items\n" + "list-skills"}, + # The added item is quoted only in list-items' JSON, not in the prompt the agent echoes. {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "ran", "criteria": [{"type": "response_contains", "needles": [f"ran {ENTRYPOINT}: {greeting} from a bundle cli, gateway http", - "skill hello-cli: SKILL.md="]}], + f"ran {ITEMS_CLI} list-items: ", '"from-cli"', "skill hello-cli: SKILL.md="]}], "depends_on": ["ask"]}, ])