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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Windows profile write-denial is distinguished from browser contention.** When
the generation client's browser launch fails with a Chrome `ProcessSingleton`
error carrying Windows access-denied code 5, it now raises `ProfileAccessError`
(configuration exit code 11) instead of a misleading "another Chrome holds it"
`ProfileLockedError` or an unclassified error. The match is on the error code,
so it works in every Windows display language. The remediation points to
profile-directory write access, not to closing a competing Chrome. Not covered
yet: `gflow auth login` / auth verification, the standalone and experimental
transport launches, and CDP launch — those still surface Chrome's raw error.
Thanks @L1meSn0w for the diagnosis and the Windows reproduction (#919).

## [0.80.0] — 2026-09-29

### Security
Expand Down
2 changes: 2 additions & 0 deletions KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,8 @@ key — surfaces as a `RuntimeError` that `auth/cookies.py` normalizes to

Chromium refuses to open two persistent contexts on the same `user-data-dir` simultaneously. Historically this surfaced as an unhelpful Chromium "ProcessSingleton: profile is locked" error partway through a run. As of the profile-lease hardening (production-readiness plan, slice D1/D3), gflow-cli enforces this itself: a cross-process advisory lock (`ProfileLease`, kernel `flock` on POSIX / `msvcrt.locking` on Windows) guards every profile directory. A second `gflow` invocation, `gflow serve` daemon task, or MCP call against an already-leased profile is rejected **immediately** by default — before any Chrome process starts — with a typed `ProfileLockedError` (**exit code 11**); it never silently corrupts the profile. Since #478, setting [`GFLOW_CLI_LEASE_WAIT_SECONDS`](docs/CONFIGURATION.md#gflow_cli_lease_wait_seconds) opts a waiter into a bounded wait that takes over as soon as the current holder finishes (holders always run to completion and are never asked to release early; same-process contention still fails fast — waiting on yourself would deadlock). Since #864 an MCP server waits 180 s by default, and queues its own calls on one profile rather than rejecting the second.

**Not every `ProcessSingleton` failure is contention.** On Windows, a `ProcessSingleton` error carrying access-denied code `(0x5)` means Chrome cannot write the profile directory; gflow reports it as `ProfileAccessError` — see [CONFIGURATION § Profile-directory permissions at browser launch](docs/CONFIGURATION.md#profile-directory-permissions-at-browser-launch).

**Workaround:** use different profiles for parallel work — different profiles acquire independent leases and run fully concurrently.

### gflow refuses to open my profile after a downgrade ("written by a newer Chromium", exit 11)
Expand Down
16 changes: 16 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ See [AUTHENTICATION § Session storage](AUTHENTICATION.md#session-storage) for t
create it — a `tools/` directory of user-authored "My Tools" TOMLs
(`<GFLOW_CLI_HOME>/tools/*.toml`, auto-loaded; see [TOOLS.md § My Tools](TOOLS.md)).

#### Profile-directory permissions at browser launch

On Windows, when a generation run's browser launch fails with a Chrome
`ProcessSingleton` error carrying access-denied code 5, gflow reports
`ProfileAccessError`, a configuration error (exit code 11), rather than suggesting
contention. Ensure the process can write the complete persistent profile directory,
or use a profile inside a writable `GFLOW_CLI_HOME`. Relocating application-level
lease locks alone does not relocate Chrome's `ProcessSingleton`, cookies, or crash
reports.

This classification covers the generation client's launch only. `gflow auth login`,
auth verification, the standalone and experimental transports, and CDP launch still
surface Chrome's raw error for the same cause; look for `ProcessSingleton` plus
`(0x5)` in it. Launch failures without the access-denied code keep their existing
classification; this is not general cross-platform permission-error detection.

### `GFLOW_CLI_PROFILE`

**What:** Default profile name used when `--profile` isn't passed on the CLI.
Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1951,7 +1951,7 @@ shell scripts can branch on the failure mode without parsing stderr.
| `8` | `AuthMissingError` | Required auth credential is absent from profile | `gflow auth login --profile <name>` |
| `9` | `TransportTimeoutError` | Browser/API operation exceeded its timeout (incl. an i2v frame UUID not found in the media picker, #287; or a wedged submission stage — the error names the stage and your Playwright version) | Retry; raise the relevant timeout — for a frame-UUID miss verify the UUID belongs to the `--project` passed; for a `stage_stalled` abort check your Playwright is in range (see [KNOWN_ISSUES](../KNOWN_ISSUES.md)) |
| `10` | `WafRejectionError` | Flow security layer rejected the request | Change prompt/request and retry |
| `11` | `ConfigurationError` | Local configuration or browser mode is invalid — on the migrated `flow.google.com` host also a request the host cannot take as given (no `--project`, a model its menu does not offer, a `--duration` its settings pane renders no control for); includes `ProfileLockedError` (same-profile lease contention: another `gflow`/daemon/MCP call already owns this profile) and `ProfileEngineDowngradeError` (the profile was last written by a newer Chromium major than the bundled engine about to open it — see [AUTHENTICATION § Chromium downgrade guard](AUTHENTICATION.md#chromium-downgrade-guard)) | Fix the option/env var shown in the error; for lease contention wait, use a different `--profile`, or set `GFLOW_CLI_LEASE_WAIT_SECONDS=N` to wait bounded; upgrade gflow-cli/Playwright or re-run `gflow auth login` for a downgrade refusal |
| `11` | `ConfigurationError` | Local configuration or browser mode is invalid — on the migrated `flow.google.com` host also a request the host cannot take as given (no `--project`, a model its menu does not offer, a `--duration` its settings pane renders no control for); includes `ProfileLockedError` (same-profile lease contention: another `gflow`/daemon/MCP call already owns this profile), `ProfileAccessError` (Windows Chrome cannot write the profile directory), and `ProfileEngineDowngradeError` (the profile was last written by a newer Chromium major than the bundled engine about to open it — see [AUTHENTICATION § Chromium downgrade guard](AUTHENTICATION.md#chromium-downgrade-guard)) | Fix the option/env var shown in the error; for lease contention wait, use a different `--profile`, or set `GFLOW_CLI_LEASE_WAIT_SECONDS=N` to wait bounded; for profile access denial grant write access to the complete profile directory or use a writable profile; upgrade gflow-cli/Playwright or re-run `gflow auth login` for a downgrade refusal |
| `12` | `AuthLoginTimeoutError` | Browser sign-in was not completed in time | Re-run login or raise `GFLOW_CLI_AUTH_LOGIN_TIMEOUT` |
| `13` | `SecurityError` | Unsafe local profile or secret handling blocked | Follow the error's safety guidance |
| `14` | `AuthBrowserRejectedError` | Sign-in rejected the browser for `navigator.webdriver` | Re-run `gflow auth login`; with Chrome installed the `chrome` strategy retries automatically |
Expand Down
23 changes: 22 additions & 1 deletion src/gflow_cli/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
FlowApiError, # re-exported via gflow_cli.api.__init__
FlowHostMigratedError,
NetworkError,
ProfileAccessError,
ProfileLockedError,
RateLimitError,
SceneConcatError,
Expand Down Expand Up @@ -165,6 +166,17 @@ def _dc_replace(obj: _DataclassT, /, **changes: Any) -> _DataclassT:
)


def _is_profile_access_denied(exc: BaseException) -> bool:
"""True when Chrome's ProcessSingleton failed because the profile is read-only.

Matches the Win32 code (ERROR_ACCESS_DENIED = 5), never the message text: Chrome
embeds the OS's *localized* description (``拒绝访问。 (0x5)``, ``Acesso negado.
(0x5)``), so only the code is the same on every Windows display language.
"""
message = str(exc)
return "ProcessSingleton" in message and ("(0x5)" in message or "Error code: 5" in message)


def _is_target_closed(exc: BaseException) -> bool:
"""Heuristic — True if ``exc`` is Playwright's TargetClosedError or wraps it."""
name = type(exc).__name__
Expand Down Expand Up @@ -1093,11 +1105,20 @@ async def _settled_lang(self, page: Any) -> str | None:
return after

async def _launch_persistent_context(self, kwargs: JsonObject) -> BrowserContext:
"""Launch the persistent context; translate a launch-time crash into ProfileLockedError."""
"""Launch the context and classify profile access versus contention failures."""
assert self._pw is not None
try:
return await self._pw.chromium.launch_persistent_context(**kwargs)
except Exception as exc:
# Before _is_target_closed: an access denial can arrive as a
# TargetClosedError too, and must not be reported as contention.
if _is_profile_access_denied(exc):
raise ProfileAccessError(
detail=(
f"Chrome cannot create runtime files under {self.profile_dir}; "
"the directory is not writable by this process"
),
) from exc
if _is_target_closed(exc):
# A TargetClosedError at LAUNCH usually means the profile dir
# is held by another Chrome — most commonly a stale browser
Expand Down
15 changes: 15 additions & 0 deletions src/gflow_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"NetworkError",
"OwnerEvidence",
"ProblemDetails",
"ProfileAccessError",
"QueueSchemaError",
"RateLimitError",
"SceneConcatError",
Expand Down Expand Up @@ -459,6 +460,20 @@ class ConfigurationError(GFlowError):
)


class ProfileAccessError(ConfigurationError):
"""Raised when Chrome cannot write its persistent profile directory."""

problem_type = "https://gflow-cli.dev/errors/profile-access"
title = "Profile directory not writable"
_default_remediation = (
"Check filesystem permissions or sandbox policy for the full profile "
"directory. Grant this process write access, or authenticate a profile "
"inside a writable GFLOW_CLI_HOME. Relocating only application-level lock "
"files is not enough; Chrome still writes its ProcessSingleton, cookie, and "
"crash-report files inside the profile."
)


@dataclass(frozen=True, slots=True)
class OwnerEvidence:
"""Private diagnostic evidence about the recorded lease owner (§6.4 of the
Expand Down
82 changes: 82 additions & 0 deletions tests/api/test_concurrency.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,85 @@ class TargetClosedError(Exception):
await client.__aenter__()
assert "stale Chrome" in (exc_info.value.remediation_hint or "")
pw.stop.assert_awaited() # partial-setup guard still tears down the driver


_SINGLETON_HEADER = (
"BrowserType.launch_persistent_context: Failed to create a ProcessSingleton "
"for your profile directory. This usually means that the profile is already "
"in use by another instance of Chromium."
)


@pytest.mark.parametrize("error_class_name", ["TargetClosedError", "Error"])
@pytest.mark.parametrize(
"lock_line",
[
"Lock file can not be created: Access is denied. (0x5)",
"Lock file can not be created: 拒绝访问。 (0x5)", # zh-CN, measured on Chrome 154
"Lock file can not be created: Acesso negado. (0x5)", # pt-BR
"Lock file can not be created: Zugriff verweigert (0x5)", # de-DE
"Lock file can not be created! Error code: 5", # older Chrome wording
],
)
@pytest.mark.asyncio
async def test_process_singleton_access_denied_raises_profile_access_error(
tmp_path: Path, settings_n4: Settings, error_class_name: str, lock_line: str
) -> None:
"""A launch-time access denial is not evidence of a competing Chrome owner — in
any Windows display language, and whether Playwright raises TargetClosedError
or a generic Error (Chrome 154's real shape)."""
from gflow_cli.errors import ProfileAccessError
from gflow_cli.json_output import exit_code_for

launch_error = type(error_class_name, (Exception,), {})
with patch("gflow_cli.api.client.async_playwright") as mock_pw_factory:
pw = MagicMock()
pw.stop = AsyncMock()
pw.chromium.launch_persistent_context = AsyncMock(
side_effect=launch_error(f"{_SINGLETON_HEADER}\n{lock_line}")
)
mock_pw_factory.return_value.start = AsyncMock(return_value=pw)
client = FlowApiClient(profile_dir=tmp_path, settings=settings_n4)
with pytest.raises(ProfileAccessError, match="not writable") as exc_info:
await client.__aenter__()
error = exc_info.value
assert exit_code_for(error) == 11
assert error.to_problem_details()["type"] == "https://gflow-cli.dev/errors/profile-access"
assert "filesystem permissions" in (error.remediation_hint or "")
pw.stop.assert_awaited()


@pytest.mark.asyncio
async def test_process_singleton_without_access_code_stays_contention(
tmp_path: Path, settings_n4: Settings
) -> None:
"""Playwright's real 'already in use' header with no 0x5 line is still contention."""
from gflow_cli.errors import ProfileLockedError

class TargetClosedError(Exception):
pass

with patch("gflow_cli.api.client.async_playwright") as mock_pw_factory:
pw = MagicMock()
pw.stop = AsyncMock()
pw.chromium.launch_persistent_context = AsyncMock(
side_effect=TargetClosedError(_SINGLETON_HEADER)
)
mock_pw_factory.return_value.start = AsyncMock(return_value=pw)
client = FlowApiClient(profile_dir=tmp_path, settings=settings_n4)
with pytest.raises(ProfileLockedError):
await client.__aenter__()


@pytest.mark.parametrize(
"detail",
[
"Access is denied. (0x5)",
"ProcessSingleton is already held by another process",
"Target closed",
],
)
def test_profile_access_denied_requires_singleton_and_permission_markers(detail: str) -> None:
from gflow_cli.api.client import _is_profile_access_denied

assert not _is_profile_access_denied(RuntimeError(detail))
16 changes: 16 additions & 0 deletions website/docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ See [AUTHENTICATION § Session storage](AUTHENTICATION.md#session-storage) for t
create it — a `tools/` directory of user-authored "My Tools" TOMLs
(`<GFLOW_CLI_HOME>/tools/*.toml`, auto-loaded; see [TOOLS.md § My Tools](TOOLS.md)).

#### Profile-directory permissions at browser launch

On Windows, when a generation run's browser launch fails with a Chrome
`ProcessSingleton` error carrying access-denied code 5, gflow reports
`ProfileAccessError`, a configuration error (exit code 11), rather than suggesting
contention. Ensure the process can write the complete persistent profile directory,
or use a profile inside a writable `GFLOW_CLI_HOME`. Relocating application-level
lease locks alone does not relocate Chrome's `ProcessSingleton`, cookies, or crash
reports.

This classification covers the generation client's launch only. `gflow auth login`,
auth verification, the standalone and experimental transports, and CDP launch still
surface Chrome's raw error for the same cause; look for `ProcessSingleton` plus
`(0x5)` in it. Launch failures without the access-denied code keep their existing
classification; this is not general cross-platform permission-error detection.

### `GFLOW_CLI_PROFILE`

**What:** Default profile name used when `--profile` isn't passed on the CLI.
Expand Down
2 changes: 2 additions & 0 deletions website/docs/KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,8 @@ key — surfaces as a `RuntimeError` that `auth/cookies.py` normalizes to

Chromium refuses to open two persistent contexts on the same `user-data-dir` simultaneously. Historically this surfaced as an unhelpful Chromium "ProcessSingleton: profile is locked" error partway through a run. As of the profile-lease hardening (production-readiness plan, slice D1/D3), gflow-cli enforces this itself: a cross-process advisory lock (`ProfileLease`, kernel `flock` on POSIX / `msvcrt.locking` on Windows) guards every profile directory. A second `gflow` invocation, `gflow serve` daemon task, or MCP call against an already-leased profile is rejected **immediately** by default — before any Chrome process starts — with a typed `ProfileLockedError` (**exit code 11**); it never silently corrupts the profile. Since #478, setting [`GFLOW_CLI_LEASE_WAIT_SECONDS`](CONFIGURATION.md#gflow_cli_lease_wait_seconds) opts a waiter into a bounded wait that takes over as soon as the current holder finishes (holders always run to completion and are never asked to release early; same-process contention still fails fast — waiting on yourself would deadlock). Since #864 an MCP server waits 180 s by default, and queues its own calls on one profile rather than rejecting the second.

**Not every `ProcessSingleton` failure is contention.** On Windows, a `ProcessSingleton` error carrying access-denied code `(0x5)` means Chrome cannot write the profile directory; gflow reports it as `ProfileAccessError` — see [CONFIGURATION § Profile-directory permissions at browser launch](CONFIGURATION.md#profile-directory-permissions-at-browser-launch).

**Workaround:** use different profiles for parallel work — different profiles acquire independent leases and run fully concurrently.

### gflow refuses to open my profile after a downgrade ("written by a newer Chromium", exit 11)
Expand Down
Loading
Loading