From ad4718dfe216d413e51aec1d0a8dde0258a8b8d1 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 23 Jul 2026 23:38:23 -0700 Subject: [PATCH 1/2] feat(run): gate `comfy run` on paid partner nodes via --allow-spend (BE-4326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `comfy run` silently spent Comfy credits when a workflow embedded partner-API (paid) nodes and a credential resolved. `comfy generate` and `comfy run-template` both gate on spend consent; `comfy run` was the hole. Add an `--allow-spend` flag (default False) and a shared `_spend_gate` consent interlock mirroring run-template's gate (BE-4113): - Pretty + TTY: warn naming the nodes, confirm (default No); decline → `spend_consent_required` + exit 1. - JSON / non-TTY: fail closed with `spend_consent_required`, telling the caller to re-run with `--allow-spend`; nothing submitted, nothing spent. The gate fires BEFORE partner-credential resolution / cloud auth so a refusal never triggers a network OAuth refresh. Detection stays fail-open (empty object_info → no gate). Applied to both the local and cloud submit paths (cloud also bills partner nodes server-side). run-template forwards `allow_spend=True` on its handoff to execute() so its already-consented run is not gated a second time. Docs + skill updated. Co-Authored-By: Claude Opus 4.8 --- comfy_cli/cmdline.py | 12 + comfy_cli/command/run/__init__.py | 65 ++++++ comfy_cli/command/templates.py | 5 + comfy_cli/skills/comfy/SKILL.md | 10 + docs/json-output.md | 1 + tests/comfy_cli/command/test_run.py | 225 ++++++++++++++++++- tests/comfy_cli/command/test_run_template.py | 14 ++ 7 files changed, 330 insertions(+), 2 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 57c6f0d6..96ea25f0 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -800,6 +800,16 @@ def run( ), ), ] = False, + allow_spend: Annotated[ + bool, + typer.Option( + "--allow-spend", + help=( + "Consent to running partner-API (paid) nodes that spend Comfy credits. " + "Required for workflows embedding partner nodes when not confirming interactively." + ), + ), + ] = False, ): # Snapshot kwargs before the body mutates api_key/host/port — analytics should record what user actually supplied. _track_props = tracking.filter_command_kwargs(dict(locals())) @@ -871,6 +881,7 @@ def run( notify=effective_notify, print_prompt=print_prompt, preloaded=preloaded, + allow_spend=allow_spend, ) return @@ -894,6 +905,7 @@ def run( api_key=api_key, print_prompt=print_prompt, preloaded=preloaded, + allow_spend=allow_spend, ) except typer.Exit as e: if (e.exit_code or 0) == 0: diff --git a/comfy_cli/command/run/__init__.py b/comfy_cli/command/run/__init__.py index 4ea9fa6a..8cfeb7cc 100644 --- a/comfy_cli/command/run/__init__.py +++ b/comfy_cli/command/run/__init__.py @@ -8,6 +8,7 @@ """ import json +import sys import time import uuid from datetime import timedelta @@ -50,6 +51,45 @@ workspace_manager = WorkspaceManager() +def _spend_gate(renderer, partner_nodes: list[str], allow_spend: bool, *, details: dict) -> None: + """Consent interlock for partner-API (paid) nodes (BE-4326). + + Mirrors the ``comfy run-template`` gate (BE-4113): a workflow that embeds + partner-API nodes (Veo/Kling/BFL/Gemini/…) spends Comfy credits when it + runs, so require explicit consent before submitting. A no-op when there are + no partner nodes or ``--allow-spend`` was passed, so partner-free runs are + byte-identical. + + Fires BEFORE any partner-credential resolution / cloud auth so a refusal + never triggers a network OAuth refresh. Raises ``typer.Exit(1)`` when + consent is withheld; returns normally when the run may proceed. + """ + if not partner_nodes or allow_spend: + return + if renderer.is_pretty() and sys.stdin.isatty(): + pprint( + "[yellow]⚠ This workflow uses partner-API nodes that spend Comfy " + f"credits: {', '.join(partner_nodes)}.[/yellow]" + ) + if not typer.confirm("Run anyway and spend credits?", default=False): + renderer.error( + code="spend_consent_required", + message="declined — workflow not submitted, no credits spent", + details=details, + ) + raise typer.Exit(code=1) + else: + renderer.error( + code="spend_consent_required", + message=( + "workflow uses partner-API (paid) nodes; re-run with --allow-spend to consent to spending Comfy credits" + ), + hint="paid nodes only run with explicit consent; free (non-partner) workflows run without this flag", + details=details, + ) + raise typer.Exit(code=1) + + # Mapping from the deleted legacy `comfy run --json` dialect (JsonEmitter, # `{"event": …, "error": {"kind": …}}`) to the renderer dialect # (`{"schema": "event/1", "type": …}` events + final `type: "envelope"` line). @@ -105,6 +145,7 @@ def execute( api_key: str | None = None, print_prompt: bool = False, preloaded: tuple[dict, str, bool] | None = None, + allow_spend: bool = False, ): # `0.0.0.0` is a wildcard bind, not a connect address. macOS / Windows # clients can't reach it; on Linux it happens to resolve to a loopback. @@ -215,6 +256,17 @@ def execute( # the cloud submit path uses. object_info = _fetch_object_info(host, port) partner_nodes = _detect_partner_nodes(workflow, object_info) + # Spend gate (BE-4326): partner-API nodes spend Comfy credits. Require + # explicit consent before resolving a credential or submitting. Fires + # BEFORE _resolve_partner_credential() below so a refusal never triggers a + # network OAuth refresh. Detection stays fail-open (object_info == {} → no + # partner_nodes → no gate), same posture as run-template. + _spend_gate( + renderer, + partner_nodes, + allow_spend, + details={"partner_nodes": partner_nodes, "host": host, "port": port}, + ) extra_data: dict | None = None if api_key: extra_data = {"api_key_comfy_org": api_key} @@ -500,6 +552,7 @@ def execute_cloud( notify: bool = False, print_prompt: bool = False, preloaded: tuple[dict, str, bool] | None = None, + allow_spend: bool = False, ): """Run a workflow against Comfy Cloud via the stored OAuth session. @@ -601,6 +654,18 @@ def execute_cloud( _preflight_validate(renderer, parsed_workflow, cloud_object_info, target_label="cloud") + # Spend gate (BE-4326): the cloud also bills partner-API nodes, so apply the + # same consent interlock as the local path before authenticating/submitting. + # Fail-open on detection (empty cloud object_info → no gate), and fire before + # Client() so a refusal never triggers cloud auth. + partner_nodes = _detect_partner_nodes(parsed_workflow, cloud_object_info) + _spend_gate( + renderer, + partner_nodes, + allow_spend, + details={"partner_nodes": partner_nodes, "where": "cloud"}, + ) + target = resolve_target(where="cloud") try: client = Client(target, timeout=float(timeout)) diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index 523a83bd..1557cb31 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -936,6 +936,11 @@ def run_template_cmd( verbose=verbose, timeout=timeout, api_key=api_key, + # run-template's own spend gate (above) has already consented (or + # found no paid nodes), so forward consent to avoid a second gate in + # execute() (BE-4326). run-template's gate is strictly stronger — it + # also inspects gallery signals — and has already run. + allow_spend=True, ) finally: try: diff --git a/comfy_cli/skills/comfy/SKILL.md b/comfy_cli/skills/comfy/SKILL.md index 51d2303e..69ac0051 100644 --- a/comfy_cli/skills/comfy/SKILL.md +++ b/comfy_cli/skills/comfy/SKILL.md @@ -632,6 +632,16 @@ the prompt_id mid-flight (it's hidden until the job finishes). It defaults **on** in pretty/human async mode, and **off** in JSON/agent contexts and with `--wait`. Override explicitly with `--notify` or `--no-notify`. +**Spend gate — a workflow embedding partner-API (paid) nodes spends the +user's Comfy credits.** Interactive TTY runs confirm before spending; `--json` +/ non-TTY runs **fail closed** with error code `spend_consent_required` (exit +1, nothing submitted, nothing spent) unless `--allow-spend` is passed. Add +`--allow-spend` only when the human has actually approved the spend — do not +reflexively add it to make the error go away. Free (non-partner) workflows run +without the flag and are byte-identical. (`comfy run-template` gates the same +way with the same flag, and forwards consent to `comfy run` once its own gate +has passed.) + **Scope:** the async-first / `jobs watch` / state-file pattern above is the **`comfy run`** workflow path only. `comfy generate` (partner-API one-call) has its own waiting model — see the next section. diff --git a/docs/json-output.md b/docs/json-output.md index c10a53c4..13f3ab8b 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -324,6 +324,7 @@ registry test enforces this) and surfaced by `comfy discover`. | `connection_error` | Server unreachable mid-flow: `URLError`, `TimeoutError`, or other `OSError` (including on `/object_info`) | — | 1 | | `workflow_unknown_nodes` | Pre-submit validation found unknown class_types / shape mismatches | `errors` (array), `warnings` (array) | 1 | | `partner_node_requires_credential` | Workflow uses a partner-API node and no `api_key_comfy_org` credential is available | `partner_nodes` (array of str), `host`, `port` | 1 | +| `spend_consent_required` | Workflow embeds partner-API (paid) nodes and `--allow-spend` was not passed (machine mode) or interactive consent was declined; re-run with `--allow-spend`. Free (non-partner) workflows are unaffected. | `partner_nodes` (array of str), `host`, `port` | 1 | | `prompt_rejected` | Server returned HTTP 400 with `node_errors` | `status` (400), `node_errors` (array — [shape](#node_errors-shape)) | 1 | | `client_error` | Server returned another HTTP 4xx response | `status` (int, 4xx), `body` (str) | 1 | | `server_error` | Server returned an HTTP 5xx response | `status` (int, 5xx), `body` (str) | 1 | diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index 1b9d8672..bd6abedc 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -811,7 +811,9 @@ def capture_error(self, *, code, message, hint=None, details=None, exit_code=1): patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): with pytest.raises(typer.Exit) as exc_info: - execute(wf_file, host="127.0.0.1", port=8188, wait=True, timeout=30) + # allow_spend=True: consent is granted, so the spend gate is a + # no-op and we reach the credential-resolution path under test. + execute(wf_file, host="127.0.0.1", port=8188, wait=True, timeout=30, allow_spend=True) assert exc_info.value.exit_code == 1 # /prompt must NOT be hit — refuse pre-submit. MockExec.assert_not_called() @@ -840,7 +842,9 @@ def test_proceeds_and_injects_credential_when_available(self, tmp_path, monkeypa mock_exec = MagicMock() MockExec.return_value = mock_exec mock_exec.outputs = [] - execute(wf_file, host="127.0.0.1", port=8188, wait=True, timeout=30) + # allow_spend=True: consent granted, so the spend gate is a no-op + # and the credential is resolved + injected as before. + execute(wf_file, host="127.0.0.1", port=8188, wait=True, timeout=30, allow_spend=True) # WorkflowExecution receives the credential via the # ``extra_data`` constructor kwarg. @@ -871,6 +875,165 @@ def test_non_partner_workflow_skips_preflight(self, workflow_file, monkeypatch): MockExec.assert_called_once() +class TestExecuteSpendGate: + """`comfy run` gates partner-API (paid) workflows on `--allow-spend` + (BE-4326), mirroring `comfy run-template`'s spend gate. A partner-node + workflow must not silently spend Comfy credits: machine mode fails closed + with `spend_consent_required`; a TTY prompts; consent (flag or "yes") lets + the run proceed to the credential path unchanged. Partner-free workflows + are byte-identical (no gate).""" + + PARTNER_WF = { + "1": {"class_type": "Veo3VideoGenerationNode", "inputs": {"prompt": "x"}}, + "2": {"class_type": "SaveVideo", "inputs": {"video": ["1", 0]}}, + } + OBJECT_INFO = { + "Veo3VideoGenerationNode": { + "category": "partner/video/Veo", + "output": ["VIDEO"], + "output_name": ["VIDEO"], + }, + "SaveVideo": {"category": "video", "output": [], "output_name": [], "output_node": True}, + } + + def _wf_file(self, tmp_path): + path = tmp_path / "partner.json" + path.write_text(json.dumps(self.PARTNER_WF)) + return str(path) + + def _capture_errors(self, monkeypatch): + """Patch Renderer.error to record every emitted code/details.""" + errors = [] + from comfy_cli.output.renderer import Renderer + + original_error = Renderer.error + + def capture_error(self, *, code, message, hint=None, details=None, exit_code=1): + errors.append({"code": code, "message": message, "details": details}) + return original_error(self, code=code, message=message, hint=hint, details=details, exit_code=exit_code) + + monkeypatch.setattr(Renderer, "error", capture_error) + return errors + + def test_paid_node_machine_mode_fails_closed_without_flag(self, tmp_path, monkeypatch): + """No `--allow-spend`, non-TTY: exit 1, `spend_consent_required`, + `partner_nodes` in details, and /prompt never hit.""" + wf_file = self._wf_file(tmp_path) + monkeypatch.setattr("comfy_cli.command.run.sys.stdin.isatty", lambda: False, raising=False) + errors = self._capture_errors(monkeypatch) + + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run._fetch_object_info", return_value=self.OBJECT_INFO), + patch("comfy_cli.command.run._resolve_partner_credential") as MockCred, + patch("comfy_cli.command.run.WorkflowExecution") as MockExec, + ): + with pytest.raises(typer.Exit) as exc_info: + execute(wf_file, host="127.0.0.1", port=8188, wait=True, timeout=30) + assert exc_info.value.exit_code == 1 + # Nothing submitted, and the gate fired BEFORE credential resolution + # (no network OAuth refresh on a refusal). + MockExec.assert_not_called() + MockCred.assert_not_called() + + assert errors and errors[0]["code"] == "spend_consent_required" + assert "Veo3VideoGenerationNode" in (errors[0]["details"] or {}).get("partner_nodes", []) + + def test_paid_node_with_allow_spend_proceeds(self, tmp_path, monkeypatch): + """`--allow-spend` skips the gate and reaches submission unchanged.""" + wf_file = self._wf_file(tmp_path) + errors = self._capture_errors(monkeypatch) + + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run._fetch_object_info", return_value=self.OBJECT_INFO), + patch("comfy_cli.command.run._resolve_partner_credential", return_value=None), + patch("comfy_cli.command.run.ExecutionProgress"), + patch("comfy_cli.command.run.WorkflowExecution") as MockExec, + ): + mock_exec = MagicMock() + MockExec.return_value = mock_exec + mock_exec.outputs = [] + # A stored api_key means the missing-credential path is satisfied, + # so the run submits. + execute(wf_file, host="127.0.0.1", port=8188, wait=True, timeout=30, api_key="k", allow_spend=True) + MockExec.assert_called_once() + + assert not any(e["code"] == "spend_consent_required" for e in errors) + + def test_no_paid_nodes_never_gates(self, workflow_file, monkeypatch): + """A partner-free workflow submits with no gate, flag or not.""" + errors = self._capture_errors(monkeypatch) + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch( + "comfy_cli.command.run._fetch_object_info", + return_value={ + "EmptyLatentImage": {"category": "latent", "output": ["LATENT"], "output_name": ["LATENT"]}, + "PreviewAny": {"category": "image", "output": [], "output_name": [], "output_node": True}, + }, + ), + patch("comfy_cli.command.run.ExecutionProgress"), + patch("comfy_cli.command.run.WorkflowExecution") as MockExec, + ): + mock_exec = MagicMock() + MockExec.return_value = mock_exec + mock_exec.outputs = [] + execute(workflow_file, host="127.0.0.1", port=8188, wait=True, timeout=30) + MockExec.assert_called_once() + assert not any(e["code"] == "spend_consent_required" for e in errors) + + def test_tty_confirm_declined_blocks(self, tmp_path, monkeypatch): + """Pretty + TTY: a declined confirm blocks with `spend_consent_required` + and submits nothing.""" + wf_file = self._wf_file(tmp_path) + from comfy_cli.output.renderer import Renderer + + monkeypatch.setattr(Renderer, "is_pretty", lambda self: True) + monkeypatch.setattr("comfy_cli.command.run.sys.stdin.isatty", lambda: True, raising=False) + monkeypatch.setattr("typer.confirm", lambda *a, **k: False) + errors = self._capture_errors(monkeypatch) + + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run._fetch_object_info", return_value=self.OBJECT_INFO), + patch("comfy_cli.command.run._resolve_partner_credential") as MockCred, + patch("comfy_cli.command.run.WorkflowExecution") as MockExec, + ): + with pytest.raises(typer.Exit) as exc_info: + execute(wf_file, host="127.0.0.1", port=8188, wait=True, timeout=30) + assert exc_info.value.exit_code == 1 + MockExec.assert_not_called() + MockCred.assert_not_called() + + assert any(e["code"] == "spend_consent_required" for e in errors) + + def test_tty_confirm_accepted_proceeds(self, tmp_path, monkeypatch): + """Pretty + TTY: an accepted confirm proceeds to submission.""" + wf_file = self._wf_file(tmp_path) + from comfy_cli.output.renderer import Renderer + + monkeypatch.setattr(Renderer, "is_pretty", lambda self: True) + monkeypatch.setattr("comfy_cli.command.run.sys.stdin.isatty", lambda: True, raising=False) + monkeypatch.setattr("typer.confirm", lambda *a, **k: True) + errors = self._capture_errors(monkeypatch) + + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run._fetch_object_info", return_value=self.OBJECT_INFO), + patch("comfy_cli.command.run._resolve_partner_credential", return_value=None), + patch("comfy_cli.command.run.ExecutionProgress"), + patch("comfy_cli.command.run.WorkflowExecution") as MockExec, + ): + mock_exec = MagicMock() + MockExec.return_value = mock_exec + mock_exec.outputs = [] + execute(wf_file, host="127.0.0.1", port=8188, wait=True, timeout=30, api_key="k") + MockExec.assert_called_once() + + assert not any(e["code"] == "spend_consent_required" for e in errors) + + class TestExecuteUiWorkflow: UI = { "nodes": [ @@ -1177,6 +1340,64 @@ def test_ui_workflow_no_object_info_surfaces_cql_no_graph(self, ui_workflow_file assert exc_info.value.exit_code == 1 +class TestExecuteCloudSpendGate: + """The cloud submit also bills partner-API nodes server-side, so BE-4326 + applies the same consent gate there. Detection is fail-open (empty cloud + object_info → no gate), and the gate fires before cloud auth/submit.""" + + PARTNER_WF = {"1": {"class_type": "Veo3VideoGenerationNode", "inputs": {"prompt": "x"}}} + CLOUD_OI = { + "Veo3VideoGenerationNode": {"category": "partner/video/Veo", "output": ["VIDEO"], "output_name": ["VIDEO"]} + } + + def _wf(self, tmp_path): + p = tmp_path / "cloud_partner.json" + p.write_text(json.dumps(self.PARTNER_WF)) + return str(p) + + def test_cloud_partner_node_machine_mode_fails_closed(self, tmp_path, monkeypatch): + from comfy_cli.command.run import execute_cloud + + monkeypatch.setattr("comfy_cli.command.run.sys.stdin.isatty", lambda: False, raising=False) + with ( + patch("comfy_cli.cql.engine._load_from_target", return_value=self.CLOUD_OI), + patch("comfy_cli.command.run._preflight_validate"), + patch("comfy_cli.target.resolve_target") as mock_target, + patch("comfy_cli.comfy_client.Client") as MockClient, + ): + with pytest.raises(typer.Exit) as exc: + execute_cloud(self._wf(tmp_path), wait=False) + assert exc.value.exit_code == 1 + # The gate fires before cloud auth/submit. + mock_target.assert_not_called() + MockClient.assert_not_called() + + def test_cloud_partner_node_allow_spend_proceeds(self, tmp_path): + from comfy_cli.comfy_client import SubmitResult + from comfy_cli.command.run import execute_cloud + from comfy_cli.target import Target + + target = Target( + kind="cloud", + base_url="https://cloud.example.com", + path_prefix="/api", + history_path="history_v2", + jobs_path="jobs", + api_key="k", + ) + mock_client = MagicMock() + mock_client.submit_prompt.return_value = SubmitResult(prompt_id="p1", number=1, node_errors={}) + with ( + patch("comfy_cli.cql.engine._load_from_target", return_value=self.CLOUD_OI), + patch("comfy_cli.command.run._preflight_validate"), + patch("comfy_cli.target.resolve_target", return_value=target), + patch("comfy_cli.comfy_client.Client", return_value=mock_client), + patch("comfy_cli.command.run._spawn_watcher"), + ): + execute_cloud(self._wf(tmp_path), wait=False, allow_spend=True) + assert mock_client.submit_prompt.called + + # --------------------------------------------------------------------------- # execute_cloud --wait terminal handling # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/command/test_run_template.py b/tests/comfy_cli/command/test_run_template.py index 4cb08de2..d1f3ce43 100644 --- a/tests/comfy_cli/command/test_run_template.py +++ b/tests/comfy_cli/command/test_run_template.py @@ -317,6 +317,20 @@ def test_allow_spend_unblocks_paid_template(app, gallery_file, template_body, se assert len(run_spy.calls) == 1 +def test_consented_handoff_forwards_allow_spend_so_run_does_not_regate( + app, gallery_file, template_body, server_up, run_spy +): + # BE-4326: run-template's spend gate has already consented, so the handoff + # to `comfy run`'s execute() must forward allow_spend=True — otherwise + # execute()'s own gate would fail closed a second time under --json. + _force_json_renderer() + result = CliRunner().invoke( + app, ["run-template", "--gallery", gallery_file, "api_flux2", "--allow-spend", *HOSTPORT] + ) + assert result.exit_code == 0, result.output + assert run_spy.calls[0]["allow_spend"] is True + + def test_interactive_decline_blocks_without_submitting( app, gallery_file, template_body, server_up, run_spy, monkeypatch ): From 436d1f1321bb6a538c47b02d4bc1551f7631e8ee Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 24 Jul 2026 00:03:13 -0700 Subject: [PATCH 2/2] fix(run): harden spend-gate prompt + align docs (BE-4326 review) Address cursor-review panel findings on PR #591: - Guard sys.stdin.isatty(): detached/pythonw contexts can leave sys.stdin as None (AttributeError) or closed (ValueError); route those to the fail-closed machine-mode error instead of crashing. - Escape partner class_type names before Rich-markup interpolation so a name like `[bold]` can't raise MarkupError or inject formatting. - Align docs/json-output.md: the cloud spend_consent_required variant carries `where: "cloud"` in details, not host/port. The fail-open catalog-unavailable findings (High/Medium) are a deliberate, load-bearing design choice; deferred to a follow-up for a product decision rather than reversed here. Co-Authored-By: Claude Opus 4.8 --- comfy_cli/command/run/__init__.py | 30 ++++++++++--- docs/json-output.md | 2 +- tests/comfy_cli/command/test_run.py | 69 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/comfy_cli/command/run/__init__.py b/comfy_cli/command/run/__init__.py index 8cfeb7cc..39205029 100644 --- a/comfy_cli/command/run/__init__.py +++ b/comfy_cli/command/run/__init__.py @@ -15,6 +15,7 @@ from urllib import request # noqa: F401 — patch target for tests (run.request.urlopen) import typer +from rich.markup import escape as _rich_escape from websocket import ( # noqa: F401 — patch target for tests (run.WebSocket) WebSocket, WebSocketException, @@ -51,6 +52,24 @@ workspace_manager = WorkspaceManager() +def _stdin_is_interactive() -> bool: + """True only when stdin is a live TTY. + + ``sys.stdin.isatty()`` assumes stdin is a live stream, but in detached / + ``pythonw`` contexts ``sys.stdin`` can be ``None`` (AttributeError on + ``.isatty``) or a closed file (ValueError). Treat both as non-interactive so + the spend gate falls through to the fail-closed machine-mode error instead + of raising an uncontrolled exception (BE-4326). + """ + stdin = getattr(sys, "stdin", None) + if stdin is None: + return False + try: + return bool(stdin.isatty()) + except (AttributeError, ValueError): + return False + + def _spend_gate(renderer, partner_nodes: list[str], allow_spend: bool, *, details: dict) -> None: """Consent interlock for partner-API (paid) nodes (BE-4326). @@ -66,11 +85,12 @@ def _spend_gate(renderer, partner_nodes: list[str], allow_spend: bool, *, detail """ if not partner_nodes or allow_spend: return - if renderer.is_pretty() and sys.stdin.isatty(): - pprint( - "[yellow]⚠ This workflow uses partner-API nodes that spend Comfy " - f"credits: {', '.join(partner_nodes)}.[/yellow]" - ) + if renderer.is_pretty() and _stdin_is_interactive(): + # Escape class_type names before interpolating into Rich markup: a name + # containing markup like ``[bold]`` would otherwise be parsed as a tag + # (MarkupError/StyleSyntaxError, or injected formatting). + names = ", ".join(_rich_escape(n) for n in partner_nodes) + pprint(f"[yellow]⚠ This workflow uses partner-API nodes that spend Comfy credits: {names}.[/yellow]") if not typer.confirm("Run anyway and spend credits?", default=False): renderer.error( code="spend_consent_required", diff --git a/docs/json-output.md b/docs/json-output.md index 13f3ab8b..0d016a3d 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -324,7 +324,7 @@ registry test enforces this) and surfaced by `comfy discover`. | `connection_error` | Server unreachable mid-flow: `URLError`, `TimeoutError`, or other `OSError` (including on `/object_info`) | — | 1 | | `workflow_unknown_nodes` | Pre-submit validation found unknown class_types / shape mismatches | `errors` (array), `warnings` (array) | 1 | | `partner_node_requires_credential` | Workflow uses a partner-API node and no `api_key_comfy_org` credential is available | `partner_nodes` (array of str), `host`, `port` | 1 | -| `spend_consent_required` | Workflow embeds partner-API (paid) nodes and `--allow-spend` was not passed (machine mode) or interactive consent was declined; re-run with `--allow-spend`. Free (non-partner) workflows are unaffected. | `partner_nodes` (array of str), `host`, `port` | 1 | +| `spend_consent_required` | Workflow embeds partner-API (paid) nodes and `--allow-spend` was not passed (machine mode) or interactive consent was declined; re-run with `--allow-spend`. Free (non-partner) workflows are unaffected. | `partner_nodes` (array of str); local path also carries `host`, `port`, the cloud path carries `where: "cloud"` | 1 | | `prompt_rejected` | Server returned HTTP 400 with `node_errors` | `status` (400), `node_errors` (array — [shape](#node_errors-shape)) | 1 | | `client_error` | Server returned another HTTP 4xx response | `status` (int, 4xx), `body` (str) | 1 | | `server_error` | Server returned an HTTP 5xx response | `status` (int, 5xx), `body` (str) | 1 | diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index bd6abedc..682748e2 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -1034,6 +1034,75 @@ def test_tty_confirm_accepted_proceeds(self, tmp_path, monkeypatch): assert not any(e["code"] == "spend_consent_required" for e in errors) +class TestSpendGateStdinAndMarkup: + """Robustness of the interactive spend prompt (BE-4326): a missing/closed + stdin must fall through to the fail-closed machine-mode error rather than + crash, and partner class_type names must not be interpreted as Rich markup.""" + + PARTNER_NODES = ["Veo3VideoGenerationNode"] + + def _capture_errors(self, monkeypatch): + errors = [] + from comfy_cli.output.renderer import Renderer + + original_error = Renderer.error + + def capture_error(self, *, code, message, hint=None, details=None, exit_code=1): + errors.append({"code": code, "message": message, "details": details}) + return original_error(self, code=code, message=message, hint=hint, details=details, exit_code=exit_code) + + monkeypatch.setattr(Renderer, "error", capture_error) + return errors + + def test_stdin_none_is_non_interactive(self, monkeypatch): + """`sys.stdin is None` (detached/pythonw) → non-interactive, no crash.""" + from comfy_cli.command.run import _stdin_is_interactive + + monkeypatch.setattr("comfy_cli.command.run.sys.stdin", None) + assert _stdin_is_interactive() is False + + def test_stdin_closed_is_non_interactive(self, monkeypatch): + """A closed stdin raises ValueError on `.isatty()` → non-interactive.""" + from comfy_cli.command.run import _stdin_is_interactive + + closed = io.StringIO() + closed.close() + monkeypatch.setattr("comfy_cli.command.run.sys.stdin", closed) + assert _stdin_is_interactive() is False + + def test_pretty_with_dead_stdin_fails_closed_not_crash(self, monkeypatch): + """Pretty renderer + None stdin: the gate must emit the fail-closed + machine-mode error and Exit(1), never an uncontrolled AttributeError.""" + from comfy_cli.command.run import _spend_gate + from comfy_cli.output.renderer import Renderer + + monkeypatch.setattr(Renderer, "is_pretty", lambda self: True) + monkeypatch.setattr("comfy_cli.command.run.sys.stdin", None) + errors = self._capture_errors(monkeypatch) + renderer = Renderer() + + with pytest.raises(typer.Exit) as exc_info: + _spend_gate(renderer, self.PARTNER_NODES, False, details={"partner_nodes": self.PARTNER_NODES}) + assert exc_info.value.exit_code == 1 + assert any(e["code"] == "spend_consent_required" for e in errors) + + def test_markup_in_node_name_does_not_crash_confirm(self, monkeypatch): + """A class_type containing Rich markup like `[bold]` must be escaped, + not parsed — the interactive confirm renders without MarkupError.""" + from comfy_cli.command.run import _spend_gate + from comfy_cli.output.renderer import Renderer + + monkeypatch.setattr(Renderer, "is_pretty", lambda self: True) + monkeypatch.setattr("comfy_cli.command.run._stdin_is_interactive", lambda: True) + # Accept the prompt so the gate returns cleanly; the point is the render + # of the warning line above the prompt must not raise. + monkeypatch.setattr("typer.confirm", lambda *a, **k: True) + renderer = Renderer() + + # Should not raise (MarkupError/StyleSyntaxError) despite the `[bold]`. + _spend_gate(renderer, ["Evil[bold]Node"], False, details={"partner_nodes": ["Evil[bold]Node"]}) + + class TestExecuteUiWorkflow: UI = { "nodes": [