fix(github-app): PAT fallback covers every mint failure; video/motion/x cleanups

mint_installation_token now wraps JWT build + HTTP + parsing so raw
httpx/jwt failures surface as GitHubAppError and the existing PAT
fallback catches them all (a GitHub outage no longer crashes git
operations for App-bound projects). The PEM is validated at
credential-set time instead of first mint, and the installation-token
cache is cleared when credentials are deleted. Also: the video
preview root resolves symlinks like its sibling route (frames under a
symlinked workspaces_root no longer false-404), the tracked dangling
motion/node_modules symlink is removed and the gitignore gains a
slash-less entry that actually matches symlinks, changelog caption
input strips GHSA refs like PR refs, and the edit-project dialog hides
the GitHub App section for auto-detected gitlab.com projects and warns
before a save that would clear both auth sources.
This commit is contained in:
Renn F
2026-07-22 03:31:12 +02:00
parent 0296ec6fde
commit 5ca8a9c4a6
14 changed files with 369 additions and 38 deletions
@@ -204,6 +204,38 @@ describe("EditProjectDialog — GitHub App binding", () => {
).not.toBeInTheDocument();
});
it("hides the picker for an auto-detected gitlab.com URL (git_provider stored as null)", async () => {
renderDialog(
makeProject({
git_provider: null,
git_url: "https://gitlab.com/acme/widgets.git",
}),
);
expect(
await screen.findByText(/App auth is GitHub-only/i),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Select repo/i }),
).not.toBeInTheDocument();
});
it("warns when saving would clear both the App binding and the PAT", async () => {
renderDialog(
makeProject({ github_installation_id: null, has_git_token: true }),
);
expect(
screen.queryByText(/no git credentials at all/i),
).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("switch", { name: /clear token/i }));
expect(
await screen.findByText(/no git credentials at all/i),
).toBeInTheDocument();
});
it("binding via the repo picker sets github_installation_id in the submitted payload", async () => {
renderDialog(makeProject());
@@ -25,7 +25,7 @@ import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConventionsTab } from "@/components/conventions/conventions-tab";
import { Key, KeyRound } from "lucide-react";
import { Key, KeyRound, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types";
import { githubAppApi } from "@/lib/api";
@@ -103,6 +103,28 @@ const SANDBOX_EXTENSIONS: Record<
],
};
// Auto-detect from the git URL host, mirroring
// roboco/foundation/policy/forge.py's detect_provider: only gitlab.com is
// auto-detected as non-GitHub for App-binding purposes — github.com and any
// unrecognized/self-hosted host stay github-ish (a self-hosted forge must
// set the Forge select explicitly to change this).
function isAutoDetectedGitlab(gitUrl: string): boolean {
const url = gitUrl.trim();
let host: string | null = null;
if (url.includes("://")) {
try {
host = new URL(url).hostname.toLowerCase() || null;
} catch {
host = null;
}
} else {
// scp-like SSH syntax: [user@]host:path
const match = /^(?:[^@/]+@)?([^/:]+):/.exec(url);
host = match ? match[1].toLowerCase() : null;
}
return host === "gitlab.com";
}
const SANDBOX_SERVICE_HINTS: Record<string, string> = {
postgres:
"Ephemeral PostgreSQL container for this project's agent spawns — random creds, tmpfs storage, torn down at end of engagement.",
@@ -218,8 +240,20 @@ function EditProjectForm({
const appConfigured = !!credStatus?.has_credentials;
// App auth is GitHub-only; a self-hosted Gitea/GitLab project keeps using
// its own token below regardless of any installation id already stored.
// An "auto" provider still needs a host check — an auto-detected
// gitlab.com project stores git_provider=None, so the explicit-string
// check alone would wrongly show the App section for it.
const isNonGithubProvider =
gitProvider === "gitea" || gitProvider === "gitlab";
gitProvider === "gitea" ||
gitProvider === "gitlab" ||
(gitProvider === "auto" && isAutoDetectedGitlab(gitUrl));
// True when saving now would leave this project with no way to
// authenticate git operations at all: no App binding AND no PAT (either
// explicitly cleared, or never set and no replacement entered).
const willHaveNoToken =
clearToken || (!project.has_git_token && !newToken.trim());
const bothAuthSourcesEmpty = githubInstallationId === null && willHaveNoToken;
const handleRepoSelected = (repo: SelectedRepo) => {
setGitUrl(repo.git_url);
@@ -487,6 +521,17 @@ function EditProjectForm({
)}
</div>
{bothAuthSourcesEmpty && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/50 bg-amber-50 dark:bg-amber-950/20 p-3 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Saving now leaves this project with no git credentials at
all no GitHub App binding and no personal access token.
Clone, push, and PR operations will fail until one is set.
</span>
</div>
)}
{/* Assigned Cell */}
<div className="grid gap-2">
<HelpTip label="Which cell owns this project — only that cell's agents can claim its tasks (enforced server-side, not just a UI filter).">