asbox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_sandbox/__init__.py +29 -0
- agent_sandbox/_bundled_guest/Dockerfile +28 -0
- agent_sandbox/_bundled_guest/agentd/__init__.py +8 -0
- agent_sandbox/_bundled_guest/agentd/app.py +120 -0
- agent_sandbox/_bundled_guest/agentd/exec.py +55 -0
- agent_sandbox/_bundled_guest/agentd/hooks.py +102 -0
- agent_sandbox/_bundled_guest/agentd/identity.py +66 -0
- agent_sandbox/_bundled_guest/agentd/ports.py +17 -0
- agent_sandbox/_bundled_guest/agentd/serve.py +58 -0
- agent_sandbox/_bundled_guest/agentd/state.py +58 -0
- agent_sandbox/_bundled_guest/requirements.txt +3 -0
- agent_sandbox/agent_client.py +140 -0
- agent_sandbox/cli/__init__.py +1 -0
- agent_sandbox/cli/main.py +1162 -0
- agent_sandbox/cli/state.py +78 -0
- agent_sandbox/control_plane.py +277 -0
- agent_sandbox/errors.py +19 -0
- agent_sandbox/guest_source.py +83 -0
- agent_sandbox/infra/__init__.py +31 -0
- agent_sandbox/infra/archive.py +107 -0
- agent_sandbox/infra/config.py +310 -0
- agent_sandbox/infra/provisioner.py +379 -0
- agent_sandbox/infra/resources.py +639 -0
- agent_sandbox/infra/state.py +148 -0
- agent_sandbox/models.py +99 -0
- agent_sandbox/ports.py +22 -0
- agent_sandbox/sandbox.py +235 -0
- agent_sandbox_mcp/__init__.py +11 -0
- agent_sandbox_mcp/__main__.py +8 -0
- agent_sandbox_mcp/config.py +145 -0
- agent_sandbox_mcp/envelope.py +71 -0
- agent_sandbox_mcp/resources.py +91 -0
- agent_sandbox_mcp/server.py +62 -0
- agent_sandbox_mcp/session.py +235 -0
- agent_sandbox_mcp/tools/__init__.py +32 -0
- agent_sandbox_mcp/tools/execution.py +64 -0
- agent_sandbox_mcp/tools/filesystem.py +193 -0
- agent_sandbox_mcp/tools/images.py +97 -0
- agent_sandbox_mcp/tools/lifecycle.py +232 -0
- agent_sandbox_mcp/tools/logs.py +102 -0
- agent_sandbox_mcp/tools/metrics.py +93 -0
- agent_sandbox_mcp/tools/runtime.py +85 -0
- asbox-0.1.0.dist-info/METADATA +807 -0
- asbox-0.1.0.dist-info/RECORD +47 -0
- asbox-0.1.0.dist-info/WHEEL +4 -0
- asbox-0.1.0.dist-info/entry_points.txt +3 -0
- asbox-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""agent_sandbox: embeddable Python SDK for microVM sandboxes on AWS Lambda MicroVMs.
|
|
2
|
+
|
|
3
|
+
A Python fork of the microsandbox developer experience. The local ``libkrun``
|
|
4
|
+
runtime is replaced by AWS Lambda MicroVMs (managed Firecracker); the SDK talks
|
|
5
|
+
to the ``lambda-microvms`` control plane and to a guest agent (``agentd``) baked
|
|
6
|
+
into the MicroVM image over its dedicated HTTPS endpoint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from agent_sandbox.errors import (
|
|
10
|
+
AgentError,
|
|
11
|
+
ControlPlaneError,
|
|
12
|
+
SandboxError,
|
|
13
|
+
SandboxTimeoutError,
|
|
14
|
+
)
|
|
15
|
+
from agent_sandbox.models import ExecResult, SandboxConfig, SandboxState
|
|
16
|
+
from agent_sandbox.sandbox import Sandbox
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Sandbox",
|
|
20
|
+
"SandboxConfig",
|
|
21
|
+
"SandboxState",
|
|
22
|
+
"ExecResult",
|
|
23
|
+
"SandboxError",
|
|
24
|
+
"ControlPlaneError",
|
|
25
|
+
"AgentError",
|
|
26
|
+
"SandboxTimeoutError",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# MicroVM image for AWS Lambda MicroVMs.
|
|
2
|
+
#
|
|
3
|
+
# Lambda MicroVMs runs this Dockerfile, starts the app, and snapshots the fully
|
|
4
|
+
# initialized environment. Every launched MicroVM resumes from that snapshot, so
|
|
5
|
+
# agentd is already listening the moment run-microvm returns.
|
|
6
|
+
#
|
|
7
|
+
# Lambda MicroVMs is ARM64-only.
|
|
8
|
+
FROM --platform=linux/arm64 python:3.12-slim
|
|
9
|
+
|
|
10
|
+
ENV PYTHONUNBUFFERED=1 \
|
|
11
|
+
PYTHONDONTWRITEBYTECODE=1 \
|
|
12
|
+
AGENTD_PORT=8080 \
|
|
13
|
+
AGENTD_HOOK_PORT=9000
|
|
14
|
+
|
|
15
|
+
WORKDIR /opt/agentd
|
|
16
|
+
|
|
17
|
+
COPY requirements.txt ./
|
|
18
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
19
|
+
|
|
20
|
+
COPY agentd ./agentd
|
|
21
|
+
|
|
22
|
+
# 8080: agentd application API (proxied to clients). 9000: lifecycle hooks
|
|
23
|
+
# (platform-only). Both must be exposed so the platform can reach the hook port.
|
|
24
|
+
EXPOSE 8080 9000
|
|
25
|
+
|
|
26
|
+
# serve.py starts both listeners in one process: agentd on AGENTD_PORT and the
|
|
27
|
+
# lifecycle-hook server on AGENTD_HOOK_PORT. User workloads are launched via exec.
|
|
28
|
+
CMD ["python", "-m", "agentd.serve"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""agentd: the in-VM guest agent.
|
|
2
|
+
|
|
3
|
+
Runs inside the Lambda MicroVM image and exposes a small HTTP API that the SDK's
|
|
4
|
+
``AgentClient`` calls to execute commands and read/write files. This is the
|
|
5
|
+
Python analogue of microsandbox's ``agentd`` guest agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""FastAPI application for the guest agent.
|
|
2
|
+
|
|
3
|
+
Auth note: authentication is handled by the Lambda MicroVMs ingress proxy, which
|
|
4
|
+
validates the ``X-aws-proxy-auth`` token before traffic reaches this process.
|
|
5
|
+
agentd therefore trusts inbound requests and does not re-validate the token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, HTTPException
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from agentd import state
|
|
19
|
+
from agentd.exec import run_command
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def _lifespan(_app: FastAPI):
|
|
24
|
+
# Signals the /ready lifecycle hook that agentd has fully started, so the
|
|
25
|
+
# platform snapshots a serving agentd rather than a half-booted one.
|
|
26
|
+
state.ready.set()
|
|
27
|
+
yield
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = FastAPI(title="agentd", version="0.1.0", lifespan=_lifespan)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExecRequest(BaseModel):
|
|
34
|
+
command: str
|
|
35
|
+
args: list[str] = Field(default_factory=list)
|
|
36
|
+
cwd: str | None = None
|
|
37
|
+
env: dict[str, str] | None = None
|
|
38
|
+
timeout: float | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExecResponse(BaseModel):
|
|
42
|
+
exit_code: int
|
|
43
|
+
stdout: str
|
|
44
|
+
stderr: str
|
|
45
|
+
encoding: str = "base64"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReadRequest(BaseModel):
|
|
49
|
+
path: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ReadResponse(BaseModel):
|
|
53
|
+
content: str
|
|
54
|
+
encoding: str = "base64"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WriteRequest(BaseModel):
|
|
58
|
+
path: str
|
|
59
|
+
content: str
|
|
60
|
+
encoding: str = "base64"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.get("/healthz")
|
|
64
|
+
async def healthz() -> dict[str, str]:
|
|
65
|
+
return {"status": "ok", "version": app.version}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.post("/v1/exec", response_model=ExecResponse)
|
|
69
|
+
async def exec_command(req: ExecRequest) -> ExecResponse:
|
|
70
|
+
try:
|
|
71
|
+
with state.track():
|
|
72
|
+
outcome = await run_command(
|
|
73
|
+
req.command,
|
|
74
|
+
req.args,
|
|
75
|
+
cwd=req.cwd,
|
|
76
|
+
env=req.env,
|
|
77
|
+
timeout=req.timeout,
|
|
78
|
+
)
|
|
79
|
+
except TimeoutError as exc:
|
|
80
|
+
raise HTTPException(status_code=504, detail="command timed out") from exc
|
|
81
|
+
except FileNotFoundError as exc:
|
|
82
|
+
raise HTTPException(status_code=400, detail=f"command not found: {req.command}") from exc
|
|
83
|
+
return ExecResponse(
|
|
84
|
+
exit_code=outcome.exit_code,
|
|
85
|
+
stdout=base64.b64encode(outcome.stdout).decode("ascii"),
|
|
86
|
+
stderr=base64.b64encode(outcome.stderr).decode("ascii"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.post("/v1/fs/read", response_model=ReadResponse)
|
|
91
|
+
async def read_file(req: ReadRequest) -> ReadResponse:
|
|
92
|
+
try:
|
|
93
|
+
with open(req.path, "rb") as fh:
|
|
94
|
+
data = fh.read()
|
|
95
|
+
except FileNotFoundError as exc:
|
|
96
|
+
raise HTTPException(status_code=404, detail=f"no such file: {req.path}") from exc
|
|
97
|
+
except OSError as exc:
|
|
98
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
99
|
+
return ReadResponse(content=base64.b64encode(data).decode("ascii"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.post("/v1/fs/write")
|
|
103
|
+
async def write_file(req: WriteRequest) -> dict[str, int]:
|
|
104
|
+
if req.encoding == "base64":
|
|
105
|
+
try:
|
|
106
|
+
data = base64.b64decode(req.content)
|
|
107
|
+
except (ValueError, TypeError) as exc:
|
|
108
|
+
raise HTTPException(status_code=400, detail="invalid base64 content") from exc
|
|
109
|
+
else:
|
|
110
|
+
data = req.content.encode("utf-8")
|
|
111
|
+
|
|
112
|
+
directory = os.path.dirname(req.path)
|
|
113
|
+
if directory:
|
|
114
|
+
os.makedirs(directory, exist_ok=True)
|
|
115
|
+
try:
|
|
116
|
+
with open(req.path, "wb") as fh:
|
|
117
|
+
fh.write(data)
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
120
|
+
return {"bytes_written": len(data)}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Command execution inside the guest.
|
|
2
|
+
|
|
3
|
+
Captures stdout, stderr, and the exit code. A dedicated module (mirroring
|
|
4
|
+
microsandbox's split between the agent loop and exec/session handling) so PTY
|
|
5
|
+
support can be added here later without touching the HTTP layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class ExecOutcome:
|
|
17
|
+
exit_code: int
|
|
18
|
+
stdout: bytes
|
|
19
|
+
stderr: bytes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def run_command(
|
|
23
|
+
command: str,
|
|
24
|
+
args: list[str] | None = None,
|
|
25
|
+
*,
|
|
26
|
+
cwd: str | None = None,
|
|
27
|
+
env: dict[str, str] | None = None,
|
|
28
|
+
timeout: float | None = None,
|
|
29
|
+
) -> ExecOutcome:
|
|
30
|
+
"""Run ``command`` with ``args`` and capture its output.
|
|
31
|
+
|
|
32
|
+
``env`` is merged onto the current process environment rather than replacing
|
|
33
|
+
it, so PATH and friends remain intact.
|
|
34
|
+
"""
|
|
35
|
+
argv = [command, *(args or [])]
|
|
36
|
+
merged_env = {**os.environ, **(env or {})}
|
|
37
|
+
|
|
38
|
+
proc = await asyncio.create_subprocess_exec(
|
|
39
|
+
*argv,
|
|
40
|
+
stdout=asyncio.subprocess.PIPE,
|
|
41
|
+
stderr=asyncio.subprocess.PIPE,
|
|
42
|
+
cwd=cwd,
|
|
43
|
+
env=merged_env,
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
47
|
+
except asyncio.TimeoutError:
|
|
48
|
+
proc.kill()
|
|
49
|
+
await proc.wait()
|
|
50
|
+
raise
|
|
51
|
+
return ExecOutcome(
|
|
52
|
+
exit_code=proc.returncode if proc.returncode is not None else -1,
|
|
53
|
+
stdout=stdout or b"",
|
|
54
|
+
stderr=stderr or b"",
|
|
55
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Lifecycle-hook HTTP server, called by the AWS Lambda MicroVMs platform.
|
|
2
|
+
|
|
3
|
+
This is the *lifecycle plane*: a separate FastAPI app (served on ``HOOK_PORT`` by
|
|
4
|
+
``serve.py``) that the platform invokes at image build and MicroVM state
|
|
5
|
+
transitions. It is never reached by application clients — the ``forward`` CLI
|
|
6
|
+
guard keeps auth tokens from ever being scoped to the hook port.
|
|
7
|
+
|
|
8
|
+
Contract: return 200 on success; return 503 from ``/ready`` and ``/validate``
|
|
9
|
+
when the app needs more time (the platform keeps polling until the configured
|
|
10
|
+
timeout). Handlers are kept fast and idempotent per the platform guidance.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
|
|
20
|
+
_PYTHON = sys.executable or "python3"
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, Request, Response
|
|
23
|
+
|
|
24
|
+
from agentd import identity, state
|
|
25
|
+
from agentd.exec import run_command
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("agentd.hooks")
|
|
28
|
+
|
|
29
|
+
hooks_app = FastAPI(title="agentd-hooks", version="0.1.0")
|
|
30
|
+
|
|
31
|
+
# The platform fixes these paths; only the port is ours to choose.
|
|
32
|
+
PREFIX = "/aws/lambda-microvms/runtime/v1"
|
|
33
|
+
|
|
34
|
+
# Suspend drain budget: how long /suspend waits for in-flight exec to finish.
|
|
35
|
+
# Must stay under the suspendTimeoutInSeconds configured in the image's --hooks
|
|
36
|
+
# (see agent_sandbox.infra.resources._default_hooks) or the platform gives up
|
|
37
|
+
# first. Overridable so it can track that value without editing code.
|
|
38
|
+
_SUSPEND_DRAIN_SECONDS = float(os.getenv("AGENTD_SUSPEND_DRAIN_SECONDS", "8"))
|
|
39
|
+
|
|
40
|
+
# /run fires once per boot-from-snapshot; guard against duplicate delivery so a
|
|
41
|
+
# stray call can't rotate identity out from under a running workload.
|
|
42
|
+
_run_lock = threading.Lock()
|
|
43
|
+
_run_done = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@hooks_app.post(f"{PREFIX}/ready")
|
|
47
|
+
async def ready() -> Response:
|
|
48
|
+
"""Build hook: 200 once agentd has started, else 503 so the platform retries."""
|
|
49
|
+
return Response(status_code=200 if state.ready.is_set() else 503)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@hooks_app.post(f"{PREFIX}/validate")
|
|
53
|
+
async def validate() -> Response:
|
|
54
|
+
"""Build hook: smoke-test the snapshot by running a trivial exec in-process."""
|
|
55
|
+
try:
|
|
56
|
+
outcome = await run_command(_PYTHON, ["-c", "pass"], timeout=10.0)
|
|
57
|
+
except Exception as exc: # noqa: BLE001 - any failure means "not valid yet"
|
|
58
|
+
log.warning("validate: exec failed: %s", exc)
|
|
59
|
+
return Response(status_code=503)
|
|
60
|
+
return Response(status_code=200 if outcome.exit_code == 0 else 503)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@hooks_app.post(f"{PREFIX}/run")
|
|
64
|
+
async def run(request: Request) -> Response:
|
|
65
|
+
"""Runtime hook: fires once after run-from-snapshot. Mint per-VM identity."""
|
|
66
|
+
global _run_done
|
|
67
|
+
with _run_lock:
|
|
68
|
+
if _run_done:
|
|
69
|
+
return Response(status_code=200) # idempotent: already ran this boot
|
|
70
|
+
payload = await request.body()
|
|
71
|
+
identity.regenerate("run", run_payload=payload or None)
|
|
72
|
+
# Mark done only AFTER a successful regenerate, so a transient failure leaves
|
|
73
|
+
# the flag clear and the platform's retry still gets a fresh identity.
|
|
74
|
+
with _run_lock:
|
|
75
|
+
_run_done = True
|
|
76
|
+
return Response(status_code=200)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@hooks_app.post(f"{PREFIX}/resume")
|
|
80
|
+
async def resume() -> Response:
|
|
81
|
+
"""Runtime hook: fires after SUSPENDED -> RUNNING. Reseed randomness/identity."""
|
|
82
|
+
identity.regenerate("resume")
|
|
83
|
+
return Response(status_code=200)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@hooks_app.post(f"{PREFIX}/suspend")
|
|
87
|
+
async def suspend() -> Response:
|
|
88
|
+
"""Runtime hook: fires before RUNNING -> SUSPENDED. Drain in-flight exec."""
|
|
89
|
+
state.wait_drain(_SUSPEND_DRAIN_SECONDS)
|
|
90
|
+
return Response(status_code=200)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@hooks_app.post(f"{PREFIX}/terminate")
|
|
94
|
+
async def terminate() -> Response:
|
|
95
|
+
"""Runtime hook: fires before termination. Flush logs; best-effort cleanup."""
|
|
96
|
+
for stream in (sys.stdout, sys.stderr):
|
|
97
|
+
try:
|
|
98
|
+
stream.flush()
|
|
99
|
+
except Exception: # noqa: BLE001 - best-effort
|
|
100
|
+
pass
|
|
101
|
+
logging.shutdown()
|
|
102
|
+
return Response(status_code=200)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Per-VM uniqueness state, driven by the ``/run`` and ``/resume`` hooks.
|
|
2
|
+
|
|
3
|
+
Every MicroVM resumes from the SAME memory+disk snapshot, so anything derived
|
|
4
|
+
from randomness at image-build time is IDENTICAL across VMs (the "snapshot
|
|
5
|
+
uniqueness" pitfall). :func:`regenerate` mints fresh, CSPRNG-backed identity and
|
|
6
|
+
reseeds the stdlib non-CSPRNG so each running VM diverges. It is called on
|
|
7
|
+
``/run`` (first boot from snapshot) and ``/resume``, and is written to a tmpfs
|
|
8
|
+
file so processes launched via ``/v1/exec`` can read it.
|
|
9
|
+
|
|
10
|
+
Both agentd and the hooks server share this module (same process), so reseeding
|
|
11
|
+
here is visible to agentd's request handlers too.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import secrets
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
# tmpfs path (per-VM, not part of the shared on-disk snapshot layout).
|
|
23
|
+
IDENTITY_PATH = os.environ.get("AGENTD_IDENTITY_PATH", "/run/agentd/identity")
|
|
24
|
+
|
|
25
|
+
_lock = threading.Lock()
|
|
26
|
+
_state: dict[str, object] = {
|
|
27
|
+
"vm_id": None,
|
|
28
|
+
"generation": 0,
|
|
29
|
+
"last_event": None,
|
|
30
|
+
"booted_at": None,
|
|
31
|
+
"run_payload": None,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def regenerate(event: str, *, run_payload: bytes | None = None) -> dict[str, object]:
|
|
36
|
+
"""Mint a fresh per-VM identity and reseed randomness.
|
|
37
|
+
|
|
38
|
+
Uses :mod:`secrets` (a CSPRNG pulling fresh kernel entropy) so the new id is
|
|
39
|
+
unique per VM even though the snapshot is shared, then reseeds :mod:`random`
|
|
40
|
+
so later non-CSPRNG use also diverges. Persisting is best-effort.
|
|
41
|
+
"""
|
|
42
|
+
with _lock:
|
|
43
|
+
_state["vm_id"] = secrets.token_hex(16)
|
|
44
|
+
_state["generation"] = int(_state["generation"]) + 1
|
|
45
|
+
_state["last_event"] = event
|
|
46
|
+
_state["booted_at"] = time.time()
|
|
47
|
+
if run_payload is not None:
|
|
48
|
+
_state["run_payload"] = run_payload.decode("utf-8", errors="replace")
|
|
49
|
+
random.seed(secrets.randbits(256))
|
|
50
|
+
_persist(str(_state["vm_id"]))
|
|
51
|
+
return dict(_state)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def current() -> dict[str, object]:
|
|
55
|
+
with _lock:
|
|
56
|
+
return dict(_state)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _persist(vm_id: str) -> None:
|
|
60
|
+
try:
|
|
61
|
+
os.makedirs(os.path.dirname(IDENTITY_PATH), exist_ok=True)
|
|
62
|
+
with open(IDENTITY_PATH, "w", encoding="utf-8") as fh:
|
|
63
|
+
fh.write(vm_id)
|
|
64
|
+
except OSError:
|
|
65
|
+
# Best-effort: identity is still available in-process via current().
|
|
66
|
+
pass
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Well-known ports for the guest, as a single source of truth.
|
|
2
|
+
|
|
3
|
+
``AGENT_PORT`` is agentd's application API (exec/fs), fronted by the platform's
|
|
4
|
+
auth-token proxy. ``HOOK_PORT`` is the lifecycle-hook server, called only by the
|
|
5
|
+
platform. Both are overridable via env; the Dockerfile bakes the defaults.
|
|
6
|
+
|
|
7
|
+
Invariant: ``HOOK_PORT`` here (what ``serve.py`` binds) must equal the SDK's
|
|
8
|
+
``agent_sandbox.ports.HOOK_PORT`` (what ``--hooks`` tells the platform). Both
|
|
9
|
+
default to 9000; override consistently on both sides.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
AGENT_PORT: int = int(os.getenv("AGENTD_PORT", "8080"))
|
|
17
|
+
HOOK_PORT: int = int(os.getenv("AGENTD_HOOK_PORT", "9000"))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Process entrypoint: serve agentd and the lifecycle hooks side by side.
|
|
2
|
+
|
|
3
|
+
One container runs one ``CMD``, so this single process starts both listeners:
|
|
4
|
+
|
|
5
|
+
* agentd's application API on ``AGENT_PORT`` (main thread) — fronted by the
|
|
6
|
+
platform's auth-token proxy, this is what the SDK/CLI talk to.
|
|
7
|
+
* the lifecycle-hook server on ``HOOK_PORT`` (daemon thread) — called only by the
|
|
8
|
+
platform. Bound to ``0.0.0.0`` because the platform reaches it over the guest's
|
|
9
|
+
network interface, not loopback.
|
|
10
|
+
|
|
11
|
+
Running both in one process lets the hooks reseed state agentd shares (see
|
|
12
|
+
``identity``/``state``); running the hooks on their own thread keeps ``/suspend``
|
|
13
|
+
responsive even while agentd is busy.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
|
|
22
|
+
import uvicorn
|
|
23
|
+
|
|
24
|
+
from agentd.ports import AGENT_PORT, HOOK_PORT
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("agentd.serve")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _serve_hooks() -> None:
|
|
30
|
+
# The hook server is required: the platform's /ready gates image build, and
|
|
31
|
+
# /suspend etc. gate runtime transitions. If it ever exits — failed bind,
|
|
32
|
+
# import error — don't let agentd keep the container alive without it (which
|
|
33
|
+
# would surface only as an opaque /ready timeout). Crash the process instead.
|
|
34
|
+
try:
|
|
35
|
+
uvicorn.run(
|
|
36
|
+
"agentd.hooks:hooks_app",
|
|
37
|
+
host="0.0.0.0",
|
|
38
|
+
port=HOOK_PORT,
|
|
39
|
+
log_level="warning",
|
|
40
|
+
)
|
|
41
|
+
except BaseException:
|
|
42
|
+
log.exception("hook server crashed")
|
|
43
|
+
finally:
|
|
44
|
+
os._exit(1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> None:
|
|
48
|
+
threading.Thread(target=_serve_hooks, name="agentd-hooks", daemon=True).start()
|
|
49
|
+
uvicorn.run(
|
|
50
|
+
"agentd.app:app",
|
|
51
|
+
host="0.0.0.0",
|
|
52
|
+
port=AGENT_PORT,
|
|
53
|
+
log_level="info",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared in-process state, visible to both server threads.
|
|
2
|
+
|
|
3
|
+
agentd (app plane) and the lifecycle hooks (:mod:`agentd.hooks`) run in the same
|
|
4
|
+
process but on separate threads/event loops (see ``serve.py``), so this module
|
|
5
|
+
uses plain :mod:`threading` primitives rather than asyncio ones:
|
|
6
|
+
|
|
7
|
+
* :data:`ready` — set once agentd's app has started; the ``/ready`` hook reads it
|
|
8
|
+
so the platform snapshots only a fully-booted agentd.
|
|
9
|
+
* :data:`inflight` — count of currently-running ``exec`` calls, so the
|
|
10
|
+
``/suspend`` hook can drain in-flight work before the VM is frozen.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
|
|
19
|
+
# Set by agentd's startup event; read by the /ready hook.
|
|
20
|
+
ready = threading.Event()
|
|
21
|
+
|
|
22
|
+
_lock = threading.Lock()
|
|
23
|
+
_inflight = 0
|
|
24
|
+
_drained = threading.Condition(_lock)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def track() -> Iterator[None]:
|
|
29
|
+
"""Count one in-flight exec for the duration of the ``with`` block."""
|
|
30
|
+
global _inflight
|
|
31
|
+
with _lock:
|
|
32
|
+
_inflight += 1
|
|
33
|
+
try:
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
with _lock:
|
|
37
|
+
_inflight -= 1
|
|
38
|
+
if _inflight == 0:
|
|
39
|
+
_drained.notify_all()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def inflight() -> int:
|
|
43
|
+
with _lock:
|
|
44
|
+
return _inflight
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def wait_drain(timeout: float) -> bool:
|
|
48
|
+
"""Block until no exec is in flight, or ``timeout`` elapses.
|
|
49
|
+
|
|
50
|
+
Returns ``True`` if fully drained, ``False`` on timeout. Used by ``/suspend``
|
|
51
|
+
so the snapshot isn't taken mid-command.
|
|
52
|
+
"""
|
|
53
|
+
with _drained:
|
|
54
|
+
if _inflight == 0:
|
|
55
|
+
return True
|
|
56
|
+
# wait_for returns True when the predicate held (drained), False on timeout.
|
|
57
|
+
drained = _drained.wait_for(lambda: _inflight == 0, timeout=timeout)
|
|
58
|
+
return drained
|