Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 any clone of a github.com URL fails (deliberately no matching of git's error text -- a failed GitHub clone is overwhelmingly an access problem, and the raw error stays visible alongside), 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.
86 changes: 86 additions & 0 deletions apps/minds/imbue/minds/desktop_client/agent_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,24 @@ 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 clone of a github.com workspace source failed. By far the most
# common cause: 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, so
# the creating page shows sign-in guidance alongside the raw error.
GIT_AUTH_REQUIRED = auto()


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

Expand Down Expand Up @@ -255,6 +273,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 @@ -284,6 +309,39 @@ 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")


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: ANY failed clone of a
``https://github.com/...`` workspace source. Deliberately no matching of
git's error text (git has no structured error output, and substring
matching is brittle across git versions and locales): a github.com clone
that failed at all is overwhelmingly an access problem -- the repo is
private (or does not exist) and this machine's git credentials cannot see
it -- and the creating page's guidance covers that case while the raw git
error stays visible right above it for anything rarer. Every other
failure returns ``None``.
"""
if not isinstance(error, GitCloneError):
return None
if not _is_github_https_url(repo_source):
return None
return CreationErrorKind.GIT_AUTH_REQUIRED
Comment thread
pseay-imbue marked this conversation as resolved.
Outdated


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

Expand Down Expand Up @@ -347,6 +405,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 instead of hanging. 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 default
workspace template'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 @@ -405,6 +483,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 @@ -431,6 +511,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 @@ -1386,6 +1467,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 @@ -1529,6 +1611,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 @@ -1915,9 +1998,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
87 changes: 87 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,17 @@
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 _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 @@ -127,6 +130,52 @@ 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_any_failed_github_clone() -> None:
"""ANY failed clone of a github.com source classifies -- no matching of
git's error text (deliberately: substring matching is brittle, and a
failed github clone is overwhelmingly an access problem the guidance
covers, while the raw error stays visible alongside it)."""
url = "https://github.com/acme/private-repo.git"
failures = (
"git clone failed:\nfatal: could not read Username for 'https://github.com': terminal prompts disabled",
"git clone failed:\nfatal: Authentication failed for 'https://github.com/acme/private-repo.git/'",
"git fetch failed:\nremote: Repository not found.\n"
"fatal: repository 'https://github.com/acme/private-repo.git/' not found",
"git clone failed:\nfatal: unable to access 'https://github.com/acme/repo.git/': "
"Could not resolve host: github.com",
)
for message in failures:
assert classify_creation_error(url, GitCloneError(message)) is CreationErrorKind.GIT_AUTH_REQUIRED, message


def test_classify_creation_error_ignores_non_github_sources() -> None:
"""The guidance recommends the GitHub CLI, so a clone 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_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 @@ -853,6 +902,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 either shape.
"""
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 "terminal prompts disabled" in message or "Authentication failed" in message, 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 @@ -79,6 +79,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 @@ -1015,6 +1015,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
39 changes: 39 additions & 0 deletions apps/minds/imbue/minds/desktop_client/api_v1_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from imbue.minds.desktop_client.agent_creator import AgentCreationInfo
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.app import create_desktop_client
from imbue.minds.desktop_client.auth import FileAuthStore
from imbue.minds.desktop_client.backend_resolver import AgentDisplayInfo
Expand Down Expand Up @@ -516,6 +517,44 @@ def test_create_operation_status_includes_status_text(
assert body["kind"] == "create"
assert body["status_text"] == status_text_for(str(AgentCreationStatus.INITIALIZING), launch_mode=LaunchMode.DOCKER)
assert body["status_text"]
# An in-flight (non-failed) creation carries no failure classification.
assert body["error_kind"] is None


def test_create_operation_status_carries_error_kind_for_classified_failures(
tmp_path: Path,
root_concurrency_group: ConcurrencyGroup,
notification_dispatcher: NotificationDispatcher,
) -> None:
# A failed creation whose error was classified (e.g. a private GitHub repo
# the local git credentials cannot see) reports the machine-readable kind
# alongside the error message; the creating page gates its static sign-in
# guidance on it.
creation_id = CreationId()
creator = _StatusReportingAgentCreator(
paths=WorkspacePaths(data_dir=tmp_path / "minds"),
root_concurrency_group=root_concurrency_group,
notification_dispatcher=notification_dispatcher,
system_interface_health_tracker=SystemInterfaceHealthTracker(),
fixed_info=AgentCreationInfo(
creation_id=creation_id,
status=AgentCreationStatus.FAILED,
launch_mode=LaunchMode.DOCKER,
error="git clone failed:\nfatal: could not read Username for 'https://github.com'",
error_kind=CreationErrorKind.GIT_AUTH_REQUIRED,
),
)
client = _client_with_agent_creator(
tmp_path, root_concurrency_group, notification_dispatcher, agent_creator=creator
)

response = client.get(f"/api/v1/workspaces/operations/create/{creation_id}", headers=_auth_header())

assert response.status_code == 200
body = json.loads(response.data)
assert body["status"] == "FAILED"
assert body["error"]
assert body["error_kind"] == "GIT_AUTH_REQUIRED"


def test_create_workspace_full_surface_returns_202_and_threads_fields(
Expand Down
Loading