Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
07f365d
test(upload): resolve the retry sleep at call time so patched sleeps …
DLANSAMA Nov 6, 2026
6ce081b
fix(download): redact delimiter-bearing passwords, accept text/plain …
DLANSAMA Nov 6, 2026
8510046
feat(textsafe): add display_safe for untrusted names shown in a terminal
DLANSAMA Nov 6, 2026
5e615b8
fix(mqtt): send each command at most once and report unconfirmed prin…
DLANSAMA Nov 6, 2026
e7cb97d
fix(tls): a configured certificate pin always wins over insecure_tls
DLANSAMA Nov 6, 2026
65b6dfa
fix(commands): hold raw G-code to heater limits, ASCII-only AMS mappi…
DLANSAMA Nov 6, 2026
ae67fc4
fix(display): keep terminal control and bidi characters out of untrus…
DLANSAMA Nov 6, 2026
3c95e2b
fix(slice): exact layer heights, deterministic filament choice, keep …
DLANSAMA Nov 6, 2026
38b7939
fix(config): validate value types, cap timeouts, disable option abbre…
DLANSAMA Nov 6, 2026
8a2683c
fix(output): never emit NaN/Infinity, survive hostile AMS numbers, no…
DLANSAMA Nov 6, 2026
476589e
docs: changelog for the audit fixes; record the measured test count
DLANSAMA Nov 6, 2026
bf55ef0
docs(plan): start docs/MASTER-PLAN.md and the inbox
DLANSAMA Nov 6, 2026
5e5a0a6
fix: findings from two independent reviews of the audit fixes
DLANSAMA Nov 6, 2026
f5d98f3
docs(plan): name the audit PR in D-1
DLANSAMA Nov 6, 2026
f368c55
test: build at-sign URL literals from pieces; privacy smoke sees work…
DLANSAMA Nov 6, 2026
ef01a1d
fix(upload): hold pre-sliced G-code to the heater limits
DLANSAMA Nov 6, 2026
8cae64c
fix(go): re-read the AMS before a guided print starts
DLANSAMA Nov 6, 2026
acf226a
fix(setup): confirm what mDNS found before using it
DLANSAMA Nov 6, 2026
0959679
test(upload): give the dry-run tests a real file to scan
DLANSAMA Nov 6, 2026
db9237c
fix(upload): close the gaps review found in the pre-sliced heater scan
DLANSAMA Nov 6, 2026
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
92 changes: 92 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,55 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version

### Printer safety

- **A printer command is sent at most once.** When the connection dropped
after a command reached the printer but before its acknowledgement came
back, the MQTT client reconnected by itself and resent it: a `gcode` move,
`stop` or `light` ran twice while the command reported success. Automatic
reconnect is off; the command is reported as sent but unconfirmed
(`sent: true`, `acknowledged: false`) and is never resent.
- **An unacknowledged print start is no longer reported as "not printed".**
When the print command was published but the printer never confirmed it,
`print` and `job` said `printed: false`, which invites a retry and a second
print. They now report `printed: null` with `sent: true`, say the printer
may already be printing, and point at `plate status`. The fields are on the
`--json` error envelope too (before, it carried only "Command failed").
- **Raw G-code is held to the heater limits.** `plate gcode "M104 S400"` went
straight to the printer. `M104`/`M109` above 350 °C and `M140`/`M190` above
150 °C are now refused before anything is sent, as is a temperature that
is not a plain number (`S4e2`, `S0x190`, non-ASCII digits), including after
a tool number (`M104 T0 S400`).
- **Pre-sliced files are held to the heater limits too.** A downloaded or
third-party sliced 3MF (or `.gcode`) went to the printer with whatever
temperatures it carried. `upload`, `job` and `send` (and `job --dry-run`) now
read every plate's G-code and refuse a nozzle above 350 °C or a bed above
150 °C, naming the plate and line. Every line ending (LF, CR, CRLF) and
every archive entry counts, duplicate names included; G-code or a 3MF that
cannot be read is refused, and so is a line over 1 MiB.
- **Guided mode re-reads the AMS before it starts a print.** `plate go` and the
TUI read the loaded filament when the material is chosen; slicing can take
minutes, and a spool swapped in that time was fed at the temperatures sliced
for the old one. Start print now reads the AMS again and refuses, with
nothing sent, if the material or slot changed or cannot be read (upload only
is unaffected). The summary before confirming gains a Feed row (`AMS0·2
(PETG)` or External spool) and a Temperatures row with the hottest nozzle
and bed targets in the file's G-code.
- **Setup asks before using the one printer mDNS found.** Discovery is
unauthenticated and the access code goes to the configured address, yet a
lone result was taken without a question. It is now shown and confirmed
(no falls back to typing the address). An address no printer can have
(`0.0.0.0`, multicast) is skipped, and one a LAN printer would not have
(a public address, this computer) is flagged and needs an explicit yes.
- `--ams-mapping` accepts ASCII digits only; `٣` and other Unicode digits that
`int()` reads were passed through as slot numbers.
- The upload guard that refuses to overwrite the file being printed now waits
for a full status report, and a print whose report names no file blocks the
upload too: a partial report without `gcode_file` read as "not printing this
file" and let the upload through.
- `slice --quality 0.28` (a height with no named profile) now needs a 0.28 mm
process profile and lists the available heights when there is none; it used
to slice the Standard profile without saying so. A failed OrcaSlicer run no
longer replaces an earlier good output file.

- **A1 and A1 mini can be sliced.** Every A1/A1 mini slice failed inside
OrcaSlicer ("Relative extruder addressing requires resetting the extruder
position at each layer"): Bambu's A1 profiles omit the per-layer `G92 E0`
Expand Down Expand Up @@ -73,6 +122,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version

### Fixed

- `download` accepts URLs with non-ASCII paths (pasted from a browser), and
ASCII STL/OBJ/G-code served as `text/plain` when the URL names a model file.
Ctrl-C during a download or ZIP extraction removes the partial file. A
malformed IPv6 host is a clean "Invalid URL" instead of a traceback.
- Printables: a download link that is not a string is reported as an API
change instead of crashing.
- Config values of the wrong type (a number for `access_code`,
`cert_fingerprint` or `printer_ip`, a non-object `config.json`) give a
config error or fall back cleanly instead of a traceback; `camera_port` is
validated, and `preflight` reports `cert-fingerprint` and `camera-port`
checks. Timeouts are capped at 24 hours.
- Option abbreviations are off: `--conf` no longer silently means `--confirm`.
- JSON output never contains `NaN` or `Infinity` (invalid JSON): non-finite
numbers from a printer report become `null`. Over-long AMS numbers no longer
crash `status`.
- `doctor` no longer leaves an empty temporary file behind when its checks
stop early; `snapshot --sim` refuses the Docker streamer instead of trying it.
- Slicing: filament profile choice is deterministic (exact name, then
shortest) and logged; profile directories with `[` or `*` in the path work;
an absurd temperature override is a validation error, not a traceback.
- Interactive mode: prompts no longer interpret `[...]` as markup, and a failed
upload or print says so before returning to the menu.
- A failed TLS data-channel setup closes its socket instead of leaking it.

- `status --monitor` exited 0 with no output when the printer refused the
connection (wrong access code), so a script could not tell it from success.
It now fails with exit 2 (`failed_step: mqtt`) on a refused connection, on a
Expand Down Expand Up @@ -140,6 +213,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version

### Security

- **`insecure_tls` no longer switches off a configured certificate pin.**
With both `cert_fingerprint` and `insecure_tls` set, MQTT and the camera
skipped the pin and accepted any certificate. A pin is now always checked.
- **Untrusted names cannot drive the terminal.** File names from the printer's
FTP listing, MQTT report fields (`gcode_file`, AMS tray types, error text)
in `status`, `status --monitor` and the TUI, mDNS names and prompt text could carry ESC sequences (clear the screen,
write the clipboard with OSC 52) or bidi overrides that make one name read
as another. They are shown with control and bidi characters replaced, and
names with them are refused for upload (download renaming replaces them).
- A hostile or corrupt MQTT report (deeply nested JSON, an over-long integer,
non-object JSON, `1e999`) is ignored instead of killing the client's network
thread, which left a held session reporting stale state, or stopping
`status --monitor` from ever seeing the end of the print.
- A URL password holding `/`, `?` or `#` (`user:pa/ss@host`, `user:12/ss@host`)
is now redacted and the URL refused: the parser read it as a host and port,
so the password was echoed in errors and JSON, and with a digit prefix the
request went to host `user` with the password in its path. `https:///user:pw@host`
and similar host-less forms are redacted too.

- **`"camera_allow_streamer": "false"` no longer enables the unpinned camera
streamer.** The key was read with `bool()`, so any non-empty string —
including `"false"` — turned on the Docker streamer that ignores
Expand Down
22 changes: 16 additions & 6 deletions bambu_cli/ams.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
filament is loaded where and to build a correct ``--ams-mapping`` argument.
"""

import math

# Bambu firmware reports two sentinel values in ``tray_now`` that are not AMS
# slot indices. They are distinct states and must not be conflated: 254 means
# the printer is feeding from the external spool (vt_tray), 255 means nothing
Expand All @@ -17,15 +19,22 @@
def _to_int(value, default=None):
try:
return int(value)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
return default


def _to_float(value, default=None):
try:
return float(value)
except (TypeError, ValueError):
result = float(value)
except (TypeError, ValueError, OverflowError):
return default
# NaN/Infinity from a spoofed report are unknown, not a reading.
return result if math.isfinite(result) else default


def _tray_type(raw):
"""Filament type as a non-empty string, else None (any other JSON type is junk)."""
return raw if isinstance(raw, str) and raw else None


def _normalize_color(raw):
Expand Down Expand Up @@ -94,7 +103,7 @@ def parse_ams(status):
# _to_int returns `default` for missing AND unparseable input, so the
# default alone covers every case -- no None check needed after it.
vt_slot = _to_int(vt_tray_raw.get("id"), default=EXTERNAL_SPOOL_TRAY_ID)
vt_type = vt_tray_raw.get("tray_type") or None
vt_type = _tray_type(vt_tray_raw.get("tray_type"))
vt_color = _normalize_color(vt_tray_raw.get("tray_color"))
vt_remain = _to_int(vt_tray_raw.get("remain"))
# 254 and 255 are BOTH sentinels, but they do not mean the same thing:
Expand Down Expand Up @@ -130,11 +139,12 @@ def parse_ams(status):
continue
unit_id = _to_int(unit_raw.get("id"), default=0) or 0
trays = []
for tray_raw in unit_raw.get("tray") or []:
tray_list = unit_raw.get("tray")
for tray_raw in tray_list if isinstance(tray_list, list) else []:
if not isinstance(tray_raw, dict):
continue
slot = _to_int(tray_raw.get("id"), default=0) or 0
ftype = tray_raw.get("tray_type") or None
ftype = _tray_type(tray_raw.get("tray_type"))
absolute = unit_id * 4 + slot
trays.append(
{
Expand Down
11 changes: 9 additions & 2 deletions bambu_cli/cliparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,24 @@ def positive_seconds(value):
"""
import math

from bambu_cli.constants import MAX_TIMEOUT_SECONDS

try:
seconds = float(value)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
raise argparse.ArgumentTypeError(f"must be a positive number of seconds (got {value!r})") from None
if not math.isfinite(seconds) or seconds <= 0:
raise argparse.ArgumentTypeError(f"must be a positive number of seconds (got {value!r})")
if seconds > MAX_TIMEOUT_SECONDS:
raise argparse.ArgumentTypeError(f"must be at most {MAX_TIMEOUT_SECONDS} seconds (got {value!r})")
return seconds


def positive_int(value):
"""argparse type for a 1-based index such as a plate number."""
try:
number = int(value)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
raise argparse.ArgumentTypeError(f"must be a positive whole number (got {value!r})") from None
if number < 1:
raise argparse.ArgumentTypeError(f"must be a positive whole number (got {value!r})")
Expand Down Expand Up @@ -106,6 +110,9 @@ class JsonArgumentParser(argparse.ArgumentParser):

def __init__(self, *args, **kwargs):
kwargs.setdefault("conflict_handler", "resolve")
# An abbreviated --conf must not reach the --confirm gate: agent policies
# that look for the literal flag would be bypassed.
kwargs.setdefault("allow_abbrev", False)
super().__init__(*args, **kwargs)

def error(self, message):
Expand Down
15 changes: 9 additions & 6 deletions bambu_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,16 @@ def emit_doctor_failure(failed_step, exit_code, error, extra=None):
emit_json(payload)

cap_path = _namespace_get(args, "output")
if not cap_path:
fd, cap_path = tempfile.mkstemp(prefix="printer_capabilities_", suffix=".json")
os.close(fd)
cap_path = _expand_path(cap_path)
if cap_path.startswith("-"):
if cap_path:
cap_path = _expand_path(cap_path)
if cap_path and cap_path.startswith("-"):
message = f"Invalid output path: {_path_for_message(cap_path)}"
logger.error(message)
emit_doctor_failure("validate", EXIT_FILE_ERROR, message)
abort("", exit_code=EXIT_FILE_ERROR)
try:
_ensure_parent_dir(cap_path)
if cap_path:
_ensure_parent_dir(cap_path)
except BambuError as exc:
emit_doctor_failure(
"validate",
Expand Down Expand Up @@ -223,6 +222,10 @@ def shown_ip():
},
}

if not cap_path:
# Created only now so a run that fails earlier leaves nothing behind.
fd, cap_path = tempfile.mkstemp(prefix="printer_capabilities_", suffix=".json")
os.close(fd)
try:
with open(cap_path, "w", encoding="utf-8") as f:
json.dump(capabilities, f, indent=2)
Expand Down
43 changes: 37 additions & 6 deletions bambu_cli/commands/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
)
from bambu_cli.errors import abort
from bambu_cli.fsutil import _portable_basename
from bambu_cli.gcode_limits import file_heater_problem
from bambu_cli.logging_utils import logger
from bambu_cli.paths import exception_for_message as _exception_for_message
from bambu_cli.paths import expand_path as _expand_path
from bambu_cli.paths import path_for_message as _path_for_message
from bambu_cli.slicer import _directory_input_message, _is_directory_input
from bambu_cli.textsafe import display_safe
from bambu_cli.utils import emit_json

# gcode_state values for which the printer is using its current file.
Expand All @@ -38,20 +40,30 @@ def _print_name_key(name):
def _printing_this_file(printer, filename):
"""True when the printer reports an active print of ``filename``.

``None`` when it reports an active print without naming the file: deltas
can add up to a "complete" report with neither ``gcode_file`` nor
``subtask_name``, and "not this file" cannot be told apart from that.

``upload_file`` deletes the remote file before storing the new one, so an
upload with the name of the running print pulls the file out from under
the printer (what the firmware does then is unverified). Best effort: a
status query that fails returns False and the upload goes ahead.

``require_complete=True``: mid-print the printer streams deltas that carry
neither ``gcode_state`` nor the file name, and the first message to arrive
is often one of them; reading only that let the guard pass while printing.
"""
try:
data = printer.status(retries=0, require_complete=False)
data = printer.status(retries=0, require_complete=True)
except Exception as exc: # noqa: BLE001 -- a probe must never block the upload
logger.debug(f"Could not check the printer's current job before upload: {exc}")
return False
if not isinstance(data, dict) or str(data.get("gcode_state") or "").upper() not in _ACTIVE_GCODE_STATES:
return False
target = _print_name_key(filename)
return target in {_print_name_key(data.get("subtask_name")), _print_name_key(data.get("gcode_file"))}
names = {_print_name_key(data.get("subtask_name")), _print_name_key(data.get("gcode_file"))} - {""}
if not names:
return None
return _print_name_key(filename) in names


def cmd_upload(args, ctx=None):
Expand Down Expand Up @@ -118,6 +130,17 @@ def cmd_upload(args, ctx=None):
failed_step="validate",
extra={"file": filepath, "remote_name": filename, "bytes": filesize},
)
heater = file_heater_problem(filepath)
if heater:
# A pre-sliced 3MF from a download or another slicer carries G-code
# platecli never generated; hold it to the limits slicing obeys.
message = f"Refusing to upload {filename}: {heater}"
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
extra={"file": filepath, "remote_name": filename},
)
if getattr(args, "dry_run", False):
logger.info(f"🔍 Dry Run: Validating printer connectivity for {filename}...")
printer = ctx.printer()
Expand Down Expand Up @@ -166,9 +189,15 @@ def cmd_upload(args, ctx=None):
return filename

printer = ctx.printer()
if _printing_this_file(printer, filename):
printing = _printing_this_file(printer, filename)
if printing is not False:
what = (
f"is printing {filename}"
if printing
else f"is printing but did not say which file, so it may be {filename}"
)
abort(
f"The printer is printing {filename} right now; uploading would replace that file mid-print. "
f"The printer {what} right now; uploading would replace that file mid-print. "
"Wait for the print to finish, or upload under a different name.",
exit_code=EXIT_PRINTER_ERROR,
failed_step="upload",
Expand Down Expand Up @@ -273,7 +302,9 @@ def cmd_files(args, ctx=None):
return
logger.info("📁 Files on printer:")
for f in files:
logger.info(f" {f}")
# Names come from the printer's FTP listing; never let one write
# terminal control sequences (OSC 52 clipboard, screen clears).
logger.info(f" {display_safe(f)}")
except Exception as e:
message = f"Error listing files: {e}"
abort(
Expand Down
9 changes: 9 additions & 0 deletions bambu_cli/commands/gcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from bambu_cli.contracts import Gcode
from bambu_cli.download.naming import _has_command_injection_chars
from bambu_cli.errors import CommandUnconfirmed, abort
from bambu_cli.gcode_limits import heater_problem as _heater_problem
from bambu_cli.logging_utils import logger
from bambu_cli.utils import emit_json, get_sequence_id

Expand All @@ -27,6 +28,14 @@ def cmd_gcode(args, ctx=None):
failed_step="validate",
extra={"gcode": gcode, "sent": False},
)
problem = _heater_problem(gcode)
if problem:
abort(
f"Invalid G-code: {problem}",
exit_code=EXIT_COMMAND_ERROR,
failed_step="validate",
extra={"gcode": gcode, "sent": False},
)

if not args.confirm:
logger.warning("⚠️ This will SEND raw G-code to the printer. Add --confirm to proceed.")
Expand Down
8 changes: 7 additions & 1 deletion bambu_cli/commands/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ def cmd_snapshot(
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)
except ssl.SSLError as _exc:
if not printer.insecure_tls and printer.cert_fingerprint:
if printer.cert_fingerprint:
message = (
"Camera TLS error with a cert pin configured "
f"(refusing to fall back to the unverified Docker streamer): {_exc}"
Expand Down Expand Up @@ -290,6 +290,12 @@ def cmd_snapshot(
message = _streamer_refused_message(ctx.settings, _fallback_reason)
emit_json_error(args, "snapshot", EXIT_NETWORK_ERROR, message, failed_step="grab", output=outpath)

if ctx.simulation:
# The simulated printer has no access code; the streamer would read the
# real one and contact the real printer.
message = "Simulation mode has no camera: the Docker streamer is not available under --sim."
emit_json_error(args, "snapshot", EXIT_NETWORK_ERROR, message, failed_step="grab", output=outpath)

streamer_url = ctx.settings.camera_stream_url
camera_image = ctx.settings.camera_image

Expand Down
Loading
Loading