Require sandbox pass before builder deploy
All checks were successful
build / build (push) Successful in 12s

This commit is contained in:
robert
2026-06-07 22:53:57 -03:00
parent 49a41949ab
commit b285f3cdc8
4 changed files with 114 additions and 7 deletions

View File

@@ -1,5 +1,5 @@
name: agent-builder
version: 0.1.5
version: 0.1.6
entrypoint: agent:AgentBuilder
description: Generate, test, and deploy new a2a agents from natural language.
expose:

View File

@@ -49,7 +49,7 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
"Writes the project into the user's workspace, validates it in a "
"sandbox, then ships it via the control plane."
)
version = "0.1.5"
version = "0.1.6"
config_model = BuilderConfig
auth_model = NoAuth

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import ast
import base64
import hashlib
import io
import json
import logging
@@ -465,6 +466,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
return json.dumps({
"error": "no files in agents/" + name + "/ — write some first",
})
source_hash = _source_bundle_hash(bundle_bytes)
b64 = base64.b64encode(bundle_bytes).decode("ascii")
script = (
"set -e\n"
@@ -650,6 +652,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
}
if sanitized_fields:
result["sanitized_agent_card_fields"] = list(sanitized_fields)
_record_sandbox_result(store, name, source_hash=source_hash, result=result)
return json.dumps(result)
headers = _sandbox_headers(settings.sandbox_token, ctx.grant_token)
@@ -673,6 +676,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
}
if sanitized_fields:
result["sanitized_agent_card_fields"] = list(sanitized_fields)
_record_sandbox_result(store, name, source_hash=source_hash, result=result)
return json.dumps(result)
@tool
@@ -716,11 +720,20 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
})
builder_state = _read_builder_state(store, name)
sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix)
bundle_bytes = _tarball_workspace_dir(store, prefix)
if not bundle_bytes:
return json.dumps({"error": f"no files at agents/{name}/"})
source_hash = _source_bundle_hash(bundle_bytes)
sandbox_error = _sandbox_gate_error(builder_state, source_hash=source_hash)
if sandbox_error is not None:
return json.dumps(sandbox_error)
try:
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
except RuntimeError as exc:
return json.dumps({"error": str(exc)})
builder_state = _read_builder_state(store, name)
drift = _deployment_drift_error(
name,
latest_deploy,
@@ -733,10 +746,6 @@ def build_tools(ctx: ToolContext) -> list[Any]:
if force:
base_head = None
sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix)
bundle_bytes = _tarball_workspace_dir(store, prefix)
if not bundle_bytes:
return json.dumps({"error": f"no files at agents/{name}/"})
# Read the entrypoint from a2a.yaml so we can pass it verbatim.
entrypoint = _read_entrypoint(bundle_bytes) or f"agent:{_class_name(name)}"
try:
@@ -1268,6 +1277,55 @@ def _write_builder_state(
store.put(_builder_state_key(name), body, "application/json")
def _source_bundle_hash(bundle: bytes) -> str:
return hashlib.sha256(bundle).hexdigest()
def _record_sandbox_result(
store: _ObjectStore,
name: str,
*,
source_hash: str,
result: dict[str, Any],
) -> None:
state = _read_builder_state(store, name)
state["last_sandbox"] = {
"source_hash": source_hash,
"exit_code": result.get("exit_code"),
"updated_at": int(time.time()),
}
_write_builder_state(store, name, state)
def _sandbox_gate_error(
builder_state: dict[str, Any],
*,
source_hash: str,
) -> dict[str, Any] | None:
sandbox = builder_state.get("last_sandbox")
if not isinstance(sandbox, dict):
return {
"ok": False,
"error": "sandbox_not_passed",
"message": "Run test_agent_in_sandbox for the current source before deploying.",
}
if sandbox.get("source_hash") != source_hash:
return {
"ok": False,
"error": "sandbox_not_passed",
"message": "Source changed after the last sandbox pass; rerun test_agent_in_sandbox.",
"last_exit_code": sandbox.get("exit_code"),
}
if sandbox.get("exit_code") != 0:
return {
"ok": False,
"error": "sandbox_not_passed",
"message": "Last sandbox run failed; fix the source and rerun test_agent_in_sandbox.",
"last_exit_code": sandbox.get("exit_code"),
}
return None
def _parse_manifest_json(raw: str) -> dict[str, Any]:
try:
parsed = json.loads(raw or "{}")

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import tarfile
@@ -12,6 +13,7 @@ from agent_builder.builder import _workspace_backend_with_grep_compat
from agent_builder.tools import (
_BUILDER_STATE_FILE,
_BUILDER_INTERNAL_PREFIX,
_WorkspaceObjectStore,
_parse_manifest_json,
_deploy_poll_url,
_deployment_drift_error,
@@ -380,6 +382,18 @@ class TemplateInitTests(unittest.TestCase):
}
for rel, content in files.items():
workspace.write_bytes(prefix + rel, content.encode("utf-8"))
source_hash = hashlib.sha256(
_tarball_workspace_dir(_WorkspaceObjectStore(workspace), prefix)
).hexdigest()
workspace.write_bytes(
prefix + _BUILDER_STATE_FILE,
json.dumps({
"last_sandbox": {
"source_hash": source_hash,
"exit_code": 0,
}
}).encode("utf-8"),
)
tools = build_tools(
ToolContext(
bucket="bucket",
@@ -429,6 +443,41 @@ class TemplateInitTests(unittest.TestCase):
self.assertEqual(dsl["entrypoint"]["module"], "agent")
self.assertEqual(dsl["entrypoint"]["class_name"], "DemoAgent")
def test_cp_deploy_tarball_refuses_without_current_sandbox_pass(self) -> None:
workspace = _FakeWorkspace()
prefix = "agents/demo-agent/"
workspace.write_bytes(prefix + "agent.py", b"print('ok')\n")
workspace.write_bytes(
prefix + "a2a.yaml",
b"name: demo-agent\nversion: 0.1.0\nentrypoint: agent:DemoAgent\n",
)
workspace.write_bytes(prefix + "requirements.txt", b"")
tools = build_tools(
ToolContext(
bucket="bucket",
settings=_settings(deploy_wait_timeout_s=0),
workspace=workspace,
cp_jwt="jwt-user",
)
)
deploy = _tool_by_name(tools, "cp_deploy_tarball")
_FakeAsyncClient.posts = []
result = json.loads(
asyncio.run(
deploy.ainvoke(
{
"name": "demo-agent",
"version": "0.1.0",
"public": False,
}
)
)
)
self.assertEqual(result["error"], "sandbox_not_passed")
self.assertEqual(_FakeAsyncClient.posts, [])
def test_cp_compose_meta_agent_posts_manifest_and_records_state(self) -> None:
workspace = _FakeWorkspace()
tools = build_tools(