Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions apps/minds/changelog/preston-minds-inspirations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Creating a workspace from a private (or nonexistent) GitHub repository URL now shows helpful guidance instead of just a raw git error. When the clone fails with an authentication-shaped error for a github.com URL, the creating page explains that the repository looks private, recommends signing in with the GitHub CLI (`gh auth login`, with a link to the official quickstart), and offers the alternative of cloning the repository locally and entering its path in the form instead.

The desktop client's local `git clone` of the workspace source now runs with `GIT_TERMINAL_PROMPT=0`, so cloning a repository this computer has no credentials for fails fast with a clear error instead of hanging on a credential prompt (mirroring the earlier fix in the FCT bootstrap's git calls).

The create-operation status API (`GET /api/v1/workspaces/operations/create/<id>`) gained an optional `error_kind` field carrying a machine-readable failure classification (currently `GIT_AUTH_REQUIRED`) alongside the human-readable `error` message.
108 changes: 108 additions & 0 deletions apps/minds/imbue/minds/desktop_client/agent_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,23 @@ class AgentCreationStatus(UpperCaseStrEnum):
FAILED = auto()


class CreationErrorKind(UpperCaseStrEnum):
"""Machine-readable classification of a creation failure.

Carried alongside the human-readable ``error`` message so the creating
page can gate extra static guidance on the failure *type* instead of
substring-matching the message client-side. Only failure kinds that
change what the UI shows get a value here; unclassified failures carry
no kind and the UI shows just the error message.
"""

# The workspace source is a github.com URL and git hit an authentication
# challenge cloning it: the repo is private (or does not exist -- GitHub
# deliberately answers both the same way, to avoid leaking which private
# repos exist) and none of this machine's git credentials can see it.
GIT_AUTH_REQUIRED = auto()


class AgentCreationInfo(FrozenModel):
"""Snapshot of agent creation state, returned to callers for status polling.

Expand Down Expand Up @@ -243,6 +260,13 @@ class AgentCreationInfo(FrozenModel):
)
redirect_url: str | None = Field(default=None, description="URL to redirect to when creation is done")
error: str | None = Field(default=None, description="Error message, set when status is FAILED")
error_kind: CreationErrorKind | None = Field(
default=None,
description=(
"Machine-readable classification of the failure, set alongside ``error`` when the "
"failure is recognized (see ``classify_creation_error``); ``None`` otherwise"
),
)


def extract_repo_name(git_url: str) -> str:
Expand Down Expand Up @@ -272,6 +296,62 @@ def _is_local_path(repo_source: str) -> bool:
return repo_source.startswith(("/", "./", "../", "~"))


def _is_github_https_url(repo_source: str) -> bool:
"""Check if a repo source is an http(s) URL on github.com.

Gates the private-repo failure classification (and the sign-in guidance
the creating page shows for it, which recommends the GitHub CLI) to
sources where that guidance is actually correct.
"""
parts = urlsplit(repo_source)
if parts.scheme not in ("http", "https"):
return False
return parts.hostname in ("github.com", "www.github.com")


# Substrings of git's output that identify an authentication-shaped clone
# failure. ``clone_git_repo`` runs every git command with GIT_TERMINAL_PROMPT=0,
# so the no-usable-credentials case deterministically produces "could not read
# Username ... terminal prompts disabled" instead of hanging on a prompt. The
# other markers cover a credential helper supplying credentials that GitHub
# rejects ("Authentication failed"), credentials that cannot see the repo
# ("Repository not found" -- GitHub reports inaccessible and nonexistent repos
# identically), and access blocked by a policy such as SAML SSO (403).
_GIT_AUTH_OUTPUT_MARKERS: Final[tuple[str, ...]] = (
"could not read Username",
"could not read Password",
"terminal prompts disabled",
"Authentication failed",
"Repository not found",
"The requested URL returned error: 403",
)
Comment thread
pseay-imbue marked this conversation as resolved.
Outdated


def classify_creation_error(repo_source: str, error: Exception) -> CreationErrorKind | None:
"""Classify a creation failure into a ``CreationErrorKind``, when recognizable.

Currently recognizes exactly one case: the clone of a
``https://github.com/...`` workspace source failed with an
authentication-shaped git error (see ``_GIT_AUTH_OUTPUT_MARKERS``) --
i.e. the repo is private (or does not exist) and this machine's git
credentials cannot see it. The creating page shows static sign-in
guidance for this kind. Every other failure returns ``None``.

Matching git's error text is the only option here (git has no structured
error output), but the no-credentials shape is under our control:
``clone_git_repo`` disables terminal prompting, which makes git emit its
stable "could not read Username" error for any auth challenge.
"""
if not isinstance(error, GitCloneError):
return None
if not _is_github_https_url(repo_source):
return None
message = str(error)
if any(marker in message for marker in _GIT_AUTH_OUTPUT_MARKERS):
return CreationErrorKind.GIT_AUTH_REQUIRED
return None


def _redact_url_credentials(url: str) -> str:
"""Strip any ``user[:password]@`` userinfo from a URL's netloc for logging.

Expand Down Expand Up @@ -335,6 +415,26 @@ def _is_git_worktree(repo_dir: Path) -> bool:
return dot_git.is_file()


def _git_noninteractive_env() -> dict[str, str]:
"""Environment for the desktop client's git calls: never prompt for credentials.

Git prompts for a username/password on the controlling terminal when a
remote needs auth and no credential is available -- but the desktop client
has no terminal for the user to answer on, and when minds is launched from
a dev shell the prompt would hang the creation thread forever. With
``GIT_TERMINAL_PROMPT=0``, cloning a repo this machine lacks credentials
for fails fast with git's stable "could not read Username ... terminal
prompts disabled" error, which ``classify_creation_error`` recognizes.
Credential helpers (e.g. the macOS keychain) still work as usual -- only
interactive terminal prompting is disabled.

Deliberately a small per-file copy of the same one-line helper the FCT's
``bootstrap.manager`` and ``runtime_backup.runner`` carry (same name, same
body), rather than a shared cross-package import.
"""
return {**os.environ, "GIT_TERMINAL_PROMPT": "0"}


def clone_git_repo(
git_url: GitUrl,
clone_dir: Path,
Expand Down Expand Up @@ -393,6 +493,8 @@ def clone_git_repo(
# which would otherwise leak tokens from credentialed URLs into logs.
redacted_on_output = _RedactingOutputCallback(inner=on_output) if on_output is not None else None

git_env = _git_noninteractive_env()

# All steps run under the same child concurrency group so cancellation is
# uniform; the failure is raised AFTER the `with cg` block to keep
# GitCloneError from being wrapped in a ConcurrencyExceptionGroup. For the
Expand All @@ -419,6 +521,7 @@ def clone_git_repo(
cwd=clone_dir,
is_checked_after=False,
on_output=redacted_on_output,
env=git_env,
)
if result.returncode != 0:
stderr = result.stderr.strip()
Expand Down Expand Up @@ -1361,6 +1464,7 @@ class AgentCreator(MutableModel):
_canonical_agent_ids: dict[str, AgentId] = PrivateAttr(default_factory=dict)
_redirect_urls: dict[str, str] = PrivateAttr(default_factory=dict)
_errors: dict[str, str] = PrivateAttr(default_factory=dict)
_error_kinds: dict[str, CreationErrorKind] = PrivateAttr(default_factory=dict)
_launch_modes: dict[str, LaunchMode] = PrivateAttr(default_factory=dict)
_host_names: dict[str, str] = PrivateAttr(default_factory=dict)
_log_queues: dict[str, queue.Queue[str]] = PrivateAttr(default_factory=dict)
Expand Down Expand Up @@ -1498,6 +1602,7 @@ def get_creation_info(self, creation_id: CreationId) -> AgentCreationInfo | None
host_name=self._host_names.get(cid_str, ""),
redirect_url=self._redirect_urls.get(cid_str),
error=self._errors.get(cid_str),
error_kind=self._error_kinds.get(cid_str),
)

def get_log_queue(self, creation_id: CreationId) -> queue.Queue[str] | None:
Expand Down Expand Up @@ -1882,9 +1987,12 @@ def _create_agent_background(
except (GitCloneError, GitOperationError, MngrCommandError, ImbueCloudCliError, ValueError, OSError) as e:
logger.opt(exception=e).error("Failed to create agent for creation {}", creation_id)
log_queue.put("[minds] ERROR: {}".format(e))
error_kind = classify_creation_error(repo_source, e)
with self._lock:
self._statuses[cid_str] = AgentCreationStatus.FAILED
self._errors[cid_str] = str(e)
if error_kind is not None:
self._error_kinds[cid_str] = error_kind
finally:
log_queue.put(LOG_SENTINEL)

Expand Down
106 changes: 106 additions & 0 deletions apps/minds/imbue/minds/desktop_client/agent_creator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@
from imbue.minds.config.data_types import WorkspacePaths
from imbue.minds.desktop_client.agent_creator import AgentCreationStatus
from imbue.minds.desktop_client.agent_creator import AgentCreator
from imbue.minds.desktop_client.agent_creator import CreationErrorKind
from imbue.minds.desktop_client.agent_creator import _CreateEventCapture
from imbue.minds.desktop_client.agent_creator import _GIT_AUTH_OUTPUT_MARKERS
from imbue.minds.desktop_client.agent_creator import _build_mngr_create_command
from imbue.minds.desktop_client.agent_creator import _is_git_worktree
from imbue.minds.desktop_client.agent_creator import _is_github_https_url
from imbue.minds.desktop_client.agent_creator import _is_local_path
from imbue.minds.desktop_client.agent_creator import _redact_url_credentials
from imbue.minds.desktop_client.agent_creator import _redact_url_credentials_in_text
from imbue.minds.desktop_client.agent_creator import _rsync_worktree_over_clone
from imbue.minds.desktop_client.agent_creator import checkout_branch
from imbue.minds.desktop_client.agent_creator import classify_creation_error
from imbue.minds.desktop_client.agent_creator import clone_git_repo
from imbue.minds.desktop_client.agent_creator import extract_repo_name
from imbue.minds.desktop_client.agent_creator import probe_workspace_through_plugin
Expand Down Expand Up @@ -124,6 +128,70 @@ def test_is_local_path_recognises_relative_and_absolute_paths() -> None:
assert not _is_local_path("git@github.com:user/repo.git")


def test_is_github_https_url_matches_only_github_http_urls() -> None:
assert _is_github_https_url("https://github.com/acme/private-repo.git")
assert _is_github_https_url("http://www.github.com/acme/private-repo")
assert not _is_github_https_url("https://gitlab.example.com/acme/repo.git")
assert not _is_github_https_url("git@github.com:acme/repo.git")
assert not _is_github_https_url("ssh://git@github.com/acme/repo.git")
assert not _is_github_https_url("/local/path/to/repo")
# A github.com path segment on another host must not match.
assert not _is_github_https_url("https://evil.example.com/github.com/acme/repo")


def test_classify_creation_error_flags_prompt_disabled_github_clone_failure() -> None:
"""The no-usable-credentials shape: clone_git_repo disables terminal
prompting, so git fails with its stable "could not read Username" error."""
error = GitCloneError(
"git clone failed:\nfatal: could not read Username for 'https://github.com': terminal prompts disabled"
)
kind = classify_creation_error("https://github.com/acme/private-repo.git", error)
assert kind is CreationErrorKind.GIT_AUTH_REQUIRED


def test_classify_creation_error_flags_rejected_and_invisible_credentials() -> None:
"""A credential helper supplying rejected credentials ("Authentication
failed") and credentials that cannot see the repo (GitHub's "Repository
not found") both classify as GIT_AUTH_REQUIRED."""
url = "https://github.com/acme/private-repo.git"
rejected = GitCloneError(
"git clone failed:\nfatal: Authentication failed for 'https://github.com/acme/private-repo.git/'"
)
invisible = GitCloneError(
"git fetch failed:\nremote: Repository not found.\n"
"fatal: repository 'https://github.com/acme/private-repo.git/' not found"
)
assert classify_creation_error(url, rejected) is CreationErrorKind.GIT_AUTH_REQUIRED
assert classify_creation_error(url, invisible) is CreationErrorKind.GIT_AUTH_REQUIRED


def test_classify_creation_error_ignores_non_github_sources() -> None:
"""The guidance recommends the GitHub CLI, so an auth failure against any
other host (or a local path) must not classify."""
error = GitCloneError(
"git clone failed:\nfatal: Authentication failed for 'https://gitlab.example.com/acme/repo.git/'"
)
assert classify_creation_error("https://gitlab.example.com/acme/repo.git", error) is None
assert classify_creation_error("/local/path/to/repo", error) is None


def test_classify_creation_error_ignores_non_auth_clone_failures() -> None:
"""A github URL whose clone failed for a non-auth reason (e.g. DNS) stays
unclassified -- the private-repo guidance would be wrong."""
error = GitCloneError(
"git clone failed:\nfatal: unable to access 'https://github.com/acme/repo.git/': "
"Could not resolve host: github.com"
)
assert classify_creation_error("https://github.com/acme/repo.git", error) is None


def test_classify_creation_error_ignores_non_clone_errors() -> None:
"""Only clone failures classify; a downstream mngr failure that happens to
echo an auth-shaped string must not trigger the clone guidance."""
error = MngrCommandError("mngr create failed:\nfatal: could not read Username for 'https://github.com'")
assert classify_creation_error("https://github.com/acme/repo.git", error) is None


def test_redact_url_credentials_strips_userinfo_for_schemed_urls() -> None:
assert _redact_url_credentials("https://x-access-token:tok@github.com/user/repo") == "https://github.com/user/repo"
assert _redact_url_credentials("https://github.com/user/repo") == "https://github.com/user/repo"
Expand Down Expand Up @@ -805,6 +873,44 @@ def test_clone_git_repo_raises_on_missing_branch(tmp_path: Path) -> None:
clone_git_repo(GitUrl("file://{}".format(origin)), dest, branch=GitBranch("nonexistent"))


class _AlwaysUnauthorizedHandler(BaseHTTPRequestHandler):
"""Answers every request with 401 + a Basic challenge, like a private remote."""

def do_GET(self) -> None:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="test"')
self.end_headers()

def log_message(self, format: str, *args: object) -> None:
del format, args


@pytest.mark.timeout(30)
def test_clone_git_repo_fails_fast_with_auth_shaped_error_when_remote_requires_auth(tmp_path: Path) -> None:
"""A remote that answers with an auth challenge fails the clone quickly and
with an authentication-shaped error, instead of hanging on a credential
prompt: ``clone_git_repo`` runs git with terminal prompting disabled.

The exact message varies with the machine's credential setup (no helper:
"could not read Username ... terminal prompts disabled"; a helper that
supplies rejected credentials: "Authentication failed"), so the assertion
accepts any of the auth markers ``classify_creation_error`` recognizes.
"""
server = HTTPServer(("127.0.0.1", 0), _AlwaysUnauthorizedHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
url = GitUrl("http://127.0.0.1:{}/acme/private-repo.git".format(server.server_address[1]))
dest = tmp_path / "clone"
with pytest.raises(GitCloneError) as exc_info:
clone_git_repo(url, dest)
message = str(exc_info.value)
assert any(marker in message for marker in _GIT_AUTH_OUTPUT_MARKERS), message
finally:
server.shutdown()
thread.join(timeout=5)


def test_clone_then_checkout_branch_accepts_full_commit_sha(tmp_path: Path) -> None:
"""``clone_git_repo(branch=<40-hex sha>)`` works -- the previous
``git clone --branch <sha>`` rejected SHAs outright.
Expand Down
7 changes: 7 additions & 0 deletions apps/minds/imbue/minds/desktop_client/api_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ class CreateOperationStatusResponse(FrozenModel):
agent_id: str | None = Field(default=None, description="The created workspace agent id, once known")
redirect_url: str | None = Field(default=None, description="Absolute /goto/<agent>/ URL to navigate to when done")
error: str | None = Field(default=None, description="Failure message, when the creation failed")
error_kind: str | None = Field(
default=None,
description=(
"Machine-readable failure classification (e.g. GIT_AUTH_REQUIRED), set when the "
"failure is recognized; the creating page gates extra static guidance on it"
),
)


class DestroyOperationStatusResponse(FrozenModel):
Expand Down
4 changes: 4 additions & 0 deletions apps/minds/imbue/minds/desktop_client/api_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,10 @@ def _handle_create_operation_status(operation_id: str) -> CreateOperationStatusR
# redirects without reconstructing it client-side.
redirect_url=info.redirect_url,
error=info.error,
# Machine-readable failure classification (e.g. GIT_AUTH_REQUIRED for a
# private/nonexistent GitHub repo); the creating page reveals static
# guidance for kinds it knows about.
error_kind=str(info.error_kind) if info.error_kind is not None else None,
)


Expand Down
Loading