Require sandbox pass before builder deploy
All checks were successful
build / build (push) Successful in 12s
All checks were successful
build / build (push) Successful in 12s
This commit is contained in:
2
a2a.yaml
2
a2a.yaml
@@ -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:
|
||||
|
||||
2
agent.py
2
agent.py
@@ -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
|
||||
|
||||
@@ -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 "{}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user