Skip to content
Closed
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
33 changes: 33 additions & 0 deletions src/gflow_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import math
import os
import re
import warnings
from collections.abc import Mapping
from enum import StrEnum
Expand Down Expand Up @@ -525,6 +526,38 @@ def _validate_llm_base_url(cls, v: object) -> object:
"Override via GFLOW_CLI_LEASE_WAIT_SECONDS."
),
)
default_project_id: str | None = Field(
default=None,
description=(
"Fallback project used by the MCP gflow_generate_image / "
"gflow_generate_video tools when a call omits `project`. Applies "
"to those two MCP tools ONLY — the CLI's own --project-omission "
"path (cli_image.py / cli_video.py) resolves synchronously and "
"does not read this setting, so `gflow image t2i` without "
"--project is unaffected. Omitting MCP `project` has always meant "
"'create a scratch project' via labs.google's "
"project.createProject — which 401s unconditionally on an "
"account Google has migrated to flow.google.com (project "
"creation isn't ported there yet, #791/#639), mislabeled by the "
"response as an auth failure. Set this to an existing project id "
"(from the Flow editor URL, .../project/<id>/...) to make "
"omitting MCP `project` work on such an account instead of "
"failing every time. Still overridden by an explicit MCP "
"`project` argument. Override via GFLOW_CLI_DEFAULT_PROJECT_ID."
),
)

@field_validator("default_project_id")
@classmethod
def _validate_default_project_id(cls, value: str | None) -> str | None:
"""Same allowlist as the --project CLI option (_cli_helpers._FLOW_ID_RE,
routes._PROJECT_ID_RE): this id is interpolated into navigation URLs and
CSS selectors before any other guard runs."""
if value is not None and not re.fullmatch(r"[A-Za-z0-9\-]{1,128}", value):
msg = "GFLOW_CLI_DEFAULT_PROJECT_ID must be 1-128 chars of letters, digits, or hyphens."
raise ValueError(msg)
return value

headless: bool = Field(
default=False,
description=(
Expand Down
21 changes: 15 additions & 6 deletions src/gflow_cli/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,11 @@ async def gflow_generate_image(
``GFLOW_CLI_PROFILE`` env var → ``config.toml`` default →
auto-select if exactly one profile exists.
project: Optional existing Flow project id to generate into (mirrors the
CLI ``--project`` flag). When omitted, a scratch project is created
as before.
CLI ``--project`` flag). When omitted, falls back to
``GFLOW_CLI_DEFAULT_PROJECT_ID`` if the operator set one, else a
scratch project is created — which fails on an account Google has
migrated to flow.google.com (project creation isn't ported there
yet, #791); set the default rather than retrying without one.
project_name: Optional human-readable project title to use when creating a
fresh Flow project.
instructions: Optional list of custom agent instructions to add or enable
Expand All @@ -817,6 +820,8 @@ async def gflow_generate_image(
"""
if (proj_err := _validate_project(project)) is not None:
return proj_err
if project is None:
project = get_settings().default_project_id

if ui_mode is not None:
# Normalize case to mirror the CLI's click.Choice(case_sensitive=False)
Expand Down Expand Up @@ -1057,10 +1062,12 @@ async def gflow_generate_video( # NOSONAR
auto-select if exactly one profile exists.
project: Optional existing Flow project id to generate into (mirrors the
CLI ``--project`` flag on ``video t2v``/``i2v``/``r2v``). When
omitted, a scratch project is created on labs.google. On an account
Google has moved to flow.google.com (``GFLOW_CLI_FLOW_HOST``, read from
the server/daemon environment, not per call) ``project`` is required —
omitting it returns the exit-11-equivalent envelope. There the ported
omitted, falls back to ``GFLOW_CLI_DEFAULT_PROJECT_ID`` if the
operator set one, else a scratch project is created on labs.google.
On an account Google has moved to flow.google.com
(``GFLOW_CLI_FLOW_HOST``, read from the server/daemon environment,
not per call) ``project`` (explicit or defaulted) is required —
omitting both returns the exit-11-equivalent envelope. There the ported
modes are 't2v'; 'i2v' with a local ``initial_frame`` and no
``end_frame``; and 'r2v' with local ``reference_images``. A UUID
frame, an end frame, and r2v by ``ref_names`` or
Expand All @@ -1079,6 +1086,8 @@ async def gflow_generate_video( # NOSONAR
"""
if (proj_err := _validate_project(project)) is not None:
return proj_err
if project is None:
project = get_settings().default_project_id

if ui_mode is not None:
# Normalize case to mirror the CLI's click.Choice(case_sensitive=False);
Expand Down
56 changes: 56 additions & 0 deletions tests/mcp/test_tools_wired.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ async def test_image_t2i_returns_completed(self, temp_db: DataStore, tmp_path: P
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
default_project_id=None,
timeout_seconds=30,
),
),
Expand All @@ -137,6 +138,54 @@ async def test_image_t2i_returns_completed(self, temp_db: DataStore, tmp_path: P
assert "files" in result
assert result["params"]["prompt"] == "scenic mountain at sunset"

@pytest.mark.asyncio
async def test_image_omitted_project_falls_back_to_configured_default(
self, temp_db: DataStore, tmp_path: Path
) -> None:
"""#791/#639 follow-up: omitting `project` must not always mean 'create
a scratch project' — that 401s unconditionally on an account Google
has migrated to flow.google.com. GFLOW_CLI_DEFAULT_PROJECT_ID lets an
operator make omission resolve to a known-good existing project
instead, mirrored between the CLI and this MCP tool.
"""
from gflow_cli.mcp.tools import gflow_generate_image

fake_client = _FakeFlowApiClient()
with (
patch(
"gflow_cli.mcp.tools._resolve_and_validate_profile",
return_value="default",
),
patch(
"gflow_cli.worker.daemon.FlowApiClient",
return_value=fake_client,
),
patch(
"gflow_cli.mcp.tools.get_settings",
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
timeout_seconds=30,
default_project_id="existing-proj-1",
),
),
patch(
"gflow_cli.worker.daemon.get_settings",
return_value=MagicMock(
profile_subdir=lambda _: tmp_path / "profile_default",
headless=True,
transport=None,
output_dir=tmp_path / "out",
),
),
):
result = await gflow_generate_image(prompt="scenic mountain at sunset")

assert result["status"] == "completed"
assert result["params"]["project"] == "existing-proj-1"
# The default routes into the EXISTING-project path, not scratch creation.
fake_client.create_project.assert_not_awaited()

@pytest.mark.asyncio
async def test_image_completed_task_has_params(
self, temp_db: DataStore, tmp_path: Path
Expand All @@ -158,6 +207,7 @@ async def test_image_completed_task_has_params(
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
default_project_id=None,
timeout_seconds=30,
),
),
Expand Down Expand Up @@ -206,6 +256,7 @@ async def test_image_explicit_output_file(self, temp_db: DataStore, tmp_path: Pa
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
default_project_id=None,
timeout_seconds=30,
),
),
Expand Down Expand Up @@ -274,6 +325,7 @@ async def test_video_t2v_returns_completed(self, temp_db: DataStore, tmp_path: P
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
default_project_id=None,
timeout_seconds=30,
),
),
Expand Down Expand Up @@ -333,6 +385,7 @@ async def test_video_explicit_output_file(self, temp_db: DataStore, tmp_path: Pa
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
default_project_id=None,
timeout_seconds=30,
),
),
Expand Down Expand Up @@ -453,6 +506,7 @@ async def test_video_failed_task_returns_error(
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
default_project_id=None,
timeout_seconds=30,
),
),
Expand Down Expand Up @@ -626,6 +680,7 @@ async def test_task_enqueued_and_completed(self, temp_db: DataStore, tmp_path: P
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
default_project_id=None,
),
),
patch(
Expand Down Expand Up @@ -666,6 +721,7 @@ async def test_envelope_returns_media_id_not_workflow_id(
return_value=MagicMock(
resolved_db_path=lambda: temp_db.path,
profile_subdir=lambda _: tmp_path / "profile_default",
default_project_id=None,
),
),
patch(
Expand Down
36 changes: 36 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,3 +618,39 @@ def test_key_is_never_forwarded(self, clean_env: None, monkeypatch: pytest.Monke
monkeypatch.setenv("GFLOW_CLI_GEMINI_API_KEY", "AIza-old")
monkeypatch.delenv("GFLOW_CLI_LLM_API_KEY", raising=False)
assert Settings().llm_api_key is None


class TestDefaultProjectId:
"""#791/#639 follow-up: omitting --project / MCP `project` has always meant
'create a scratch project' via labs.google, which 401s unconditionally on
an account Google has migrated to flow.google.com. This setting lets an
operator point omission at a known-good existing project instead."""

def test_defaults_to_none(self, clean_env: None) -> None:
assert Settings().default_project_id is None

def test_accepts_a_valid_id_from_env(
self, clean_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("GFLOW_CLI_DEFAULT_PROJECT_ID", "08edda4d-6a2b-4c78-972d-469ecf842e77")
assert Settings().default_project_id == "08edda4d-6a2b-4c78-972d-469ecf842e77"

@pytest.mark.parametrize(
"bad_value",
[
"../../etc/passwd",
"abc/def",
"id with spaces",
"id;rm -rf",
"a" * 129,
],
)
def test_rejects_an_id_shaped_like_an_injection_attempt(
self, clean_env: None, monkeypatch: pytest.MonkeyPatch, bad_value: str
) -> None:
"""Same allowlist as --project (_cli_helpers._FLOW_ID_RE): this id is
interpolated into navigation URLs and CSS selectors before any other
guard runs, so a malformed value must fail closed at config load."""
monkeypatch.setenv("GFLOW_CLI_DEFAULT_PROJECT_ID", bad_value)
with pytest.raises(ValueError, match="GFLOW_CLI_DEFAULT_PROJECT_ID"):
Settings()