fix: harden generated agent platform access
All checks were successful
build / build (push) Successful in 21s
All checks were successful
build / build (push) Successful in 21s
This commit is contained in:
6
agent.py
6
agent.py
@@ -96,8 +96,10 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
# it and surface a clean "I need to run through the orchestrator"
|
# it and surface a clean "I need to run through the orchestrator"
|
||||||
# error rather than letting it bubble up as a 500.
|
# error rather than letting it bubble up as a 500.
|
||||||
try:
|
try:
|
||||||
bucket = getattr(ctx.workspace, "bucket", None)
|
workspace = ctx.workspace
|
||||||
|
bucket = getattr(workspace, "bucket", None)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
|
workspace = None
|
||||||
bucket = None
|
bucket = None
|
||||||
if not bucket:
|
if not bucket:
|
||||||
return {
|
return {
|
||||||
@@ -126,6 +128,8 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
graph = build_agent_builder(BuilderContext(
|
graph = build_agent_builder(BuilderContext(
|
||||||
bucket=bucket, cp_jwt=cp_jwt,
|
bucket=bucket, cp_jwt=cp_jwt,
|
||||||
project_prefix=f"agents/{name}",
|
project_prefix=f"agents/{name}",
|
||||||
|
workspace=workspace,
|
||||||
|
grant_token=getattr(workspace, "_grant_token", None),
|
||||||
llm_base_url=creds.base_url,
|
llm_base_url=creds.base_url,
|
||||||
llm_api_key=creds.api_key,
|
llm_api_key=creds.api_key,
|
||||||
llm_model=creds.model,
|
llm_model=creds.model,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class BuilderContext:
|
|||||||
cp_jwt: str | None = None
|
cp_jwt: str | None = None
|
||||||
settings: Settings | None = None
|
settings: Settings | None = None
|
||||||
project_prefix: str | None = None
|
project_prefix: str | None = None
|
||||||
|
workspace: Any | None = None
|
||||||
|
grant_token: str | None = None
|
||||||
# Optional caller-provided LLM creds (when the outer platform Card
|
# Optional caller-provided LLM creds (when the outer platform Card
|
||||||
# declares llm_provisioning=caller_provided). Falls back to settings.
|
# declares llm_provisioning=caller_provided). Falls back to settings.
|
||||||
llm_base_url: str | None = None
|
llm_base_url: str | None = None
|
||||||
@@ -49,6 +51,12 @@ small tool-calling DeepAgent with ``create_deep_agent`` plus
|
|||||||
``wrap_model_call`` middleware. Do not recreate this from memory: call
|
``wrap_model_call`` middleware. Do not recreate this from memory: call
|
||||||
``init_agent_template`` first and modify the generated files.
|
``init_agent_template`` first and modify the generated files.
|
||||||
|
|
||||||
|
Default generated/user agents to ``LLMProvisioning.CALLER_PROVIDED`` and
|
||||||
|
``caller_pays_llm=True`` unless the user explicitly wants platform-paid LLM
|
||||||
|
usage. ``LLMProvisioning.PLATFORM`` is allowed only through the A2A LiteLLM
|
||||||
|
grant path: the generated agent must still read ``ctx.llm`` and must never read
|
||||||
|
``A2A_LITELLM_KEY`` or any provider key directly.
|
||||||
|
|
||||||
You have packaged DeepAgents skills loaded from ``/.agent-builder/skills/``.
|
You have packaged DeepAgents skills loaded from ``/.agent-builder/skills/``.
|
||||||
Use ``deepagent-agent-design`` before designing generated agent internals,
|
Use ``deepagent-agent-design`` before designing generated agent internals,
|
||||||
``a2apack-agent-authoring`` when shaping the public A2A class and Card,
|
``a2apack-agent-authoring`` when shaping the public A2A class and Card,
|
||||||
@@ -255,9 +263,15 @@ Discipline:
|
|||||||
|
|
||||||
def build_agent_builder(ctx: BuilderContext) -> Any:
|
def build_agent_builder(ctx: BuilderContext) -> Any:
|
||||||
settings = ctx.settings or load_settings()
|
settings = ctx.settings or load_settings()
|
||||||
tools = build_tools(ToolContext(
|
tools = build_tools(
|
||||||
bucket=ctx.bucket, settings=settings, cp_jwt=ctx.cp_jwt,
|
ToolContext(
|
||||||
))
|
bucket=ctx.bucket,
|
||||||
|
settings=settings,
|
||||||
|
cp_jwt=ctx.cp_jwt,
|
||||||
|
workspace=ctx.workspace,
|
||||||
|
grant_token=ctx.grant_token,
|
||||||
|
)
|
||||||
|
)
|
||||||
model_kwargs: dict[str, Any] = {
|
model_kwargs: dict[str, Any] = {
|
||||||
"model": ctx.llm_model or settings.litellm_model,
|
"model": ctx.llm_model or settings.litellm_model,
|
||||||
"base_url": ctx.llm_base_url or (settings.litellm_url + "/v1"),
|
"base_url": ctx.llm_base_url or (settings.litellm_url + "/v1"),
|
||||||
@@ -295,6 +309,12 @@ def _build_project_backend(ctx: BuilderContext, *, settings: Settings) -> Any |
|
|||||||
prefix = ctx.project_prefix.strip("/")
|
prefix = ctx.project_prefix.strip("/")
|
||||||
if not prefix:
|
if not prefix:
|
||||||
return _with_builder_skills(StateBackend())
|
return _with_builder_skills(StateBackend())
|
||||||
|
if ctx.workspace is not None:
|
||||||
|
try:
|
||||||
|
from a2a_pack.deepagents import WorkspaceBackend
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return _with_builder_skills(StateBackend())
|
||||||
|
return _with_builder_skills(WorkspaceBackend(ctx.workspace))
|
||||||
try:
|
try:
|
||||||
from a2a_pack import Grant, WorkspaceAccess, WorkspaceMode
|
from a2a_pack import Grant, WorkspaceAccess, WorkspaceMode
|
||||||
from a2a_pack.deepagents import WorkspaceBackend
|
from a2a_pack.deepagents import WorkspaceBackend
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ Use `A2AAgent` class attributes for runtime and marketplace behavior:
|
|||||||
- `wants_cp_jwt=True` is only for trusted platform agents. It forwards the
|
- `wants_cp_jwt=True` is only for trusted platform agents. It forwards the
|
||||||
caller's control-plane token.
|
caller's control-plane token.
|
||||||
|
|
||||||
|
For generated/user-owned agents that call an LLM, default to
|
||||||
|
`LLMProvisioning.CALLER_PROVIDED` with `Pricing(..., caller_pays_llm=True, ...)`.
|
||||||
|
Use `LLMProvisioning.PLATFORM` only when the user explicitly wants
|
||||||
|
platform-paid LLM usage. In either mode, read `ctx.llm`; never read
|
||||||
|
`A2A_LITELLM_KEY`, provider keys, or platform secrets directly.
|
||||||
|
|
||||||
Keep each `@skill` method `async`, put `RunContext[...]` immediately after
|
Keep each `@skill` method `async`, put `RunContext[...]` immediately after
|
||||||
`self`, and annotate every public argument. Do not use `*args` or `**kwargs`;
|
`self`, and annotate every public argument. Do not use `*args` or `**kwargs`;
|
||||||
they are rejected and would not publish a useful schema.
|
they are rejected and would not publish a useful schema.
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ Check these items:
|
|||||||
arguments.
|
arguments.
|
||||||
- The public schema is small and stable. No unbounded command strings, hidden
|
- The public schema is small and stable. No unbounded command strings, hidden
|
||||||
prompt fragments, or provider credentials as public inputs.
|
prompt fragments, or provider credentials as public inputs.
|
||||||
- The implementation reads `ctx.llm` when `llm_provisioning` is
|
- The implementation reads `ctx.llm` for both `CALLER_PROVIDED` and
|
||||||
`CALLER_PROVIDED`.
|
`PLATFORM` LLM provisioning.
|
||||||
|
- Generated/user agents should default to
|
||||||
|
`llm_provisioning = LLMProvisioning.CALLER_PROVIDED` and
|
||||||
|
`Pricing(..., caller_pays_llm=True, ...)`. If the user explicitly wants
|
||||||
|
platform-paid LLM usage, `LLMProvisioning.PLATFORM` is acceptable only if
|
||||||
|
the code still uses `ctx.llm` and never reads LiteLLM/provider keys directly.
|
||||||
- Any use of DeepAgents file tools passes `backend=ctx.workspace_backend()`.
|
- Any use of DeepAgents file tools passes `backend=ctx.workspace_backend()`.
|
||||||
- Any project skills are seeded into the backend and passed with
|
- Any project skills are seeded into the backend and passed with
|
||||||
`skills=skill_sources or None`.
|
`skills=skill_sources or None`.
|
||||||
@@ -81,8 +86,8 @@ frontend:
|
|||||||
For React/Vite frontends, expect `frontend/package.json`,
|
For React/Vite frontends, expect `frontend/package.json`,
|
||||||
`frontend/vite.config.js`, `frontend/src/App.jsx`, and `frontend/src/a2a.js`.
|
`frontend/vite.config.js`, `frontend/src/App.jsx`, and `frontend/src/a2a.js`.
|
||||||
For static frontends, expect `frontend/dist/index.html`. The browser code must
|
For static frontends, expect `frontend/dist/index.html`. The browser code must
|
||||||
not contain platform secrets, provider keys, hard-coded deployment URLs, or
|
not contain platform secrets, provider keys, LiteLLM keys, hard-coded deployment
|
||||||
private stack details. It should load `/app/config.json`, use
|
URLs, or private stack details. It should load `/app/config.json`, use
|
||||||
`/app/a2a-client.js` or generated config endpoints, and call only the public
|
`/app/a2a-client.js` or generated config endpoints, and call only the public
|
||||||
`@skill` schemas.
|
`@skill` schemas.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""LangChain tools the inner deepagents loop uses.
|
"""LangChain tools the inner deepagents loop uses.
|
||||||
|
|
||||||
- workspace file CRUD (boto3 → MinIO, scoped to the user's bucket)
|
- workspace file CRUD through the caller's scoped workspace grant
|
||||||
- sandbox python (for `a2a card` / `a2a validate` round-trips)
|
- sandbox python (for `a2a card` / `a2a validate` round-trips)
|
||||||
- cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf)
|
- cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf)
|
||||||
"""
|
"""
|
||||||
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
|
|||||||
from .config import Settings
|
from .config import Settings
|
||||||
|
|
||||||
|
|
||||||
A2A_PACK_MIN_VERSION = "0.1.25"
|
A2A_PACK_MIN_VERSION = "0.1.26"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -36,6 +36,8 @@ class ToolContext:
|
|||||||
bucket: str
|
bucket: str
|
||||||
settings: "Settings"
|
settings: "Settings"
|
||||||
cp_jwt: str | None = None
|
cp_jwt: str | None = None
|
||||||
|
workspace: Any | None = None
|
||||||
|
grant_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_live_card(
|
async def _wait_for_live_card(
|
||||||
@@ -95,6 +97,99 @@ def _ensure_bucket(s3: Any, bucket: str) -> None:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class _ObjectStore:
|
||||||
|
def iter_keys(self, prefix: str) -> list[str]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def put(self, key: str, body: bytes, content_type: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _S3ObjectStore(_ObjectStore):
|
||||||
|
def __init__(self, s3: Any, bucket: str) -> None:
|
||||||
|
self._s3 = s3
|
||||||
|
self._bucket = bucket
|
||||||
|
|
||||||
|
def iter_keys(self, prefix: str) -> list[str]:
|
||||||
|
keys: list[str] = []
|
||||||
|
paginator = self._s3.get_paginator("list_objects_v2")
|
||||||
|
for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix):
|
||||||
|
for obj in page.get("Contents") or ():
|
||||||
|
key = obj.get("Key")
|
||||||
|
if isinstance(key, str):
|
||||||
|
keys.append(key)
|
||||||
|
return sorted(keys)
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes:
|
||||||
|
obj = self._s3.get_object(Bucket=self._bucket, Key=key)
|
||||||
|
return obj["Body"].read()
|
||||||
|
|
||||||
|
def put(self, key: str, body: bytes, content_type: str) -> None:
|
||||||
|
self._s3.put_object(
|
||||||
|
Bucket=self._bucket,
|
||||||
|
Key=key,
|
||||||
|
Body=body,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._s3.delete_object(Bucket=self._bucket, Key=key)
|
||||||
|
|
||||||
|
|
||||||
|
class _WorkspaceObjectStore(_ObjectStore):
|
||||||
|
def __init__(self, workspace: Any) -> None:
|
||||||
|
self._workspace = workspace
|
||||||
|
|
||||||
|
def iter_keys(self, prefix: str) -> list[str]:
|
||||||
|
return sorted(
|
||||||
|
key
|
||||||
|
for key in self._workspace.iter_paths()
|
||||||
|
if isinstance(key, str) and key.startswith(prefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes:
|
||||||
|
return self._workspace.read_bytes(key)
|
||||||
|
|
||||||
|
def put(self, key: str, body: bytes, content_type: str) -> None:
|
||||||
|
del content_type
|
||||||
|
self._workspace.write_bytes(key, body)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._workspace.delete_path(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_from_args(
|
||||||
|
store_or_s3: Any,
|
||||||
|
bucket_or_prefix: str,
|
||||||
|
prefix: str | None,
|
||||||
|
) -> tuple[_ObjectStore, str]:
|
||||||
|
"""Support new helper calls with ObjectStore and old tests with fake S3."""
|
||||||
|
if isinstance(store_or_s3, _ObjectStore):
|
||||||
|
return store_or_s3, bucket_or_prefix
|
||||||
|
if hasattr(store_or_s3, "iter_keys") and hasattr(store_or_s3, "get"):
|
||||||
|
return store_or_s3, bucket_or_prefix
|
||||||
|
if prefix is None:
|
||||||
|
raise TypeError("prefix is required when passing an S3 client")
|
||||||
|
return _S3ObjectStore(store_or_s3, bucket_or_prefix), prefix
|
||||||
|
|
||||||
|
|
||||||
|
def _sandbox_headers(
|
||||||
|
bearer_token: str | None,
|
||||||
|
grant_token: str | None,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
if bearer_token:
|
||||||
|
return {"authorization": f"Bearer {bearer_token}"}
|
||||||
|
if grant_token:
|
||||||
|
return {"X-A2A-Grant": grant_token}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
_AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||||
_SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
|
_SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
|
||||||
_BUILDER_STATE_FILE = ".a2a-builder-state.json"
|
_BUILDER_STATE_FILE = ".a2a-builder-state.json"
|
||||||
@@ -128,8 +223,12 @@ def _builder_state_key(name: str) -> str:
|
|||||||
def build_tools(ctx: ToolContext) -> list[Any]:
|
def build_tools(ctx: ToolContext) -> list[Any]:
|
||||||
bucket = ctx.bucket
|
bucket = ctx.bucket
|
||||||
settings = ctx.settings
|
settings = ctx.settings
|
||||||
s3 = _s3(ctx)
|
if ctx.workspace is not None:
|
||||||
_ensure_bucket(s3, bucket)
|
store: _ObjectStore = _WorkspaceObjectStore(ctx.workspace)
|
||||||
|
else:
|
||||||
|
s3 = _s3(ctx)
|
||||||
|
_ensure_bucket(s3, bucket)
|
||||||
|
store = _S3ObjectStore(s3, bucket)
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def init_agent_template(
|
def init_agent_template(
|
||||||
@@ -190,16 +289,11 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return json.dumps({"error": str(exc)})
|
return json.dumps({"error": str(exc)})
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
paginator = s3.get_paginator("list_objects_v2")
|
for key in store.iter_keys(prefix):
|
||||||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
rel = key[len(prefix):]
|
||||||
for obj in page.get("Contents") or ():
|
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
|
||||||
rel = obj["Key"][len(prefix):]
|
continue
|
||||||
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
|
out.append({"path": rel, "size": len(store.get(key))})
|
||||||
continue
|
|
||||||
out.append({
|
|
||||||
"path": rel,
|
|
||||||
"size": int(obj.get("Size") or 0),
|
|
||||||
})
|
|
||||||
return json.dumps({"agent": name, "files": out})
|
return json.dumps({"agent": name, "files": out})
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -217,10 +311,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
|
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
|
||||||
return json.dumps({"error": f"{rel} is managed by agent-builder"})
|
return json.dumps({"error": f"{rel} is managed by agent-builder"})
|
||||||
key = prefix + rel
|
key = prefix + rel
|
||||||
s3.put_object(
|
store.put(key, content.encode("utf-8"), "text/plain; charset=utf-8")
|
||||||
Bucket=bucket, Key=key, Body=content.encode("utf-8"),
|
|
||||||
ContentType="text/plain; charset=utf-8",
|
|
||||||
)
|
|
||||||
return json.dumps({"ok": True, "path": key, "size": len(content)})
|
return json.dumps({"ok": True, "path": key, "size": len(content)})
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -235,10 +326,10 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
return json.dumps({"error": f"{rel} is managed by agent-builder"})
|
return json.dumps({"error": f"{rel} is managed by agent-builder"})
|
||||||
key = prefix + rel
|
key = prefix + rel
|
||||||
try:
|
try:
|
||||||
obj = s3.get_object(Bucket=bucket, Key=key)
|
data = store.get(key)
|
||||||
except ClientError as exc:
|
except Exception as exc: # noqa: BLE001
|
||||||
return json.dumps({"error": f"read failed: {exc}"})
|
return json.dumps({"error": f"read failed: {exc}"})
|
||||||
body = obj["Body"].read().decode("utf-8", errors="replace")
|
body = data.decode("utf-8", errors="replace")
|
||||||
return json.dumps({"path": key, "content": body[:200_000]})
|
return json.dumps({"path": key, "content": body[:200_000]})
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -275,12 +366,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
for rel, content in payloads.items():
|
for rel, content in payloads.items():
|
||||||
key = base + rel
|
key = base + rel
|
||||||
encoded = content.encode("utf-8")
|
encoded = content.encode("utf-8")
|
||||||
s3.put_object(
|
store.put(key, encoded, "text/plain; charset=utf-8")
|
||||||
Bucket=bucket,
|
|
||||||
Key=key,
|
|
||||||
Body=encoded,
|
|
||||||
ContentType="text/plain; charset=utf-8",
|
|
||||||
)
|
|
||||||
written.append({"path": key, "size": len(encoded)})
|
written.append({"path": key, "size": len(encoded)})
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -308,7 +394,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
|
|
||||||
# Bundle the workspace files into the sandbox via base64 tar so
|
# Bundle the workspace files into the sandbox via base64 tar so
|
||||||
# the sandbox doesn't need MinIO read access for this skill.
|
# the sandbox doesn't need MinIO read access for this skill.
|
||||||
bundle_bytes = _tarball_workspace_dir(s3, bucket, prefix)
|
bundle_bytes = _tarball_workspace_dir(store, prefix)
|
||||||
if not bundle_bytes:
|
if not bundle_bytes:
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"error": "no files in agents/" + name + "/ — write some first",
|
"error": "no files in agents/" + name + "/ — write some first",
|
||||||
@@ -331,10 +417,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
" a2a frontend info --project . || true\n"
|
" a2a frontend info --project . || true\n"
|
||||||
"fi\n"
|
"fi\n"
|
||||||
)
|
)
|
||||||
headers = (
|
headers = _sandbox_headers(settings.sandbox_token, ctx.grant_token)
|
||||||
{"authorization": f"Bearer {settings.sandbox_token}"}
|
|
||||||
if settings.sandbox_token else None
|
|
||||||
)
|
|
||||||
async with httpx.AsyncClient(timeout=settings.sandbox_timeout_s + 30) as c:
|
async with httpx.AsyncClient(timeout=settings.sandbox_timeout_s + 30) as c:
|
||||||
r = await c.post(
|
r = await c.post(
|
||||||
f"{settings.sandbox_url}/v1/run_shell",
|
f"{settings.sandbox_url}/v1/run_shell",
|
||||||
@@ -399,7 +482,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
|
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return json.dumps({"error": str(exc)})
|
return json.dumps({"error": str(exc)})
|
||||||
builder_state = _read_builder_state(s3, bucket, name)
|
builder_state = _read_builder_state(store, name)
|
||||||
drift = _deployment_drift_error(
|
drift = _deployment_drift_error(
|
||||||
name,
|
name,
|
||||||
latest_deploy,
|
latest_deploy,
|
||||||
@@ -412,7 +495,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
if force:
|
if force:
|
||||||
base_head = None
|
base_head = None
|
||||||
|
|
||||||
bundle_bytes = _tarball_workspace_dir(s3, bucket, prefix)
|
bundle_bytes = _tarball_workspace_dir(store, prefix)
|
||||||
if not bundle_bytes:
|
if not bundle_bytes:
|
||||||
return json.dumps({"error": f"no files at agents/{name}/"})
|
return json.dumps({"error": f"no files at agents/{name}/"})
|
||||||
# Read the entrypoint from a2a.yaml so we can pass it verbatim.
|
# Read the entrypoint from a2a.yaml so we can pass it verbatim.
|
||||||
@@ -446,8 +529,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
head_sha = body.get("head_sha")
|
head_sha = body.get("head_sha")
|
||||||
if isinstance(head_sha, str) and head_sha:
|
if isinstance(head_sha, str) and head_sha:
|
||||||
_write_builder_state(
|
_write_builder_state(
|
||||||
s3,
|
store,
|
||||||
bucket,
|
|
||||||
name,
|
name,
|
||||||
{
|
{
|
||||||
"agent": name,
|
"agent": name,
|
||||||
@@ -579,10 +661,9 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
return json.dumps({"error": f"invalid source tarball: {exc}"})
|
return json.dumps({"error": f"invalid source tarball: {exc}"})
|
||||||
if not files:
|
if not files:
|
||||||
return json.dumps({"error": "managed repo source export was empty"})
|
return json.dumps({"error": "managed repo source export was empty"})
|
||||||
written = _replace_workspace_files(s3, bucket, prefix, files)
|
written = _replace_workspace_files(store, prefix, files)
|
||||||
_write_builder_state(
|
_write_builder_state(
|
||||||
s3,
|
store,
|
||||||
bucket,
|
|
||||||
name,
|
name,
|
||||||
{
|
{
|
||||||
"agent": name,
|
"agent": name,
|
||||||
@@ -691,58 +772,46 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
# --- helpers (not tools) ---
|
# --- helpers (not tools) ---
|
||||||
|
|
||||||
|
|
||||||
def _tarball_workspace_dir(s3: Any, bucket: str, prefix: str) -> bytes:
|
def _tarball_workspace_dir(
|
||||||
"""Pull every object under ``prefix`` from MinIO and produce a
|
store_or_s3: Any,
|
||||||
gzipped tarball whose entries are relative to ``prefix``."""
|
bucket_or_prefix: str,
|
||||||
paginator = s3.get_paginator("list_objects_v2")
|
prefix: str | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Pull every object under ``prefix`` and produce a relative tarball."""
|
||||||
|
store, prefix = _store_from_args(store_or_s3, bucket_or_prefix, prefix)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
added = False
|
added = False
|
||||||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
for key in store.iter_keys(prefix):
|
||||||
for obj in page.get("Contents") or ():
|
rel = key[len(prefix):]
|
||||||
key = obj["Key"]
|
if not rel:
|
||||||
rel = key[len(prefix):]
|
continue
|
||||||
if not rel:
|
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
|
||||||
continue
|
continue
|
||||||
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
|
body = store.get(key)
|
||||||
continue
|
info = tarfile.TarInfo(name=rel)
|
||||||
body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
|
info.size = len(body)
|
||||||
info = tarfile.TarInfo(name=rel)
|
info.mode = 0o644
|
||||||
info.size = len(body)
|
tf.addfile(info, io.BytesIO(body))
|
||||||
info.mode = 0o644
|
added = True
|
||||||
tf.addfile(info, io.BytesIO(body))
|
|
||||||
added = True
|
|
||||||
return buf.getvalue() if added else b""
|
return buf.getvalue() if added else b""
|
||||||
|
|
||||||
|
|
||||||
def _read_builder_state(s3: Any, bucket: str, name: str) -> dict[str, Any]:
|
def _read_builder_state(store: _ObjectStore, name: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
obj = s3.get_object(Bucket=bucket, Key=_builder_state_key(name))
|
data = json.loads(store.get(_builder_state_key(name)).decode("utf-8"))
|
||||||
except ClientError as exc:
|
except Exception: # noqa: BLE001
|
||||||
code = exc.response.get("Error", {}).get("Code")
|
|
||||||
if code in {"404", "NoSuchKey", "NotFound"}:
|
|
||||||
return {}
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
data = json.loads(obj["Body"].read().decode("utf-8"))
|
|
||||||
except (TypeError, ValueError, UnicodeDecodeError):
|
|
||||||
return {}
|
return {}
|
||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _write_builder_state(
|
def _write_builder_state(
|
||||||
s3: Any,
|
store: _ObjectStore,
|
||||||
bucket: str,
|
|
||||||
name: str,
|
name: str,
|
||||||
state: dict[str, Any],
|
state: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
body = json.dumps(state, sort_keys=True, indent=2).encode("utf-8")
|
body = json.dumps(state, sort_keys=True, indent=2).encode("utf-8")
|
||||||
s3.put_object(
|
store.put(_builder_state_key(name), body, "application/json")
|
||||||
Bucket=bucket,
|
|
||||||
Key=_builder_state_key(name),
|
|
||||||
Body=body,
|
|
||||||
ContentType="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_skill_md(skill_name: str, description: str, instructions: str) -> str:
|
def _render_skill_md(skill_name: str, description: str, instructions: str) -> str:
|
||||||
@@ -798,16 +867,9 @@ def _safe_supporting_skill_path(path: str) -> str:
|
|||||||
return rel
|
return rel
|
||||||
|
|
||||||
|
|
||||||
def _delete_workspace_objects(s3: Any, bucket: str, prefix: str) -> None:
|
def _delete_workspace_objects(store: _ObjectStore, prefix: str) -> None:
|
||||||
# MinIO rejects boto3's batch DeleteObjects request in this environment
|
for key in store.iter_keys(prefix):
|
||||||
# when Content-MD5 is not present. Per-object deletes avoid that fragile
|
store.delete(key)
|
||||||
# API path and are fine for agent source workspaces.
|
|
||||||
paginator = s3.get_paginator("list_objects_v2")
|
|
||||||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
|
||||||
for obj in page.get("Contents") or ():
|
|
||||||
key = obj.get("Key")
|
|
||||||
if key:
|
|
||||||
s3.delete_object(Bucket=bucket, Key=key)
|
|
||||||
|
|
||||||
|
|
||||||
def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
|
def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
|
||||||
@@ -834,21 +896,25 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
|
|||||||
|
|
||||||
|
|
||||||
def _replace_workspace_files(
|
def _replace_workspace_files(
|
||||||
s3: Any,
|
store_or_s3: Any,
|
||||||
bucket: str,
|
bucket_or_prefix: str,
|
||||||
prefix: str,
|
prefix_or_files: str | dict[str, bytes],
|
||||||
files: dict[str, bytes],
|
files: dict[str, bytes] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
_delete_workspace_objects(s3, bucket, prefix)
|
if files is None:
|
||||||
|
if not isinstance(prefix_or_files, dict):
|
||||||
|
raise TypeError("files are required")
|
||||||
|
store, prefix = _store_from_args(store_or_s3, bucket_or_prefix, None)
|
||||||
|
files = prefix_or_files
|
||||||
|
else:
|
||||||
|
if not isinstance(prefix_or_files, str):
|
||||||
|
raise TypeError("prefix must be a string")
|
||||||
|
store, prefix = _store_from_args(store_or_s3, bucket_or_prefix, prefix_or_files)
|
||||||
written: list[dict[str, Any]] = []
|
written: list[dict[str, Any]] = []
|
||||||
|
_delete_workspace_objects(store, prefix)
|
||||||
for rel, body in sorted(files.items()):
|
for rel, body in sorted(files.items()):
|
||||||
key = prefix + rel
|
key = prefix + rel
|
||||||
s3.put_object(
|
store.put(key, body, "application/octet-stream")
|
||||||
Bucket=bucket,
|
|
||||||
Key=key,
|
|
||||||
Body=body,
|
|
||||||
ContentType="application/octet-stream",
|
|
||||||
)
|
|
||||||
written.append({"path": rel, "size": len(body)})
|
written.append({"path": rel, "size": len(body)})
|
||||||
return written
|
return written
|
||||||
|
|
||||||
|
|||||||
@@ -40,29 +40,11 @@ spec:
|
|||||||
value: "1200"
|
value: "1200"
|
||||||
- name: DEPLOY_WAIT_TIMEOUT_S
|
- name: DEPLOY_WAIT_TIMEOUT_S
|
||||||
value: "900"
|
value: "900"
|
||||||
- name: A2A_PLATFORM_SECRET
|
- name: A2A_GRANT_VERIFYING_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef: {name: platform-secrets, key: platform_secret}
|
secretKeyRef: {name: platform-secrets, key: grant_verifying_key}
|
||||||
- name: A2A_LITELLM_URL
|
|
||||||
value: http://litellm.llm.svc.cluster.local:4000
|
|
||||||
- name: A2A_LITELLM_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef: {name: litellm-credentials, key: api_key}
|
|
||||||
- name: A2A_LITELLM_MODEL
|
|
||||||
value: gpt-5.5
|
|
||||||
- name: A2A_CP_URL
|
- name: A2A_CP_URL
|
||||||
value: http://control-plane.control-plane.svc.cluster.local
|
value: http://control-plane.control-plane.svc.cluster.local
|
||||||
- name: A2A_MINIO_ENDPOINT
|
|
||||||
value: http://microcash-infra-minio.microcash-infra.svc.cluster.local:9000
|
|
||||||
- name: A2A_MINIO_ACCESS_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef: {name: minio-credentials, key: access_key}
|
|
||||||
- name: A2A_MINIO_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef: {name: minio-credentials, key: secret_key}
|
|
||||||
- name: A2A_SANDBOX_TOKEN
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef: {name: sandbox-auth, key: token}
|
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet: {path: /healthz, port: 8000}
|
httpGet: {path: /healthz, port: 8000}
|
||||||
initialDelaySeconds: 8
|
initialDelaySeconds: 8
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
a2a-pack>=0.1.25
|
a2a-pack>=0.1.26
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
boto3>=1.34
|
boto3>=1.34
|
||||||
deepagents>=0.5.0
|
deepagents>=0.5.0
|
||||||
|
|||||||
Reference in New Issue
Block a user