diff --git a/apps/minds/changelog/preston-minds-inspirations.md b/apps/minds/changelog/preston-minds-inspirations.md new file mode 100644 index 0000000000..d986656037 --- /dev/null +++ b/apps/minds/changelog/preston-minds-inspirations.md @@ -0,0 +1,7 @@ +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 default-workspace-template bootstrap's git calls). + +Non-GitHub git remotes get the same treatment without the GitHub-specific advice: a failed clone of a git URL on another host (or an ssh remote) is classified `GIT_AUTH_REQUIRED`, and the creating page shows the same "looks private / make sure your git credentials are set up, or clone it locally and use a path" guidance, minus the `gh auth login` recommendation (which only fits github.com). A local path or unrecognized source still shows just the raw error. + +The create-operation status API (`GET /api/v1/workspaces/operations/create/`) gained an optional `error_kind` field carrying a machine-readable failure classification (`GITHUB_AUTH_REQUIRED` or `GIT_AUTH_REQUIRED`) alongside the human-readable `error` message. diff --git a/apps/minds/imbue/minds/desktop_client/agent_creator.py b/apps/minds/imbue/minds/desktop_client/agent_creator.py index abc93331e4..63ef1324dc 100644 --- a/apps/minds/imbue/minds/desktop_client/agent_creator.py +++ b/apps/minds/imbue/minds/desktop_client/agent_creator.py @@ -220,6 +220,32 @@ 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 GitHub sign-in guidance alongside the raw error. + # The clone mechanism is git, but the problem we surface is GitHub access. + GITHUB_AUTH_REQUIRED = auto() + + # The clone of a NON-github remote git source (a URL on another host, or an + # ssh remote) failed -- same likely cause (private/nonexistent, no usable + # credentials on this machine) and same guidance, minus the GitHub-CLI + # advice, which only fits github.com. The creating page shows generic + # git-credentials guidance for this kind. + GIT_AUTH_REQUIRED = auto() + + class AgentCreationInfo(FrozenModel): """Snapshot of agent creation state, returned to callers for status polling. @@ -255,6 +281,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: @@ -284,6 +317,63 @@ 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 _is_remote_git_source(repo_source: str) -> bool: + """Check if a repo source is a REMOTE git source (a URL or ssh remote). + + True for any ``scheme://`` URL (https/http/ssh/git) and for scp-style ssh + remotes (``user@host:path``). False for local paths and for bare strings + that are neither -- so a clone failure on a local path (not an access + problem) or on garbage input does not get the "you need access" guidance. + """ + if "://" in repo_source: + return True + # scp-style ssh remote, e.g. git@gitlab.example.com:group/repo.git. The + # host part (before the first ':') must contain no '/', which distinguishes + # it from a local path like ``./a:b``. + return bool(re.match(r"^[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+:", repo_source)) + + +def classify_creation_error(repo_source: str, error: Exception) -> CreationErrorKind | None: + """Classify a creation failure into a ``CreationErrorKind``, when recognizable. + + Recognizes two cases, both for a failed clone (``GitCloneError``) of a + REMOTE git source -- the likely cause is the same (private/nonexistent, + no usable credentials on this machine). 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 remote clone that failed at + all is overwhelmingly an access problem, and the creating page's guidance + covers it while the raw git error stays visible right above for anything + rarer. + + - ``https://github.com/...`` -> ``GITHUB_AUTH_REQUIRED`` (guidance names + the GitHub CLI, which only fits github.com https). + - any other remote git source (a URL on another host, or an ssh remote) + -> ``GIT_AUTH_REQUIRED`` (generic git-credentials guidance, no GitHub CLI). + + A local path or unrecognized input returns ``None`` (just the raw error). + """ + if not isinstance(error, GitCloneError): + return None + if _is_github_https_url(repo_source): + return CreationErrorKind.GITHUB_AUTH_REQUIRED + if _is_remote_git_source(repo_source): + 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. @@ -347,6 +437,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, @@ -405,6 +515,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 @@ -431,6 +543,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() @@ -1386,6 +1499,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) @@ -1529,6 +1643,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: @@ -1915,9 +2030,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) diff --git a/apps/minds/imbue/minds/desktop_client/agent_creator_test.py b/apps/minds/imbue/minds/desktop_client/agent_creator_test.py index 75815a9a68..34099d5668 100644 --- a/apps/minds/imbue/minds/desktop_client/agent_creator_test.py +++ b/apps/minds/imbue/minds/desktop_client/agent_creator_test.py @@ -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 @@ -127,6 +130,72 @@ 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.GITHUB_AUTH_REQUIRED, message + + +def test_classify_creation_error_flags_non_github_remotes_generically() -> None: + """A failed clone of a non-github REMOTE git source classifies as the + generic GIT_AUTH_REQUIRED (same access guidance, without the GitHub-CLI + advice) -- covering another host over https and scp-style ssh remotes.""" + 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 CreationErrorKind.GIT_AUTH_REQUIRED + ) + assert ( + classify_creation_error("git@gitlab.example.com:acme/repo.git", error) is CreationErrorKind.GIT_AUTH_REQUIRED + ) + assert ( + classify_creation_error("ssh://git@gitlab.example.com/acme/repo.git", error) + is CreationErrorKind.GIT_AUTH_REQUIRED + ) + + +def test_classify_creation_error_ignores_local_paths_and_bare_input() -> None: + """A clone failure on a local path is not an access problem, and a bare + non-remote string is not a recognizable remote -- neither classifies.""" + error = GitCloneError("git clone failed:\nfatal: repository not found") + assert classify_creation_error("/local/path/to/repo", error) is None + assert classify_creation_error("./relative/repo", error) is None + assert classify_creation_error("~/repo", error) is None + assert classify_creation_error("just-a-name", 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" @@ -853,6 +922,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 `` rejected SHAs outright. diff --git a/apps/minds/imbue/minds/desktop_client/api_models.py b/apps/minds/imbue/minds/desktop_client/api_models.py index 773bd67f0d..84bcb8960b 100644 --- a/apps/minds/imbue/minds/desktop_client/api_models.py +++ b/apps/minds/imbue/minds/desktop_client/api_models.py @@ -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// 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. GITHUB_AUTH_REQUIRED), set when the " + "failure is recognized; the creating page gates extra static guidance on it" + ), + ) class DestroyOperationStatusResponse(FrozenModel): diff --git a/apps/minds/imbue/minds/desktop_client/api_v1.py b/apps/minds/imbue/minds/desktop_client/api_v1.py index e87061d3fa..e65a7aca3c 100644 --- a/apps/minds/imbue/minds/desktop_client/api_v1.py +++ b/apps/minds/imbue/minds/desktop_client/api_v1.py @@ -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. GITHUB_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, ) diff --git a/apps/minds/imbue/minds/desktop_client/api_v1_test.py b/apps/minds/imbue/minds/desktop_client/api_v1_test.py index 1b944baaaf..881e25db33 100644 --- a/apps/minds/imbue/minds/desktop_client/api_v1_test.py +++ b/apps/minds/imbue/minds/desktop_client/api_v1_test.py @@ -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 @@ -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.GITHUB_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"] == "GITHUB_AUTH_REQUIRED" def test_create_workspace_full_surface_returns_202_and_threads_fields( diff --git a/apps/minds/imbue/minds/desktop_client/static/creating.js b/apps/minds/imbue/minds/desktop_client/static/creating.js index 0c7fff2f12..d95c543c82 100644 --- a/apps/minds/imbue/minds/desktop_client/static/creating.js +++ b/apps/minds/imbue/minds/desktop_client/static/creating.js @@ -15,6 +15,7 @@ var creationFailed = false; var redirectUrl = null; var creationError = ''; + var creationErrorKind = ''; // ---- Loading screen: progress bar + rotating hints ---- var TIPS = [ @@ -68,6 +69,17 @@ if (failureView) failureView.classList.remove('hidden'); var msgEl = document.getElementById('error-message'); if (msgEl) msgEl.textContent = creationError || 'unknown error'; + // Reveal extra static guidance for recognized failure kinds (a private + // repo on github.com, or on another git host). The copy lives hidden in + // the template; the backend only classifies. + var authHelpId = + creationErrorKind === 'GITHUB_AUTH_REQUIRED' ? 'github-auth-help' + : creationErrorKind === 'GIT_AUTH_REQUIRED' ? 'git-auth-help' + : null; + if (authHelpId) { + var authHelp = document.getElementById(authHelpId); + if (authHelp) authHelp.classList.remove('hidden'); + } // The prominent error box now carries the message, so clear the faint // footer caption to avoid showing it twice. var stage = document.getElementById('stage'); @@ -116,8 +128,8 @@ // the SSE 'done' event can be missed on a page reload (the log queue may // already be drained), so we poll the operation status. SSE is used only for // the live log stream. The create operation reports - // {status, is_done, redirect_url, error}; redirect_url is the absolute - // /goto// URL the server builds once the workspace is ready. + // {status, is_done, redirect_url, error, error_kind}; redirect_url is the + // absolute /goto// URL the server builds once the workspace is ready. var statusPoll = null; function applyStatus(data) { if (!data) return; @@ -128,6 +140,7 @@ } else if (data.status === 'FAILED') { creationFailed = true; creationError = data.error || 'unknown error'; + creationErrorKind = data.error_kind || ''; showFailure(); if (statusPoll) { clearInterval(statusPoll); statusPoll = null; } } else if (data.status_text && !creationFailed) { diff --git a/apps/minds/imbue/minds/desktop_client/templates/pages/Creating.jinja b/apps/minds/imbue/minds/desktop_client/templates/pages/Creating.jinja index 5867ad461e..90977700bc 100644 --- a/apps/minds/imbue/minds/desktop_client/templates/pages/Creating.jinja +++ b/apps/minds/imbue/minds/desktop_client/templates/pages/Creating.jinja @@ -42,6 +42,45 @@ + {# Private-repo guidance: revealed by creating.js only when the backend #} + {# classifies the failure as GITHUB_AUTH_REQUIRED (the workspace source #} + {# is a github.com URL none of this computer's git credentials can see). #} + {# The copy is static template content gated on the error kind; the #} + {# backend only classifies, it never generates prose. #} + + {# Generic (non-GitHub) git remote: same guidance as above but without #} + {# the GitHub-CLI advice, which only fits github.com. Revealed by #} + {# creating.js when the backend classifies the failure as GIT_AUTH_REQUIRED. #} +
Back to setup Home diff --git a/apps/minds/imbue/minds/desktop_client/templates_test.py b/apps/minds/imbue/minds/desktop_client/templates_test.py index 0cdbeace85..f167a35a60 100644 --- a/apps/minds/imbue/minds/desktop_client/templates_test.py +++ b/apps/minds/imbue/minds/desktop_client/templates_test.py @@ -7,6 +7,8 @@ from imbue.imbue_common.ids import InvalidRandomIdError from imbue.minds.desktop_client import templates as _templates_module +from imbue.minds.desktop_client.agent_creator import AgentCreationInfo +from imbue.minds.desktop_client.agent_creator import AgentCreationStatus from imbue.minds.desktop_client.templates import CATALOG from imbue.minds.desktop_client.templates import DEFAULT_EXPECTED_CREATION_DURATION_SECONDS from imbue.minds.desktop_client.templates import expected_creation_duration_seconds @@ -14,6 +16,7 @@ from imbue.minds.desktop_client.templates import render_auth_error_page from imbue.minds.desktop_client.templates import render_chrome_page from imbue.minds.desktop_client.templates import render_create_form +from imbue.minds.desktop_client.templates import render_creating_page from imbue.minds.desktop_client.templates import render_dev_styleguide_page from imbue.minds.desktop_client.templates import render_landing_page from imbue.minds.desktop_client.templates import render_login_page @@ -29,6 +32,7 @@ from imbue.minds.desktop_client.workspace_color import normalize_workspace_color from imbue.minds.desktop_client.workspace_color import pick_unused_create_color from imbue.minds.primitives import AIProvider +from imbue.minds.primitives import CreationId from imbue.minds.primitives import DockerRuntime from imbue.minds.primitives import LaunchMode from imbue.minds.primitives import OneTimeCode @@ -453,6 +457,51 @@ def test_render_create_form_shows_error_message_when_supplied() -> None: assert "Imbue cloud requires an account." in html +def test_render_creating_page_carries_hidden_github_auth_guidance() -> None: + """The creating page ships the private-repo guidance as static, hidden + content: creating.js reveals it only when the create-operation status + reports error_kind GITHUB_AUTH_REQUIRED. It must name the GitHub CLI sign-in + command, link the official docs, and offer the local-path alternative.""" + creation_id = CreationId() + info = AgentCreationInfo( + creation_id=creation_id, + status=AgentCreationStatus.INITIALIZING, + launch_mode=LaunchMode.DOCKER, + ) + html = render_creating_page(creation_id=creation_id, info=info) + assert 'id="github-auth-help"' in html + assert "gh auth login" in html + assert "https://docs.github.com/en/github-cli/github-cli/quickstart" in html + assert "path in the form instead of the URL" in html + # Hidden on first paint -- the block only shows for the classified failure. + guidance_index = html.index('id="github-auth-help"') + tag_end = html.index(">", guidance_index) + assert "hidden" in html[guidance_index:tag_end] + + +def test_render_creating_page_carries_hidden_generic_git_auth_guidance() -> None: + """The creating page also ships generic (non-GitHub) git-auth guidance, + revealed for error_kind GIT_AUTH_REQUIRED. It offers the local-path + alternative but must NOT name the GitHub CLI (which only fits github.com).""" + creation_id = CreationId() + info = AgentCreationInfo( + creation_id=creation_id, + status=AgentCreationStatus.INITIALIZING, + launch_mode=LaunchMode.DOCKER, + ) + html = render_creating_page(creation_id=creation_id, info=info) + assert 'id="git-auth-help"' in html + assert "path in the form instead of the URL" in html + # Hidden on first paint. + guidance_index = html.index('id="git-auth-help"') + tag_end = html.index(">", guidance_index) + assert "hidden" in html[guidance_index:tag_end] + # The generic block must not carry the GitHub-CLI advice. Scope the check + # to this block (the sibling github-auth-help block legitimately has it). + block_end = html.index("
", guidance_index) + assert "gh auth login" not in html[guidance_index:block_end] + + def test_render_create_form_honors_workspace_env_vars_when_opted_in(monkeypatch: pytest.MonkeyPatch) -> None: """With the explicit opt-in, the MINDS_WORKSPACE_* env vars pre-fill the form. diff --git a/blueprint/minds-inspirations/plan-minds-inspirations.md b/blueprint/minds-inspirations/plan-minds-inspirations.md index 77671ed87c..cce916b9ad 100644 --- a/blueprint/minds-inspirations/plan-minds-inspirations.md +++ b/blueprint/minds-inspirations/plan-minds-inspirations.md @@ -193,6 +193,10 @@ The interim design authenticated the git push with the mind's `GH_TOKEN` on the - **`/use-inspiration`** fetches private inspiration repos the same way (`github-git-read`), fetching the gateway URL directly instead of persisting a gateway-URL remote; the FCT `latchkey` skill documents the general git-over-gateway pattern. - **Adversarial-review fixes (same day)**: the `github-rest-api` request asks for `github-read-user` + `github-write-all` (the narrower `github-write-repos` schema does not cover `POST /user/repos`, so the previously-requested grant would 403 the flow's own repo-creation call even after user approval); the API probes pass `-f` (`latchkey curl` exits with curl's code, and the gateway's 403 denial is otherwise exit 0, making the probe vacuous); the push-permission probe handles `"any"` catch-all grants. Known caveat: already-running gateways keep the old 10 MiB body cap until they restart (the desktop gateway restarts with the minds app; a VPS gateway persists until its process exits). +### Design revision: inspiration-describing README (2026-07-14) + +The published snapshot shipped the base's `README.md`, which describes the generic default-workspace-template -- wrong for a repo that is a specific inspiration. `build_inspiration.sh` now overwrites `README.md` (step 8.5, mirroring the manifest/welcome generation) with the inspiration's title, description, a worker-completed overview FILL-IN (gated like the manifest's FILL-INs, in both the worker self-check and the lead pre-push grep), a how-to-use section, and an accumulation list of every `inspiration-*.md` manifest in the repo (titled from each manifest's front-matter, marking the newly-published one). Verified on synthetic repos: the README is retitled to the inspiration and lists single and multiple accumulated inspirations correctly. + ### Design revision: single-commit publish (2026-07-08) The megabox publish exposed a second history leak: the push sent the worker's branch, whose intermediate commits include the pre-modification state — the personal email that a published-version modification replaced with `me@example.com` was still present in the assembly commit, retrievable by anyone with the repo. §8 now mints a fresh commit from the worker worktree's FINAL tree via `git commit-tree` (parented directly on `BASE_REF`) and pushes that commit's SHA to `refs/heads/main`; the branch is never pushed. The published history is therefore exactly: template history + one snapshot commit. The worker branch keeps its granular local history (assembly → fill-ins → modifications → §6 edits) for debugging, and nothing needs rewriting — no amend, no rebase, no reset. Verified on a synthetic repo: `git log -S` for the removed value finds nothing reachable in the published clone, while the final tree carries the cleaned file. diff --git a/dev/changelog/preston-minds-inspirations.md b/dev/changelog/preston-minds-inspirations.md new file mode 100644 index 0000000000..0a051b8c13 --- /dev/null +++ b/dev/changelog/preston-minds-inspirations.md @@ -0,0 +1,14 @@ +Added and maintained the planning documents for the minds "inspirations" +feature under `blueprint/minds-inspirations/`: the implementation plan and a +concise feature prompt. Inspirations let a running mind publish a clean, +bootable snapshot of the apps it built to a new GitHub repo, and let another +mind adapt one into itself. The plan records the full design evolution from +live testing -- assembly delegated to a launch-task worker on an isolated +worktree with a strict no-merge-back invariant, an inline-chat scope gate and +post-assembly confirmation, latchkey GitHub permissioning end-to-end (REST API +plus a git push through the latchkey gateway), a single-commit publish that +leaks no intermediate state, deterministic base resolution, published-version +modifications, a bespoke-thumbnail gate, a two-scanner (betterleaks + +kingfisher) secret gate, and an inspiration-describing README. The +implementation itself lives in the default-workspace-template repo on the +companion branch of the same name.