refactor(manifest): convert TypedDict to frozen dataclasses
test / run tests/run_tests.py (pull_request) Successful in 14s

Replace the TypedDict + 14 manifest_* free functions with frozen
dataclasses (SshEntry, BottleEgress, Bottle, Agent, Manifest) carrying
their own validators and constructors. Call sites import Manifest and
chain attribute access; the manifest_* helpers and manifest_validate
are gone.

Behavior changes worth flagging:
- Agent.bottle is now required (was optional with a "(none)" fallback).
  Manifest.from_json_obj dies if any agent lacks a 'bottle' field or
  references an undefined bottle, where previously start.py raised the
  error lazily for the specific agent being launched.
- ssh.py now takes SshEntry instances; Host/IdentityFile shape checks
  moved upstream into Manifest construction, leaving only the IdentityFile
  filesystem-existence check in ssh_validate_entries.
- pipelock_bottle_allowlist's per-element string check is dropped — the
  Manifest validator enforces it at load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 21:20:15 -04:00
parent 36cb0c53bf
commit 1f36d53f7b
11 changed files with 387 additions and 408 deletions
+14 -19
View File
@@ -36,31 +36,26 @@ from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Any
from typing import Sequence
from .log import die, info
from .manifest import SshEntry
def ssh_validate_entries(entries: list[dict[str, Any]]) -> None:
"""Each entry must have Host + IdentityFile, and the IdentityFile
must exist on the host (after expanding leading ~)."""
def ssh_validate_entries(entries: Sequence[SshEntry]) -> None:
"""The IdentityFile must exist on the host (after expanding leading ~).
Host and IdentityFile shape are already enforced by Manifest validation."""
for entry in entries:
name = entry.get("Host", "")
key = entry.get("IdentityFile", "")
if not name:
die(f"ssh entry missing required field 'Host': {entry}")
if not key:
die(f"ssh entry '{name}' missing required field 'IdentityFile'")
key = _expand_tilde(key)
key = _expand_tilde(entry.IdentityFile)
if not os.path.isfile(key):
die(f"ssh key file not found for host '{name}': {key}")
die(f"ssh key file not found for host '{entry.Host}': {key}")
def ssh_setup(
container: str,
stage_dir: Path,
proxy_host_port: str,
entries: list[dict[str, Any]],
entries: Sequence[SshEntry],
) -> None:
"""Set up SSH in the container so node can authenticate using each
entry's key without the key file being readable by node."""
@@ -91,12 +86,12 @@ def ssh_setup(
container_key_paths: list[str] = []
for entry in entries:
name = entry["Host"]
key = _expand_tilde(entry["IdentityFile"])
hostname = entry["Hostname"]
user = entry["User"]
port = str(entry["Port"])
known_host_key = entry.get("KnownHostKey", "")
name = entry.Host
key = _expand_tilde(entry.IdentityFile)
hostname = entry.Hostname
user = entry.User
port = entry.Port
known_host_key = entry.KnownHostKey
key_basename = os.path.basename(key)
container_key_path = f"{keys_dir}/{key_basename}"