Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
- name: Pytest with coverage
run: |
uv pip install '.[test]'
python -W error::ResourceWarning -m pytest tests/ -m "not live" --cov=bambu_cli --cov-report=term-missing --cov-fail-under=83
python -W error::ResourceWarning -m pytest tests/ -m "not live" --cov=bambu_cli --cov-report=term-missing --cov-fail-under=86
- name: Syntax smoke
# Auto-discovers bambu_cli/**/*.py (see scripts/syntax_smoke.py).
run: python scripts/syntax_smoke.py
Expand Down
7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Logic lives in focused packages; `bambu_cli/bambu.py` is a **thin entrypoint** (

The rule exists because directories alone never held it: `protocols/`, `slicer/` and `download/` were already separate packages and still drifted — `slicer/output.py` imported a **private FTPS helper** to delete a partial file, so a change to Bambu transport code silently changed slicer behavior. If you need a helper in two adapters, push it down to rank 10 (that is what `fsutil.py` is for); do not import sideways.

Accepted debt lives in `ALLOWED` in that script, each entry with a reason. Shrink it; do not grow it. One edge is currently allowlisted: `context -> printer` (`RuntimeContext` lazily constructs a `BambuPrinter`; the real fix is a composition root that installs a printer factory).
Accepted debt lives in `ALLOWED` in that script, each entry with a reason. Shrink it; do not grow it. There are currently no allowlisted edges: `RuntimeContext.printer()` uses an injectable factory registered downward from `bambu_cli.printer`.

The same script also enforces `SEALED` — package internals no outside module may import. `bambu_cli.printables.client` is sealed because an adapter is only a sandbox if callers cannot reach past it. **Third-party integrations go behind an adapter that cannot raise:** `PrintablesAdapter.resolve()` returns a `PrintablesResolution` for every outcome, converting a renamed field or a redesigned error envelope into a typed `printables_contract_changed` result instead of a traceback in the middle of `plate job`. `KeyboardInterrupt`/`SystemExit` are deliberately the only things that still propagate.

Expand All @@ -104,8 +104,7 @@ When adding tests, follow [docs/test-backlog.md](docs/test-backlog.md) and the q

### Known architecture debt (honest)

- **`context.py` -> `printer.py`** (allowlisted in `scripts/check_layers.py`): `RuntimeContext.printer()` lazily constructs a `BambuPrinter`, so a core-services module depends on a transport facade. The fix is a composition root that installs a printer factory onto the context; deferred so the boundary work stayed reviewable.
- **`protocols/mqtt.py` is ~880 LOC** — the largest module in the package and still mixed-concern. Not split during the boundary pass on purpose: doing both at once makes the diff unreadable.
- **`protocols/mqtt.py` is a facade** over `mqtt_tls` / `mqtt_cmd` / `mqtt_print` / `mqtt_monitor` / `mqtt_session`. The old ~880 LOC hotspot was split; keep new MQTT logic in those siblings, not the facade.
- B.4 (cli extraction → paths/jsonio/argutils) and B.5 (single `verify_cert_fingerprint` in tlspin.py) both landed; see [docs/quality-roadmap.md](docs/quality-roadmap.md) for the current gap list.

## Camera snapshots for agents
Expand Down Expand Up @@ -136,7 +135,7 @@ Published on PyPI as `platecli`; the installed command is `plate`.
| Gate | Command / note |
|------|----------------|
| Default tests | `uv run python -m pytest tests/ -q -m "not live"` — never contacts a printer |
| Coverage (CI) | `--cov-fail-under=83` (CI run `31044588411` on `5b08720`, 2026-08-05: Windows 88.8% / Linux 3.9 89.3%, 3.12 89.2%, 3.14 89.2% / macOS 89.1%; A+ target **92%** — see roadmap) |
| Coverage (CI) | `--cov-fail-under=86` (CI run `31044588411` on `5b08720`, 2026-08-05: Windows 88.8% / Linux 3.9 89.3%, 3.12 89.2%, 3.14 89.2% / macOS 89.1%; A+ target **92%** — see roadmap) |
| Lint | `uvx ruff check bambu_cli` + `uvx ruff format --check bambu_cli` |
| Types | `uvx mypy -p bambu_cli` |
| Security lint | `uvx bandit -c pyproject.toml -r bambu_cli -ll` |
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ uv run python -m pytest tests/ -q -m "not live"

# Match CI hardness (ResourceWarning as error + coverage floor)
uv run python -W error::ResourceWarning -m pytest tests/ -m "not live" \
--cov=bambu_cli --cov-report=term-missing --cov-fail-under=83
--cov=bambu_cli --cov-report=term-missing --cov-fail-under=86

# Smokes used in CI — all of them (see the "lint" job in .github/workflows/ci.yml).
# These are NOT part of pytest; a green suite says nothing about them.
Expand Down Expand Up @@ -115,12 +115,12 @@ Agent/runtime rules: **[AGENTS.md](AGENTS.md)** (ships in sdist).
Threat model: **[SECURITY.md](SECURITY.md)** (ships in sdist).
JSON contracts: **[docs/api.md](docs/api.md)** + **[docs/schemas/](docs/schemas/)** (ship in sdist).

As of 2026-08-05 (0.5.0): overall **solid A− / A**. The 2026-07 audit's four
As of 2026-08-13: overall **A** (no scoreboard row below A−). The 2026-07 audit's four
architecture/contract gaps have since closed — the domain→`cli` helper extraction
(B.4), the single-sourced TLS pin verification (B.5), the remaining JSON schemas
(now *generated* from `bambu_cli/contracts/`, one per `--json` subcommand), and the
camera bind/pin-fallback hardenings. Main gaps to A+ / 1.0 are now coverage
(89.2% measured on CI's Linux legs, CI floor **83**, target 92) and the camera
(89.2% measured on CI's Linux legs, CI floor **86**, target 92) and the camera
residuals still listed in SECURITY.md. Do not read "A−/A" as "A+" — see the
scoreboard for what is actually ticked.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ Every command emits machine-readable `--json` output backed by published [JSON S
- **[User guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md)** — full setup, config reference, slicing & AMS mapping, print monitoring, and every flag
- **[Troubleshooting](https://github.com/DLANSAMA/platecli/blob/main/docs/troubleshooting.md)** — keyed by the error message you actually saw: access codes, LAN mode, cert pins, FTPS, OrcaSlicer, camera
- [AGENTS.md](https://github.com/DLANSAMA/platecli/blob/main/AGENTS.md) — architecture and safety notes for agents and automation
- [docs/api.md](https://github.com/DLANSAMA/platecli/blob/main/docs/api.md) — JSON contracts + stability policy
- [docs/api.md](https://github.com/DLANSAMA/platecli/blob/main/docs/api.md) — JSON contracts, support matrix, and stability policy
- [docs/schemas/](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/) — machine-checkable JSON Schema files
- [SECURITY.md](https://github.com/DLANSAMA/platecli/blob/main/SECURITY.md) — threat model, reporting, known limitations
- [CHANGELOG.md](https://github.com/DLANSAMA/platecli/blob/main/CHANGELOG.md) — release notes
Expand Down
8 changes: 4 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,14 @@ Tracked for hardening; not all are “bugs” in the sense of broken claims.
| Topic | Detail | Status |
|-------|--------|--------|
| **Camera Docker port bind** | Default `camera_port` is now `127.0.0.1:1985:1984`, so the streamer publishes the (unauthenticated) camera feed on **loopback only**. Set `camera_port` to `0.0.0.0:1985:1984` to deliberately expose it on the LAN. Host-qualified specs now parse correctly, `camera_port` is validated, and the CLI warns if a *pre-existing* container is still bound to a non-loopback interface (recreate with `docker rm -f bambu_camera`). | Fixed |
| **Camera pin fallback** | Default is fail-closed: a failed direct grab does **not** start or use the Docker streamer. X1-series users must set `camera_allow_streamer: true` or pass `--allow-camera-streamer`. A pinned-fingerprint **mismatch**, and any `ssl.SSLError` from the direct grab when a pin is configured, still hard-abort (no streamer even if opted in). `camera_direct_only` remains a forbid switch that wins over the opt-in. **Residuals:** (a) `insecure_tls=true` is unverified whether or not the streamer is used. (b) An already-running `bambu_camera` container keeps serving the unauthenticated feed — run `docker rm -f bambu_camera` to stop it. Setup still preserves unmanaged keys. | Fixed |
| **HTTP downloads** | `http://` and `https://` are both accepted. SSRF controls apply; **content integrity** over cleartext HTTP does not (a network attacker can substitute a model). Prefer HTTPS sources. | Residual |
| **Camera pin fallback** | Default is fail-closed: a failed direct grab does **not** start or use the Docker streamer. X1-series users must set `camera_allow_streamer: true` or pass `--allow-camera-streamer`. A pinned-fingerprint **mismatch**, and any `ssl.SSLError` from the direct grab when a pin is configured, still hard-abort (no streamer even if opted in). `camera_direct_only` remains a forbid switch that wins over the opt-in. **Accepted 1.0 residuals:** (a) `insecure_tls=true` is unverified whether or not the streamer is used. (b) An already-running `bambu_camera` container keeps serving the unauthenticated feed — run `docker rm -f bambu_camera` to stop it. Setup still preserves unmanaged keys. (c) The opt-in streamer does not honour `cert_fingerprint` — that is why it is opt-in. | Accepted for 1.0 |
| **HTTP downloads** | `http://` and `https://` are both accepted. SSRF controls apply; **content integrity** over cleartext HTTP does not (a network attacker can substitute a model). Prefer HTTPS sources. | Accepted for 1.0 |
| **pause / resume** | Required `--confirm` as of 0.3.0, matching stop/print/delete/gcode. | Fixed |
| **Windows secret ACLs** | POSIX `0600` enforcement does not apply on Windows; protect the config directory with NTFS ACLs on shared machines. | Platform residual |
| **Windows secret ACLs** | POSIX `0600` enforcement does not apply on Windows; protect the config directory with NTFS ACLs on shared machines. | Accepted for 1.0 (platform) |
| **Reverse-engineered protocols** | MQTT/FTPS behavior is best-effort; firmware updates can break compatibility. | Out of scope |
| **Access code = full LAN control** | Protect the config directory, especially on agent-operated hosts. | Residual |
| **Model content** | The tool validates packaging/paths/sizes, not whether a model is safe to print. | Residual |
| **TOFU pin capture** | First-time fingerprint probe intentionally disables cert verification to *read* the pin. A MITM during setup can poison the pin if the LAN is already hostile. | Acknowledged |
| **TOFU pin capture** | First-time fingerprint probe intentionally disables cert verification to *read* the pin. A MITM during setup can poison the pin if the LAN is already hostile. | Accepted for 1.0 |
| **Agent auto-`--confirm`** | Process/policy issue; code cannot stop intentional confirmation. | Out of process scope |
| **Third-party tools** | OrcaSlicer, gmsh, and the optional camera Docker image are outside this package’s SBOM boundary. | Out of scope |

Expand Down
1 change: 1 addition & 0 deletions bambu_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import socket
import sys

import bambu_cli.printer as _printer # noqa: F401 — registers the RuntimeContext printer factory
import bambu_cli.utils as utils
from bambu_cli.errors import BambuError

Expand Down
30 changes: 2 additions & 28 deletions bambu_cli/commands/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,6 @@ def cmd_snapshot(
if outpath.startswith("-"):
message = f"Invalid output path: {_path_for_message(outpath)}"
emit_json_error(args, "snapshot", EXIT_FILE_ERROR, message, failed_step="validate", output=outpath)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
try:
_ensure_parent_dir(outpath)
except BambuError as e:
Expand All @@ -246,17 +244,13 @@ def cmd_snapshot(
except _CameraPinMismatch as _exc:
message = f"Camera TLS certificate does not match pinned fingerprint: {_exc}"
emit_json_error(args, "snapshot", EXIT_NETWORK_ERROR, message, failed_step="grab", output=outpath)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
except ssl.SSLError as _exc:
if not printer.insecure_tls and printer.cert_fingerprint:
message = (
"Camera TLS error with a cert pin configured "
f"(refusing to fall back to the unverified Docker streamer): {_exc}"
)
emit_json_error(args, "snapshot", EXIT_NETWORK_ERROR, message, failed_step="grab", output=outpath)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
_frame = None
_fallback_reason = str(_exc)
logger.debug(f"Direct camera grab unavailable ({_exc}).")
Expand All @@ -273,8 +267,6 @@ def cmd_snapshot(
except OSError as _exc:
message = f"Could not write snapshot: {_path_for_message(outpath)}: {_exception_for_message(_exc)}"
emit_json_error(args, "snapshot", EXIT_FILE_ERROR, message, failed_step="capture", output=outpath)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
captured_at = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
sha256 = hashlib.sha256(_frame).hexdigest()
logger.info(f"\U0001f4f8 Snapshot saved: {_path_for_message(outpath)} ({size // 1024}KB)")
Expand All @@ -297,8 +289,6 @@ def cmd_snapshot(
if not streamer_is_allowed(ctx.settings, args):
message = _streamer_refused_message(ctx.settings, _fallback_reason)
emit_json_error(args, "snapshot", EXIT_NETWORK_ERROR, message, failed_step="grab", output=outpath)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)

streamer_url = ctx.settings.camera_stream_url
camera_image = ctx.settings.camera_image
Expand All @@ -308,8 +298,6 @@ def cmd_snapshot(
if not _which("docker"):
message = "Docker not found in PATH. Install Docker Desktop (Windows/macOS) or docker-ce (Linux) and retry."
emit_json_error(args, "snapshot", EXIT_CONFIG_ERROR, message, failed_step="docker", output=outpath)
safe_log_error(message)
abort("", exit_code=EXIT_CONFIG_ERROR)

camera_port = ctx.settings.camera_port
if not _camera_port_is_valid(camera_port):
Expand All @@ -318,8 +306,6 @@ def cmd_snapshot(
"[HOST:]HOSTPORT:CONTAINERPORT (e.g. 127.0.0.1:1985:1984)."
)
emit_json_error(args, "snapshot", EXIT_CONFIG_ERROR, message, failed_step="docker", output=outpath)
safe_log_error(message)
abort("", exit_code=EXIT_CONFIG_ERROR)
config_exposed = not _bind_is_loopback(_camera_bind_host(camera_port))
if config_exposed:
logger.warning(
Expand All @@ -338,8 +324,6 @@ def cmd_snapshot(
except (FileNotFoundError, subprocess.SubprocessError) as e:
message = f"Docker not reachable (is the daemon running?): {e}"
emit_json_error(args, "snapshot", EXIT_CONFIG_ERROR, message, failed_step="docker", output=outpath)
safe_log_error(message)
abort("", exit_code=EXIT_CONFIG_ERROR)
if check.returncode != 0 or "true" not in check.stdout:
logger.info("🔄 Starting camera streamer...")
access_code = _load_code()
Expand Down Expand Up @@ -376,8 +360,6 @@ def cmd_snapshot(
output=outpath,
camera_image=camera_image,
)
safe_log_error(message)
abort("", exit_code=EXIT_CONFIG_ERROR)
if run.returncode != 0:
detail = run.stderr or run.stdout or "unknown Docker error"
if isinstance(detail, bytes):
Expand All @@ -387,6 +369,7 @@ def cmd_snapshot(
if ctx.settings.printer_ip:
detail = detail.replace(ctx.settings.printer_ip, "<redacted>")
message = f"Could not start camera streamer Docker container using image {camera_image}: {detail.strip()}"
logger.info(" Build the BambuP1Streamer image locally or set `camera_image` in config.json.")
emit_json_error(
args,
"snapshot",
Expand All @@ -396,9 +379,6 @@ def cmd_snapshot(
output=outpath,
camera_image=camera_image,
)
safe_log_error(message)
logger.info(" Build the BambuP1Streamer image locally or set `camera_image` in config.json.")
abort("", exit_code=EXIT_CONFIG_ERROR)

req = urllib.request.Request(streamer_url, headers={"User-Agent": "Mozilla/5.0"})
for _ in range(30):
Expand Down Expand Up @@ -439,6 +419,7 @@ def cmd_snapshot(
)
except urllib.error.URLError as e:
message = f"Snapshot network error: {e}"
logger.info(f" Make sure the {camera_image} Docker container is running and reachable.")
emit_json_error(
args,
"snapshot",
Expand All @@ -448,16 +429,11 @@ def cmd_snapshot(
output=outpath,
camera_image=camera_image,
)
safe_log_error(message)
logger.info(f" Make sure the {camera_image} Docker container is running and reachable.")
abort("", exit_code=EXIT_NETWORK_ERROR)
except OSError as e:
message = f"Snapshot file error: {_exception_for_message(e)}"
emit_json_error(
args, "snapshot", EXIT_FILE_ERROR, message, failed_step="capture", output=outpath, camera_image=camera_image
)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
except BambuError:
raise
except Exception as e:
Expand All @@ -471,5 +447,3 @@ def cmd_snapshot(
output=outpath,
camera_image=camera_image,
)
safe_log_error(message)
abort("", exit_code=EXIT_COMMAND_ERROR)
Loading