wire.py
python
| 1 | """Wire protocol Pydantic models — Muse CLI native format. |
| 2 | |
| 3 | These models match the Muse CLI ``HttpTransport`` wire format exactly. |
| 4 | All fields are snake_case to match Muse's internal CommitDict/SnapshotDict/ |
| 5 | ObjectPayload TypedDicts. |
| 6 | |
| 7 | The wire protocol is intentionally separate from the REST API's CamelModel: |
| 8 | Wire protocol /wire/repos/{repo_id}/ ← Muse CLI speaks here |
| 9 | REST API /api/repos/{id}/ ← agents and integrations speak here |
| 10 | MCP /mcp ← agents speak here too |
| 11 | |
| 12 | Denial-of-Service limits |
| 13 | ------------------------ |
| 14 | All list fields that arrive over the network are capped so a single large |
| 15 | request cannot exhaust memory or DB connections: |
| 16 | |
| 17 | MAX_COMMITS_PER_PUSH = 1 000 — one push should carry at most 1k commits |
| 18 | MAX_OBJECTS_PER_PUSH = 1 000 — ditto for binary blobs |
| 19 | MAX_SNAPSHOTS_PER_PUSH = 1 000 — ditto for snapshot manifests |
| 20 | MAX_WANT_PER_FETCH = 1 000 — fetch want/have lists |
| 21 | MAX_B64_SIZE = 52_000_000 — ~38 MB decoded (base64 overhead ≈1.37×) |
| 22 | single-object cap; 100 MB objects must |
| 23 | use direct S3/R2 upload instead. |
| 24 | """ |
| 25 | from __future__ import annotations |
| 26 | |
| 27 | from typing import Any |
| 28 | |
| 29 | from pydantic import BaseModel, Field, field_validator |
| 30 | |
| 31 | # ── Per-request DoS limits (enforced via Pydantic max_length) ─────────────── |
| 32 | MAX_COMMITS_PER_PUSH: int = 1_000 |
| 33 | MAX_OBJECTS_PER_PUSH: int = 1_000 |
| 34 | MAX_SNAPSHOTS_PER_PUSH: int = 1_000 |
| 35 | MAX_WANT_PER_FETCH: int = 1_000 |
| 36 | # Base64 string length limit — base64 expands by ~1.37×, so 52 MB b64 ≈ 38 MB raw. |
| 37 | MAX_B64_SIZE: int = 52_000_000 |
| 38 | |
| 39 | |
| 40 | class WireCommit(BaseModel): |
| 41 | """Muse native commit record — mirrors CommitDict from muse.core.store.""" |
| 42 | |
| 43 | commit_id: str |
| 44 | repo_id: str = "" |
| 45 | branch: str = "" |
| 46 | snapshot_id: str | None = None |
| 47 | message: str = "" |
| 48 | committed_at: str = "" # ISO-8601 UTC string |
| 49 | parent_commit_id: str | None = None # first parent (linear history) |
| 50 | parent2_commit_id: str | None = None # second parent (merge commits) |
| 51 | author: str = "" |
| 52 | metadata: dict[str, str] = Field(default_factory=dict) |
| 53 | structured_delta: Any = None # domain-specific delta blob |
| 54 | sem_ver_bump: str = "none" # "none" | "patch" | "minor" | "major" |
| 55 | breaking_changes: list[str] = Field(default_factory=list) |
| 56 | agent_id: str = "" |
| 57 | model_id: str = "" |
| 58 | toolchain_id: str = "" |
| 59 | prompt_hash: str = "" |
| 60 | signature: str = "" |
| 61 | signer_key_id: str = "" |
| 62 | format_version: int = 1 |
| 63 | reviewed_by: list[str] = Field(default_factory=list) |
| 64 | test_runs: int = 0 |
| 65 | |
| 66 | model_config = {"extra": "ignore"} # tolerate future Muse fields gracefully |
| 67 | |
| 68 | |
| 69 | class WireSnapshot(BaseModel): |
| 70 | """Muse native snapshot — mirrors SnapshotDict from muse.core.store. |
| 71 | |
| 72 | The manifest maps file paths to content-addressed object IDs, |
| 73 | e.g. ``{"muse/core/pack.py": "sha256:abc123..."}``. |
| 74 | """ |
| 75 | |
| 76 | snapshot_id: str |
| 77 | # max_length caps the number of manifest entries — a 10 000-file snapshot |
| 78 | # would already be pathologically large; prevent unbounded dict parsing. |
| 79 | manifest: dict[str, str] = Field(default_factory=dict, max_length=10_000) |
| 80 | created_at: str = "" |
| 81 | |
| 82 | model_config = {"extra": "ignore"} |
| 83 | |
| 84 | |
| 85 | class WireObject(BaseModel): |
| 86 | """Content-addressed object payload — mirrors ObjectPayload from muse.core.pack.""" |
| 87 | |
| 88 | object_id: str |
| 89 | content_b64: str = Field(max_length=MAX_B64_SIZE) |
| 90 | # path is not in the Muse CLI ObjectPayload TypedDict but we accept it when present |
| 91 | path: str = Field(default="", max_length=4096) |
| 92 | |
| 93 | model_config = {"extra": "ignore"} |
| 94 | |
| 95 | @field_validator("content_b64") |
| 96 | @classmethod |
| 97 | def _check_b64_size(cls, v: str) -> str: |
| 98 | """Reject oversized objects before attempting base64 decode. |
| 99 | |
| 100 | Checking ``len(v)`` is O(1) on CPython (str stores its length) and |
| 101 | prevents the memory spike that would occur when decoding a multi-GB |
| 102 | string into bytes. |
| 103 | """ |
| 104 | if len(v) > MAX_B64_SIZE: |
| 105 | raise ValueError( |
| 106 | f"content_b64 exceeds maximum size ({MAX_B64_SIZE} chars). " |
| 107 | "Upload large objects directly to S3/R2 instead." |
| 108 | ) |
| 109 | return v |
| 110 | |
| 111 | |
| 112 | class WireBundle(BaseModel): |
| 113 | """A pack bundle sent in a push request. |
| 114 | |
| 115 | Mirrors PackBundle from muse.core.pack. All fields are optional because |
| 116 | a minimal push may only contain commits (no new objects). |
| 117 | |
| 118 | List lengths are capped to prevent DoS via an oversized single request. |
| 119 | See the module-level ``MAX_*`` constants for the exact limits. |
| 120 | """ |
| 121 | |
| 122 | commits: list[WireCommit] = Field(default_factory=list, max_length=MAX_COMMITS_PER_PUSH) |
| 123 | snapshots: list[WireSnapshot] = Field(default_factory=list, max_length=MAX_SNAPSHOTS_PER_PUSH) |
| 124 | objects: list[WireObject] = Field(default_factory=list, max_length=MAX_OBJECTS_PER_PUSH) |
| 125 | branch_heads: dict[str, str] = Field(default_factory=dict) |
| 126 | |
| 127 | |
| 128 | class WirePushRequest(BaseModel): |
| 129 | """Body for ``POST /wire/repos/{repo_id}/push``. |
| 130 | |
| 131 | Matches the payload built by HttpTransport.push_pack(): |
| 132 | ``{"bundle": {...}, "branch": "main", "force": false}`` |
| 133 | """ |
| 134 | |
| 135 | bundle: WireBundle |
| 136 | branch: str |
| 137 | force: bool = False |
| 138 | |
| 139 | |
| 140 | class WireFetchRequest(BaseModel): |
| 141 | """Body for ``POST /wire/repos/{repo_id}/fetch``. |
| 142 | |
| 143 | Matches HttpTransport.fetch_pack() payload: |
| 144 | ``{"want": [...sha...], "have": [...sha...]}`` |
| 145 | |
| 146 | ``want`` — commit SHAs the client wants. |
| 147 | ``have`` — commit SHAs the client already has (exclusion list). |
| 148 | """ |
| 149 | |
| 150 | want: list[str] = Field(default_factory=list, max_length=MAX_WANT_PER_FETCH) |
| 151 | have: list[str] = Field(default_factory=list, max_length=MAX_WANT_PER_FETCH) |
| 152 | |
| 153 | |
| 154 | class WireRefsResponse(BaseModel): |
| 155 | """Response for ``GET /wire/repos/{repo_id}/refs``. |
| 156 | |
| 157 | Parsed by HttpTransport._parse_remote_info() into RemoteInfo. |
| 158 | """ |
| 159 | |
| 160 | repo_id: str |
| 161 | domain: str |
| 162 | default_branch: str |
| 163 | branch_heads: dict[str, str] |
| 164 | |
| 165 | |
| 166 | class WirePushResponse(BaseModel): |
| 167 | """Response for ``POST /wire/repos/{repo_id}/push``. |
| 168 | |
| 169 | Parsed by HttpTransport._parse_push_result() into PushResult. |
| 170 | ``branch_heads`` is what the Muse CLI reads; ``remote_head`` is bonus |
| 171 | information for MCP consumers. |
| 172 | """ |
| 173 | |
| 174 | ok: bool |
| 175 | message: str |
| 176 | branch_heads: dict[str, str] |
| 177 | remote_head: str = "" |
| 178 | |
| 179 | |
| 180 | class WireFetchResponse(BaseModel): |
| 181 | """Response for ``POST /wire/repos/{repo_id}/fetch``. |
| 182 | |
| 183 | Parsed by HttpTransport._parse_bundle() into PackBundle. |
| 184 | """ |
| 185 | |
| 186 | commits: list[WireCommit] = Field(default_factory=list) |
| 187 | snapshots: list[WireSnapshot] = Field(default_factory=list) |
| 188 | objects: list[WireObject] = Field(default_factory=list) |
| 189 | branch_heads: dict[str, str] = Field(default_factory=dict) |