Skip to content
Open
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
12 changes: 12 additions & 0 deletions comfy_cli/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -871,6 +881,7 @@ def run(
notify=effective_notify,
print_prompt=print_prompt,
preloaded=preloaded,
allow_spend=allow_spend,
)
return

Expand All @@ -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:
Expand Down
85 changes: 85 additions & 0 deletions comfy_cli/command/run/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
"""

import json
import sys
import time
import uuid
from datetime import timedelta
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,
Expand Down Expand Up @@ -50,6 +52,64 @@
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).

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 _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",
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).
Expand Down Expand Up @@ -105,6 +165,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.
Expand Down Expand Up @@ -215,6 +276,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(
Comment thread
mattmillerai marked this conversation as resolved.
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}
Expand Down Expand Up @@ -500,6 +572,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.

Expand Down Expand Up @@ -601,6 +674,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)
Comment thread
mattmillerai marked this conversation as resolved.
_spend_gate(
renderer,
partner_nodes,
allow_spend,
details={"partner_nodes": partner_nodes, "where": "cloud"},
Comment thread
mattmillerai marked this conversation as resolved.
)

target = resolve_target(where="cloud")
try:
client = Client(target, timeout=float(timeout))
Expand Down
5 changes: 5 additions & 0 deletions comfy_cli/command/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
mattmillerai marked this conversation as resolved.
)
finally:
try:
Expand Down
10 changes: 10 additions & 0 deletions comfy_cli/skills/comfy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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); 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 |
Expand Down
Loading
Loading