200a113cce
Operators can now declare:
agent_provider:
template: claude
auth_token: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
and the provisioner injects a provider-owned api.anthropic.com egress
route (Bearer, tls_passthrough) rather than requiring a manually
declared route with the former claude_code_oauth role.
Changes:
- Add auth_token field to AgentProvider; validate claude-only.
- Remove claude_code_oauth from EGRESS_ROLES / PROVIDER_EGRESS_ROLES.
Manifests that declare the role now fail at parse time with "unknown
role" — the provisioner owns the route.
- agent_provision_plan: replace manifest_egress_routes/has_provider_auth
with auth_token; Claude branch injects the api.anthropic.com route,
placeholder env, and nonessential-traffic flags when auth_token is set.
- Add hidden_env_names: frozenset[str] to AgentProvisionPlan; Claude
branch populates it with CLAUDE_CODE_OAUTH_TOKEN.
- Remove auth_role from AgentProviderRuntime and placeholder_env_for().
- print_util.visible_agent_env_names: accept hidden_env_names from the
plan instead of dispatching on agent_provider_template.
- Both backends: drop manifest_egress_routes call, pass auth_token.
- PRD 0029 rescoped to cover both Codex and Claude provider auth.
Assisted-by: Claude Code
192 lines
7.3 KiB
Python
192 lines
7.3 KiB
Python
"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c).
|
|
|
|
Resolves the per-bottle docker subnet + bundle IP and assembles
|
|
the guest env. The agent's docker image build → smolmachine
|
|
pack pipeline runs in `launch.launch`, not here, so the
|
|
dashboard's preflight modal isn't garbled by docker-build output
|
|
before the operator has confirmed.
|
|
|
|
No VM bringup — that's `launch.launch`'s job."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
|
|
from ...agent_provider import agent_provision_plan, runtime_for
|
|
from ...backend import BottleSpec
|
|
from ...backend.docker.bottle_state import (
|
|
BottleMetadata,
|
|
agent_state_dir,
|
|
bottle_identity,
|
|
egress_state_dir,
|
|
git_gate_state_dir,
|
|
pipelock_state_dir,
|
|
supervise_state_dir,
|
|
write_metadata,
|
|
)
|
|
from ...egress import Egress
|
|
from ...git_gate import GitGate
|
|
from ...pipelock import PipelockProxy
|
|
from ...supervise import Supervise
|
|
from .bottle_plan import SmolmachinesBottlePlan
|
|
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
|
|
|
|
|
# Gateway ports the bundle exposes inside its container — pipelock
|
|
# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent
|
|
# inside the smolvm guest dials these on the bundle's pinned IP.
|
|
_BUNDLE_PIPELOCK_PORT = 8888
|
|
_BUNDLE_GIT_GATE_PORT = 9418
|
|
_BUNDLE_SUPERVISE_PORT = 9100
|
|
|
|
|
|
def resolve_plan(
|
|
spec: BottleSpec, *, stage_dir: Path
|
|
) -> SmolmachinesBottlePlan:
|
|
"""Materialize the smolmachines plan. The bundle's docker
|
|
subnet + pinned IP are derived from the slug; the agent's
|
|
`.smolmachine` artifact is built (or cache-hit) here so
|
|
launch's `machine create --from` boots without a registry
|
|
pull. Per-bottle guest env + the TSI allow_cidrs land on the
|
|
plan for launch to pass straight through to
|
|
`machine create` flags."""
|
|
smolmachines_preflight()
|
|
|
|
manifest = spec.manifest
|
|
bottle = manifest.bottle_for(spec.agent_name)
|
|
provider = bottle.agent_provider
|
|
provider_runtime = runtime_for(provider.template)
|
|
|
|
slug = spec.identity or bottle_identity(spec.agent_name)
|
|
|
|
# Record minimal metadata so `cli.py resume` can recover the
|
|
# slug. Same schema as the docker backend.
|
|
write_metadata(BottleMetadata(
|
|
identity=slug,
|
|
agent_name=spec.agent_name,
|
|
cwd=spec.user_cwd if spec.copy_cwd else "",
|
|
copy_cwd=spec.copy_cwd,
|
|
started_at=datetime.now(timezone.utc).isoformat(),
|
|
# No compose project for smolmachines bottles; chunk 4
|
|
# will give dashboard discovery a backend-specific path.
|
|
compose_project="",
|
|
))
|
|
|
|
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
|
|
|
# Agent's env: the prepare-time view doesn't yet know the
|
|
# host loopback ports the bundle's daemons get published on
|
|
# (those come from docker AFTER `docker run` returns), so
|
|
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are
|
|
# populated in launch.py and stamped onto guest_env there.
|
|
# What we set here is the part that doesn't depend on
|
|
# bundle bringup — bottle.env literals, the empty-NO_PROXY
|
|
# safe default, and the TLS trust env trio
|
|
# (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE)
|
|
# pointing at Debian's update-ca-certificates output bundle.
|
|
guest_env: dict[str, str] = {
|
|
**bottle.env,
|
|
"NO_PROXY": "localhost,127.0.0.1",
|
|
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
|
|
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
|
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
|
}
|
|
|
|
git_gate_dir = git_gate_state_dir(slug)
|
|
git_gate_dir.mkdir(parents=True, exist_ok=True)
|
|
git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir)
|
|
|
|
# Prompt file is always written (mode 0o600) so the in-VM
|
|
# path always exists. Content is the agent's `prompt`
|
|
# field (markdown body) — empty for agents with no prompt.
|
|
# claude-code reads it via --append-system-prompt-file only
|
|
# when non-empty, but the file must exist either way to
|
|
# match the docker backend's contract.
|
|
agent_dir = agent_state_dir(slug)
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
prompt_file = agent_dir / "prompt.txt"
|
|
agent = manifest.agents[spec.agent_name]
|
|
prompt_file.write_text(agent.prompt or "")
|
|
prompt_file.chmod(0o600)
|
|
|
|
machine_name = f"bot-bottle-{slug}"
|
|
# Stash the agent image ref — `launch.launch` runs the
|
|
# build → pack pipeline at bringup. Honors BOT_BOTTLE_IMAGE
|
|
# to match the docker backend's `resolve_plan` default.
|
|
agent_dockerfile_path = ""
|
|
if provider.dockerfile:
|
|
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
|
image_default = f"bot-bottle-{provider.template}:{slug}"
|
|
elif provider_runtime.dockerfile:
|
|
agent_dockerfile_path = provider_runtime.dockerfile
|
|
image_default = provider_runtime.image
|
|
else:
|
|
image_default = provider_runtime.image
|
|
agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
|
agent_provision = agent_provision_plan(
|
|
template=provider.template,
|
|
dockerfile=agent_dockerfile_path,
|
|
state_dir=agent_dir,
|
|
guest_home=os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node"),
|
|
guest_env=guest_env,
|
|
forward_host_credentials=provider.forward_host_credentials,
|
|
auth_token=provider.auth_token,
|
|
host_env=dict(os.environ),
|
|
)
|
|
merged_guest_env = dict(agent_provision.guest_env)
|
|
for key, val in agent_provision.env_vars.items():
|
|
merged_guest_env.setdefault(key, val)
|
|
agent_provision = replace(agent_provision, guest_env=merged_guest_env)
|
|
|
|
# Inner Plans for the four bundle daemons. The ABCs are
|
|
# platform-neutral — `.prepare()` writes config files + returns
|
|
# a Plan dataclass with no backend-specific assumptions. State
|
|
# dirs are still keyed by slug under the docker backend's
|
|
# bottle_state layout (shared on-host convention; not a docker
|
|
# dependency).
|
|
pipelock_dir = pipelock_state_dir(slug)
|
|
pipelock_dir.mkdir(parents=True, exist_ok=True)
|
|
proxy_plan = PipelockProxy().prepare(
|
|
bottle, slug, pipelock_dir, agent_provision.egress_routes,
|
|
)
|
|
|
|
egress_dir = egress_state_dir(slug)
|
|
egress_dir.mkdir(parents=True, exist_ok=True)
|
|
egress_plan = Egress().prepare(
|
|
bottle, slug, egress_dir, agent_provision.egress_routes,
|
|
)
|
|
|
|
supervise_plan = None
|
|
if bottle.supervise:
|
|
supervise_dir = supervise_state_dir(slug)
|
|
supervise_dir.mkdir(parents=True, exist_ok=True)
|
|
supervise_plan = Supervise().prepare(slug, supervise_dir)
|
|
|
|
return SmolmachinesBottlePlan(
|
|
spec=spec,
|
|
stage_dir=stage_dir,
|
|
slug=slug,
|
|
bundle_subnet=subnet,
|
|
bundle_gateway=gateway,
|
|
bundle_ip=bundle_ip,
|
|
machine_name=machine_name,
|
|
agent_image_ref=agent_image_ref,
|
|
guest_env=agent_provision.guest_env,
|
|
prompt_file=prompt_file,
|
|
proxy_plan=proxy_plan,
|
|
git_gate_plan=git_gate_plan,
|
|
egress_plan=egress_plan,
|
|
supervise_plan=supervise_plan,
|
|
agent_provision=agent_provision,
|
|
)
|
|
|
|
|
|
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
|
path = Path(os.path.expanduser(path_value))
|
|
if not path.is_absolute():
|
|
path = Path(spec.user_cwd) / path
|
|
return str(path)
|