Files
PaperClipAI/ui/src/pages/apps/AppDetail.tsx
T
3edba11576 fix(github): stop counting suspended installations as repository access (#17343)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents get GitHub access through a managed GitHub App connection.
**Refresh access** reads the installations and repositories that the
user token can reach.
> - GitHub keeps listing a suspended App installation and all of its
repositories for the user token. But the token then loses write access
and cannot read private repositories.
> - `loadGitHubGrantMetadata` did not read `suspended_at`. It counted a
suspended installation as live access ("all repos") and set the
connection to healthy.
> - So the board showed "connected, all repositories" while agents got a
403 on `git push` and a 404 on private repositories. The suspend webhook
marks the connection unhealthy, but the next refresh or reconnect
reverted that.
> - This pull request skips suspended installations. When all
installations are suspended, Refresh access fails with a clear error and
a link to the installation settings page, where an owner can click
**Unsuspend**.
> - The benefit is that the board shows the real access state, and
operators get a direct path to fix it.

## Linked Issues or Issue Description

**What happened?**
A GitHub App installation on an organization was suspended. The
Paperclip GitHub connection still showed "connected" with access to all
repositories after **Refresh access**. Agents that used the managed
token got `Permission to <org>/<repo>.git denied` (403) on push,
`Resource not accessible by integration` on branch creation, and 404 on
private repositories.

**Expected behavior**
Refresh access must not count a suspended installation as repository
access. When every installation is suspended, Refresh access must fail
with a message that says the installation is suspended, and the page
must link to the installation settings page.

**Steps to reproduce**
1. Connect GitHub to Paperclip through the GitHub App, with an
installation on an organization.
2. On GitHub, suspend that installation (Organization settings → GitHub
Apps → Configure → Suspend).
3. In Paperclip, click **Refresh access** on the GitHub connection.
4. See that the connection stays healthy and reports all repositories,
while agent pushes fail with 403.

**Paperclip version or commit**
`master` at a708a1333.

**Additional context**
`GET /user/installations` returns the suspended installation with
`suspended_at` set and `repository_selection: all`, and `GET
/user/installations/{id}/repositories` still lists its repositories.
Unsuspending the installation restored push access and private reads for
the same token.

## What Changed

- `server/src/services/tool-access/github-access-discovery.ts`:
`loadGitHubGrantMetadata` skips installations that have `suspended_at`
set. It does not add their IDs, owners, repository selection, or
repositories. When there is no usable installation and at least one is
suspended, it throws `github_installation_required` with a "suspended"
message. `managementUrl` points at the suspended installation's settings
page (or the generic installations page when more than one is
suspended).
- `server/src/services/tool-access/catalog/health.ts`:
`checkConnectionHealth` (the Refresh access path) keeps
`installationUrl` and `managementUrl` when it rebuilds a
`github_installation_required` error. Before, the links were dropped.
- `server/src/services/tool-access/catalog/failures.ts`:
`github_installation_required` keeps its own code and returns 422.
Before, it was folded into a generic `paperclip_error` with HTTP 502.
- `ui/src/pages/apps/app-detail/AccessRetryBanner.tsx` (new): the
attention banner with the saved health message and **Retry access**.
After a failed Refresh access with `github_installation_required`, it
shows an **Open installation settings on GitHub** link (https
`github.com` URLs only).
- `ui/src/pages/apps/AppDetail.tsx`: uses `AccessRetryBanner`, refetches
the connection when a refresh fails so the banner shows the saved
message, and keys the page on `connectionId` so a recovery link never
carries over to another connection. The file stays under 1,000 lines.
- Tests:
- `server/src/__tests__/github-grant-metadata.test.ts`: a mix of
suspended and active installations, and an account where all
installations are suspended.
- `server/src/__tests__/github-health-recovery.test.ts`: drives
`checkConnectionHealth` with a suspended-installation GitHub response
and checks the 422, code, and recovery links.
- `ui/src/pages/apps/AppDetail.test.tsx`: a failed Retry access renders
the installation settings link, and navigating to another cached
connection does not show it.

## Verification

- Red/green: with the source changes reverted and the tests kept, the
new tests fail (suspended installation counted as `all`; recovery link
missing from the health-check error; link not rendered; link leaking to
another connection). With the changes, they pass.
- `server`: `github-grant-metadata`, `github-health-recovery`,
`tool-access-service`, `github-operation-credentials`,
`tool-access-oauth-protocol.integration`, `tool-connection-removal`,
`tool-connection-visibility`, and the other `github-*` / `tool-access-*`
suites pass locally (630 tests).
- `ui`: `AppDetail.test.tsx` passes (78 tests). `ui` `tsc --noEmit`
shows no errors in changed files.
- Not run locally: the server `tsc`. The sandbox has a 4 GB memory
limit, and the server typecheck is killed there even on clean `master`.
CI ran the full typecheck and build, and both pass.
- CI: all gates green. One unrelated `workspace-recovery.test.ts` poll
timeout in a server shard passed on re-run.
- Live check: after the suspended installation was unsuspended, a
managed-token `git push --dry-run` succeeded and a private repository
returned 200.

## Risks

- Low risk. The change only affects installations that GitHub marks as
suspended. Active installations behave as before.
- A connection that has only suspended installations now fails Refresh
access with `github_installation_required` (422) and does not report
fake access. This is the intended behavior.
- The recovery link only renders for https `github.com` URLs.
- Keying the app page on `connectionId` remounts it when moving between
connections, which resets per-page state (for example an open rename
field). That reset is intended.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through the
Claude Code agent SDK with tool use (shell, file edit, code execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:16:40 -07:00

1000 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AiConnectionPoolConnector } from "@/components/ai-connections/AiConnectionPoolConnector";
import { aiConnectionRouterPluginKey } from "@paperclipai/shared";
import { ConnectionInstructionsSettings } from "@/features/connections/ConnectionInstructions";
import { HonchoWorkspaceSettings } from "@/features/connections/HonchoWorkspaceSettings";
import { BrowserUseSettingsPanel } from "./app-detail/BrowserUseSettingsPanel";
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE, isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared";
import { RemoteMcpManagement } from "@/features/connections/remote-mcp/RemoteMcpManagement";
import { remoteMcpProviders } from "@/features/connections/remote-mcp/providers";
import { ManagedAiConnectionDetails } from "@/components/ai-connections/ManagedAiConnectionDetails";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { EmailConnectionAccess } from "@/components/EmailConnectionAccess";
import { EmailConnectionInboxes } from "./chat/EmailEndpointSetup";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Pencil } from "lucide-react";
import type {
ToolApplication,
ToolConnection,
ToolPolicy,
ToolProfileWithDetails,
} from "@paperclipai/shared";
import {
connectionDisplaySecondaryHint,
humanizeConnectionDisplayName,
aiSubscriptionNeedsIsolatedLogin,
isToolConnectionAttentionHealth as isAttentionHealthStatus,
} from "@paperclipai/shared";
import { Navigate, useParams, useNavigate, useSearchParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { consumeSkillSourceReturn } from "@/lib/skill-source-connect-return";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { accessApi } from "@/api/access";
import { buildCompanyUserProfileMap } from "@/lib/company-members";
import { installStateFrom, type InstallState } from "@/lib/tool-installs";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { AppLogo } from "./AppLogo";
import { UnverifiedServerBadge } from "./UnverifiedServerBadge";
import {
appApplicationSourceSlug,
appConnectionSourceSlug,
appDefinitionDarkLogoUrl,
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
type AppGalleryDisplayEntry,
} from "./app-definition-display";
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
import { ConnectionProvenanceChip } from "./ConnectionProvenanceChip";
import { IdentitiesSection } from "./app-detail/IdentitiesSection";
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
import { AgentConnectionAccess } from "./app-detail/AgentConnectionAccess";
import { ConnectedAggregatorApps } from "./app-detail/ConnectedAggregatorApps";
import { isAppAggregator } from "@paperclipai/shared/aggregator-apps";
import { actionPermissionMutation } from "./app-detail/action-permissions";
import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel";
import { ReviewPanel } from "./app-detail/ReviewPanel";
import { AccessRetryBanner } from "./app-detail/AccessRetryBanner";
import {
ReconnectCard,
DangerZone,
connectionAddress,
connectionTransportLabel,
} from "./app-detail/AdvancedPanel";
import type { AccessDraft } from "./app-detail/types";
import {
connectionDisplayNameForOwner,
connectionOwnerProfile,
} from "./connection-owner";
export { connectionAddress, connectionTransportLabel };
export function AppDetail(props: { renderActions?: (connection: ToolConnection) => ReactNode; renderAgentSettings?: (connection: ToolConnection) => ReactNode; renderConnectionSettings?: (connection: ToolConnection) => ReactNode; onReconnect?: (connection: ToolConnection) => void } = {}) {
const { connectionId = "" } = useParams<{ connectionId: string }>();
const connection = useQuery({ queryKey: queryKeys.tools.connection(connectionId), queryFn: () => toolsApi.getConnection(connectionId), enabled: !!connectionId });
if (connection.isPending) return <p role="status">Loading connection…</p>;
if (connection.error) return <p role="alert">{connection.error.message}</p>;
const pluginKey = connection.data && aiConnectionRouterPluginKey(connection.data);
// Keyed so page state, such as a failed refresh's recovery link, never carries over to another connection.
return pluginKey ? <AiConnectionPoolConnector pluginKey={pluginKey} connection={connection.data} /> : <StandardAppDetail key={connectionId} {...props} />;
}
function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectionSettings, onReconnect }: {
renderActions?: (connection: ToolConnection) => ReactNode;
/** Optional agent settings within Permissions, following the access controls. */
renderAgentSettings?: (connection: ToolConnection) => ReactNode;
/** Provider prerequisites shown before identity and agent access. */
renderConnectionSettings?: (connection: ToolConnection) => ReactNode;
onReconnect?: (connection: ToolConnection) => void;
} = {}) {
const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
const needsCatalog = activeTab === "review" || activeTab === "permissions";
const connectionQuery = useQuery({
queryKey: queryKeys.tools.connection(connectionId),
queryFn: () => toolsApi.getConnection(connectionId),
enabled: !!connectionId && !!activeTab,
});
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const installsQuery = useQuery({
queryKey: queryKeys.tools.connectionInstalls(connectionId),
queryFn: () => toolsApi.getConnectionInstalls(connectionId),
enabled: !!connectionId && activeTab === "permissions",
});
const galleryQuery = useQuery({
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const catalogQuery = useQuery({
queryKey: queryKeys.tools.catalog(connectionId),
queryFn: () => toolsApi.listCatalog(connectionId),
enabled: !!connectionId && needsCatalog,
});
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
});
const policiesQuery = useQuery({
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listPolicies(selectedCompanyId!),
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && activeTab === "permissions",
});
const userDirectoryQuery = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
// Identity grants drive reconnect authorization on every tab as well as the
// Permissions controls. A personal reconnect belongs to one fixed user, so
// the banner must not offer that action to anyone else.
const grantsQuery = useQuery({
queryKey: queryKeys.tools.connectionGrants(connectionId),
queryFn: () => toolsApi.listConnectionGrants(connectionId),
enabled: !!connectionId && !!activeTab,
});
const connection = connectionQuery.data;
const application = connection
? (applicationsQuery.data?.applications ?? []).find((candidate) => candidate.id === connection.applicationId)
: undefined;
const grantRows = grantsQuery.data?.grants ?? [];
const retainedPersonalGrant = connection?.credentialPolicy === "per_user"
? grantRows.find((grant) => (
grant.kind === "user" && grant.subjectUserId === connection.createdByUserId
))
?? grantRows.find((grant) => grant.kind === "user" && grant.status === "active")
?? grantRows.find((grant) => grant.kind === "user")
?? null
: null;
const currentUserPersonalGrant = grantRows.find((grant) => (
grant.kind === "user" && grant.subjectUserId === grantsQuery.data?.currentUserId
)) ?? null;
const retainedAgentGrant = connection?.credentialPolicy === "per_agent"
? grantRows.find((grant) => grant.kind === "agent" && grant.status === "active")
?? grantRows.find((grant) => grant.kind === "agent")
?? null
: null;
const retainedOrganizationGrant = grantRows.find((grant) => (
grant.kind === "organization" && grant.isDefault
)) ?? grantRows.find((grant) => grant.kind === "organization") ?? null;
const managedIdentityGrant = connection?.credentialPolicy === "per_user"
? currentUserPersonalGrant ?? retainedPersonalGrant
: connection?.credentialPolicy === "per_agent"
? retainedAgentGrant
: connection?.credentialPolicy === "per_user_with_fallback"
? currentUserPersonalGrant ?? retainedOrganizationGrant
: retainedOrganizationGrant;
const managedPersonalUserId = managedIdentityGrant?.kind === "user"
? managedIdentityGrant.subjectUserId ?? connection?.createdByUserId ?? null
: null;
const canReconnect = managedIdentityGrant?.kind === "user"
? Boolean(
managedPersonalUserId
&& managedPersonalUserId === grantsQuery.data?.currentUserId
&& grantsQuery.data?.capabilities.canConnectAsCurrentUser,
)
: grantsQuery.data?.capabilities.canConfigure === true;
const reconnectUnavailableMessage = grantsQuery.isLoading
? "Checking who can reconnect this identity…"
: grantsQuery.isError
? "We couldn't verify who can reconnect this identity. Reload the page to try again."
: managedIdentityGrant?.kind === "user"
&& managedPersonalUserId !== grantsQuery.data?.currentUserId
? "The person this connection belongs to must reconnect it."
: "You don't have permission to reconnect this identity.";
const logoEntry = useMemo(
() => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection, application),
[galleryQuery.data, connection, application],
);
const brandKey = appApplicationSourceSlug(application)
?? appConnectionSourceSlug(connection)
?? (logoEntry ? appDefinitionSlug(logoEntry) : null);
const userProfileById = useMemo(
() => buildCompanyUserProfileMap(userDirectoryQuery.data?.users),
[userDirectoryQuery.data],
);
const owner = connection ? connectionOwnerProfile(connection, userProfileById) : null;
const baseAppName = connection
? logoEntry ? appDefinitionName(logoEntry) : humanizeConnectionDisplayName(connection)
: "App";
const appName = connection
? connectionDisplayNameForOwner(connection, baseAppName, owner)
: "App";
const successNoticeShownFor = useRef<string | null>(null);
useEffect(() => {
if (
activeTab !== "permissions"
|| searchParams.get("success") !== "1"
|| !connection
|| successNoticeShownFor.current === connection.id
) return;
successNoticeShownFor.current = connection.id;
const skillSourcePath = selectedCompanyId === connection.companyId
&& connection.status === "active"
&& appConnectionSourceSlug(connection) === "github"
? consumeSkillSourceReturn(connection.companyId)
: null;
pushToast({
title: `${appName} connected`,
body: skillSourcePath ? "Choose a repository to import your skills." : "The connection is ready. Review permissions or test an action below.",
tone: "success",
});
navigate(skillSourcePath ?? appTabHref(connection.id, "permissions"), { replace: true });
}, [activeTab, appName, connection, navigate, pushToast, searchParams, selectedCompanyId]);
useEffect(() => {
if (!activeTab) return;
setBreadcrumbs([
{ label: "Connectors", href: "/apps" },
{ label: appName, href: appTabHref(connectionId, "permissions") },
{ label: appTabLabel(activeTab) },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, appName, connectionId, activeTab]);
const catalog = catalogQuery.data?.catalog ?? [];
const profile = useMemo(
() => (profilesQuery.data?.profiles ?? []).find((p) => p.profileKey === `app:${connectionId}`),
[profilesQuery.data, connectionId],
);
const enabledIds = useMemo(() => enabledCatalogIds(profile), [profile]);
const askFirstIds = useMemo(
() => askFirstCatalogIds(policiesQuery.data?.policies ?? [], connectionId),
[policiesQuery.data, connectionId],
);
const install = useMemo(
() => installStateFrom(installsQuery.data?.installs ?? connection?.installs),
[connection?.installs, installsQuery.data?.installs],
);
const access = useMemo(() => accessFrom(connection?.connectionPurpose === "ai" ? undefined : profile, install), [connection?.connectionPurpose, profile, install]);
const managesRemoteMcpAccess = isRemoteMcpConnectorMethod(connection?.config?.sourceTemplateKey, connection?.config?.connectionMethodKey);
const agents = agentsQuery.data ?? [];
const disconnectRemote = useMutation({
mutationFn: () => toolsApi.archiveConnection(connectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
navigate("/apps");
},
onError: (error) => pushToast({ title: "Couldn't disconnect", body: error instanceof Error ? error.message : "Please try again.", tone: "error" }),
});
const [pending, setPending] = useState(false);
const persist = useMutation({
mutationFn: async (next: {
enabled: Set<string>;
askFirst: Set<string>;
access: AccessDraft;
reviewed?: Set<string>;
}) => {
return connection?.connectionPurpose === "ai"
? toolsApi.putConnectionInstalls(connectionId, next.access.mode === "all" ? [{ targetType: "company", targetId: selectedCompanyId! }] : [...next.access.agentIds].map(targetId => ({ targetType: "agent" as const, targetId })))
: toolsApi.finishApp(selectedCompanyId!, connectionId, {
enabledCatalogEntryIds: [...next.enabled],
askFirstCatalogEntryIds: [...next.askFirst].filter((id) => next.enabled.has(id)),
...(next.reviewed ? { reviewedCatalogEntryIds: [...next.reviewed] } : {}),
access: next.access.mode === "all" ? "all_agents" : { agentIds: [...next.access.agentIds] },
});
},
onMutate: () => setPending(true),
onSuccess: () => {
void installsQuery.refetch();
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.policies(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't save that",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
onSettled: () => setPending(false),
});
const [renaming, setRenaming] = useState(false);
const [nameDraft, setNameDraft] = useState("");
const rename = useMutation({
mutationFn: (name: string) => toolsApi.updateConnection(connectionId, { name }),
onSuccess: () => {
setRenaming(false);
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't rename the app",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const startOAuth = useMutation({
mutationFn: (input?: { asAgentId?: string }) => toolsApi.startOAuth(connectionId, input),
onSuccess: async (start) => {
try {
const target = await prepareOAuthNavigation(start);
if (target.kind === "reauthentication" && start.handoff) {
savePendingCloudHandoff(start.handoff.session);
}
navigateTopLevel(target.url);
} catch (error) {
pushToast({
title: "Couldn't start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
}
},
onError: (error) =>
pushToast({
title: "Couldn't start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const invalidateGrants = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
};
/**
* "Connect as me" and "Reconnect" for the signed-in user's own identity. The
* subject is always the caller — the server refuses any other subject — so
* there is no path here to start consent on a coworker's behalf.
*/
const startPersonalAuth = useMutation({
mutationFn: () => {
const subjectUserId = grantsQuery.data?.currentUserId;
if (!subjectUserId) throw new Error("Sign in again to connect your own account.");
return toolsApi.startPersonalAuthorization(selectedCompanyId!, connectionId, {
subjectUserId,
returnTo: appTabHref(connectionId, "permissions"),
});
},
onSuccess: async ({ url, handoff }) => {
try {
const target = await prepareOAuthNavigation({ authorizationUrl: url, handoff });
if (target.kind === "reauthentication" && handoff) {
savePendingCloudHandoff(handoff.session);
}
navigateTopLevel(target.url);
} catch (error) {
pushToast({
title: "Couldn't start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
}
},
onError: (error) =>
pushToast({
title: "Couldn't start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
// A denied or conflicting audience save keeps the dialog open with the
// selection intact, so the error is surfaced inline rather than as a toast.
const [audienceError, setAudienceError] = useState<string | null>(null);
const [audienceOpenGrantId, setAudienceOpenGrantId] = useState<string | null>(null);
const replaceAudience = useMutation({
mutationFn: ({ grantId, memberUserIds }: { grantId: string; memberUserIds: string[] }) =>
toolsApi.replaceConnectionGrantMembers(connectionId, grantId, memberUserIds),
onMutate: () => setAudienceError(null),
onSuccess: (grant) => {
invalidateGrants();
setAudienceOpenGrantId(null);
pushToast({
title: "Audience saved",
body: (grant.members?.length ?? 0) === 0
? "Every organization member can use this identity."
: `${grant.members?.length} ${grant.members?.length === 1 ? "member" : "members"} can use this identity.`,
tone: "success",
});
},
onError: (error) =>
setAudienceError(error instanceof Error ? error.message : "We couldn't save that audience."),
});
const refreshTools = useMutation({
mutationFn: () => toolsApi.refreshCatalog(connectionId),
onSuccess: (result) => {
// Discovery extends the app profile for new actions as well as the catalog.
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.policies(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
pushToast({
title: `Found ${result.discoveredCount} ${result.discoveredCount === 1 ? "action" : "actions"}`,
body: result.quarantinedCount > 0
? `${result.quarantinedCount} new ${result.quarantinedCount === 1 ? "action needs" : "actions need"} your OK.`
: undefined,
tone: "success",
});
},
onError: (error) =>
pushToast({
title: "Couldn't refresh actions",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const refreshGitHubAccess = useMutation({
mutationFn: () => toolsApi.checkConnectionHealth(connectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
pushToast({
title: "GitHub access refreshed",
body: "Account, installation, and repository access are current.",
tone: "success",
});
},
onError: (error) => {
// The failed check is saved on the connection; refetch so the banner
// shows it next to any GitHub recovery link.
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
pushToast({
title: "Couldn't refresh GitHub access",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
},
});
const apply = (mutate: {
enabled?: Set<string>;
askFirst?: Set<string>;
access?: AccessDraft;
reviewed?: Set<string>;
}) =>
persist.mutate({
enabled: mutate.enabled ?? new Set(enabledIds),
askFirst: mutate.askFirst ?? new Set(askFirstIds),
access: mutate.access ?? access,
reviewed: mutate.reviewed,
});
const reviewQuarantined = (allowedIds: string[]) => {
const quarantinedIds = new Set(quarantined.map((entry) => entry.id));
const nextEnabled = new Set([...enabledIds].filter((id) => !quarantinedIds.has(id)));
for (const id of allowedIds) nextEnabled.add(id);
apply({ enabled: nextEnabled, reviewed: quarantinedIds });
};
// Keep old bookmarks and OAuth return URLs working after Setup and Test were
// consolidated into Permissions, and Activity moved to the company feed.
if (connectionId && (tab === "setup" || tab === "test")) {
const query = searchParams.toString();
return <Navigate replace to={`${appTabHref(connectionId, "permissions")}${query ? `?${query}` : ""}`} />;
}
if (tab === "activity") {
return <Navigate replace to="/activity?action=tool_" />;
}
if (!connectionId || !activeTab) {
return <Navigate replace to={connectionId ? appTabHref(connectionId, "permissions") : "/apps"} />;
}
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select an organization to manage apps.</div>;
}
if (connectionQuery.isLoading) {
return (
<div className="max-w-3xl space-y-4">
<Skeleton className="h-10 w-56" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-40 w-full" />
</div>
);
}
if (!connection) {
return (
<div className="max-w-3xl p-6">
<p className="text-sm text-muted-foreground">We couldn't find that app.</p>
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps")}>
Back to connectors
</Button>
</div>
);
}
if (isRetiredComposioConnection(connection)) {
return <div className="max-w-4xl space-y-6 pb-12">
<h1 className="text-xl font-semibold">{appName}</h1>
<section role="status" className="space-y-3 rounded-lg border border-border bg-muted p-4">
<h2 className="text-sm font-semibold">Connection retired</h2>
<p className="text-sm text-muted-foreground">{RETIRED_COMPOSIO_MESSAGE}</p>
<p className="text-sm text-muted-foreground">Remove each obsolete connection separately. Removing this one does not remove other connections.</p>
<Button variant="outline" onClick={() => navigate("/apps/connect?source=composio")}>Add Composio MCP connection</Button>
</section>
{grantsQuery.data?.capabilities.canConfigure === true && <DangerZone
appName={appName}
removing={disconnectRemote.isPending}
onRemove={() => disconnectRemote.mutate()}
/>}
</div>;
}
const aiGrantRevoked = connection.connectionPurpose === "ai"
&& grantRows.length > 0 && grantRows.every((grant) => grant.status === "revoked");
const status: StatusInfo = aiGrantRevoked ? { label: "Revoked", tone: "attention" } : statusFor(connection);
const needsReconnect = connection.requiresReauthorization
?? (status.tone === "attention" && connection.healthStatus !== "unknown");
const quarantined = catalog.filter((e) => e.status === "quarantined");
const active = catalog.filter((e) => e.status === "active");
const readOnly = active.filter((e) => e.isReadOnly);
const canChange = active.filter((e) => !e.isReadOnly);
const actionsContent = renderActions?.(connection) ?? (connection.connectionPurpose === "ai" ? <ManagedAiConnectionDetails connection={connection} /> : undefined);
const actionCount = actionsContent !== undefined ? null : catalogQuery.data ? active.length : null;
const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading;
const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError;
const permissionsFailed = reviewFailed || installsQuery.isError || agentsQuery.isError;
return (
<div className="max-w-4xl space-y-10 pb-12">
<AppDetailHeader
appName={appName}
connection={connection}
logoEntry={logoEntry}
brandKey={brandKey}
allowRemoteLogo={!applicationsQuery.isPending}
status={status}
actionCount={actionCount}
renaming={renaming}
nameDraft={nameDraft}
renamePending={rename.isPending}
onNameDraftChange={setNameDraft}
onRenameStart={() => {
setNameDraft(appName);
setRenaming(true);
}}
onRenameCancel={() => setRenaming(false)}
onRenameSubmit={(next) => {
if (next && next !== appName) rename.mutate(next);
else setRenaming(false);
}}
/>
{status.tone === "attention" && connection.requiresReauthorization === false && (
<AccessRetryBanner
message={connection.healthMessage}
error={refreshGitHubAccess.error}
pending={refreshGitHubAccess.isPending}
onRetry={() => refreshGitHubAccess.mutate()}
/>
)}
{needsReconnect && (
<ReconnectCard
connection={connection}
galleryEntry={logoEntry}
canReconnect={canReconnect}
reconnectUnavailableMessage={reconnectUnavailableMessage}
onReconnect={onReconnect ? () => onReconnect(connection) : (connection.connectionPurpose === "ai" || isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey)) ? () => navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`) : undefined}
onReconnected={() => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
}}
/>
)}
{activeTab === "review" && (
reviewFailed
? <ToolsLoadError onRetry={() => {
void catalogQuery.refetch();
void profilesQuery.refetch();
void policiesQuery.refetch();
}} />
: reviewLoading
? <ToolsLoading />
: <ReviewPanel
connectionId={connectionId}
quarantined={quarantined}
pending={pending}
onReviewQuarantined={reviewQuarantined}
/>
)}
{activeTab === "permissions" && (
permissionsFailed
? <ToolsLoadError onRetry={() => {
void catalogQuery.refetch();
void profilesQuery.refetch();
void policiesQuery.refetch();
void installsQuery.refetch();
void agentsQuery.refetch();
}} />
: permissionsLoading
? <ToolsLoading />
: <div className="space-y-10">
{isAppAggregator(brandKey) && grantsQuery.data?.capabilities.canConfigure === true
? <ConnectedAggregatorApps key={connection.id} connection={connection} /> : null}
{connection.config?.sourceTemplateKey === "honcho" && <HonchoWorkspaceSettings key={connection.id} connection={connection} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}
{renderConnectionSettings?.(connection)}
{connection.config?.sourceTemplateKey === "browser-use-cloud" && <BrowserUseSettingsPanel connection={connection} grants={grantsQuery.data} />}
{connection.config?.sourceTemplateKey === "railway" && <RailwayAccessPanel connection={connection} grants={grantsQuery.data} />}
{connection.config?.provider === "agentmail" && <EmailConnectionInboxes companyId={connection.companyId} connectionId={connection.id} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}
{connection.config?.provider === "agentmail" ? <EmailConnectionAccess companyId={connection.companyId} connectionId={connection.id} agents={agents} /> : <>
<IdentitiesSection
appName={appName}
credentialPolicy={connection.credentialPolicy}
ownerUserId={connection.createdByUserId}
connectedUser={owner}
dedicatedAgent={managedIdentityGrant?.kind === "agent"
? agents.find((agent) => agent.id === managedIdentityGrant.subjectAgentId) ?? null
: null}
grantsQuery={grantsQuery.data}
loading={grantsQuery.isLoading}
error={grantsQuery.isError}
connectPending={startPersonalAuth.isPending || startOAuth.isPending}
audiencePending={replaceAudience.isPending}
audienceError={audienceError}
audienceGrantId={audienceOpenGrantId}
onOpenAudience={(grantId) => {
setAudienceError(null);
setAudienceOpenGrantId(grantId);
}}
onCloseAudience={() => {
setAudienceOpenGrantId(null);
setAudienceError(null);
}}
onConnectAsMe={() => onReconnect ? onReconnect(connection) : startPersonalAuth.mutate()}
onConnectOrganization={() => onReconnect ? onReconnect(connection) : startOAuth.mutate()}
onConnectAgent={(agentId) => startOAuth.mutate({ asAgentId: agentId })}
onRefreshAccess={() => refreshGitHubAccess.mutate()}
refreshAccessPending={refreshGitHubAccess.isPending}
onReplaceAudience={(grant, memberUserIds) =>
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
/>
{connection.config?.sourceTemplateKey === "composio" ? <p className="text-sm text-muted-foreground">
These permissions apply to all apps available through this Composio connection. Manage app accounts and sign-in in Composio.
</p> : null}
{connection.config?.sourceTemplateKey !== "composio" && isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) && <p className="text-sm text-muted-foreground">Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.</p>}
<PermissionsPanel
afterAgentAccess={<>{logoEntry?.agentInstructions && <ConnectionInstructionsSettings key={connection.id} connection={connection} provider={logoEntry.name} template={logoEntry.agentInstructions} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}{renderAgentSettings?.(connection)}</>}
actions={actionsContent}
connectionId={connectionId}
capabilities={grantsQuery.data?.capabilities}
appName={appName}
agents={agents}
access={access}
install={connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? installStateFrom([]) : install}
readOnly={readOnly}
canChange={canChange}
quarantined={quarantined}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
pending={pending}
refreshPending={refreshTools.isPending}
permissionChangeWarning={
connection.credentialPolicy === "per_agent" && managedIdentityGrant?.providerTenant?.github
? "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls."
: undefined
}
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? next : accessIncludingInstalls(next, install) })}
onRefreshActions={() => refreshTools.mutate()}
onSetActionPermission={(ids, next) => apply(actionPermissionMutation(ids, next, enabledIds, askFirstIds))}
onReviewQuarantined={reviewQuarantined}
/>
<AgentConnectionAccess
connectionId={connectionId}
profiles={profilesQuery.data?.profiles ?? []}
policies={policiesQuery.data?.policies ?? []}
catalog={catalog}
agents={agents}
canManage={grantsQuery.data?.capabilities.canConfigure === true}
onRemove={async (profileId) => {
await toolsApi.deleteProfile(profileId);
await profilesQuery.refetch();
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
}}
/>
{managesRemoteMcpAccess && isRemoteMcpConnectorId(connection.config?.sourceTemplateKey) && <RemoteMcpManagement
providerName={baseAppName} canReconnect={canReconnect} canDisconnect={grantsQuery.data?.capabilities.canConfigure === true}
busy={disconnectRemote.isPending}
onReconnect={() => navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`)}
onManage={() => window.open(remoteMcpProviders[connection.config?.sourceTemplateKey as keyof typeof remoteMcpProviders].dashboardUrl, "_blank", "noopener,noreferrer")}
onDisconnect={() => disconnectRemote.mutateAsync()}
/>}
</>}
</div>
)}
</div>
);
}
export function AppDetailHeader({
appName,
connection,
logoEntry,
brandKey,
allowRemoteLogo,
status,
actionCount,
canRename = true,
renaming,
nameDraft,
renamePending,
onNameDraftChange,
onRenameStart,
onRenameCancel,
onRenameSubmit,
}: {
appName: string;
connection: ToolConnection;
logoEntry: AppGalleryDisplayEntry | null;
brandKey: string | null;
allowRemoteLogo: boolean;
status: StatusInfo;
actionCount: number | null;
canRename?: boolean;
renaming: boolean;
nameDraft: string;
renamePending: boolean;
onNameDraftChange: (value: string) => void;
onRenameStart: () => void;
onRenameCancel: () => void;
onRenameSubmit: (value: string) => void;
}) {
const unverifiedHost = unverifiedRemoteHost(connection);
return (
<header>
<div className="flex items-center gap-3">
<AppLogo
name={appName}
brandKey={brandKey}
logoUrl={appDefinitionLogoUrl(logoEntry)}
darkLogoUrl={appDefinitionDarkLogoUrl(logoEntry)}
allowRemoteFallback={allowRemoteLogo}
size={44}
/>
<div className="min-w-0">
{renaming ? (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
onRenameSubmit(nameDraft.trim());
}}
>
<Input
aria-label="App name"
value={nameDraft}
onChange={(event) => onNameDraftChange(event.target.value)}
className="h-9 w-64 text-lg font-bold"
autoFocus
/>
<Button type="submit" size="sm" disabled={renamePending || !nameDraft.trim()}>
{renamePending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onRenameCancel} disabled={renamePending}>
Cancel
</Button>
</form>
) : (
<div className="flex items-center gap-1.5">
<h1 className="truncate text-xl font-bold">{appName}</h1>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
aria-label="Rename app"
disabled={!canRename}
onClick={onRenameStart}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<StatusBadge status={status} />
{connection.config?.provider !== "agentmail" && actionCount !== null && (
<span className="text-xs text-muted-foreground">
{actionCount} {actionCount === 1 ? "action" : "actions"} available
</span>
)}
{connectionDisplaySecondaryHint(connection) ? (
<span className="text-xs text-muted-foreground">
{connectionDisplaySecondaryHint(connection)}
</span>
) : null}
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} /> : null}
<ConnectionProvenanceChip connection={connection} />
</div>
</div>
</div>
</header>
);
}
function ToolsLoading({ mcpActions = false }: { mcpActions?: boolean }) {
return (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
{mcpActions ? "Loading MCP actions, this may take a minute." : "Loading tools…"}
</div>
);
}
function ToolsLoadError({ onRetry }: { onRetry: () => void }) {
return (
<div className="space-y-3 py-8">
<p className="text-sm text-destructive">Couldn’t load tools for this app.</p>
<Button size="sm" variant="outline" onClick={onRetry}>Try again</Button>
</div>
);
}
function unverifiedRemoteHost(connection: ToolConnection): string | null {
const sourceTemplateKey = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey;
if (
connection.transport !== "mcp_remote"
|| (typeof sourceTemplateKey === "string" && sourceTemplateKey.trim())
) return null;
const value = connection.config?.url
?? connection.config?.endpoint
?? connection.config?.remoteUrl
?? connection.transportConfig.url
?? connection.transportConfig.endpoint
?? connection.transportConfig.remoteUrl;
if (typeof value !== "string") return null;
try {
return new URL(value).host || null;
} catch {
return null;
}
}
type StatusInfo = { label: string; tone: "connected" | "attention" | "paused" };
function statusFor(connection: ToolConnection): StatusInfo {
if (connection.enabled === false || connection.status === "disabled") {
return { label: "Paused", tone: "paused" };
}
if (isAttentionHealthStatus(connection.healthStatus) || (connection.connectionPurpose === "ai" && (connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(connection.config)))) {
return { label: "Needs attention", tone: "attention" };
}
return { label: "Connected", tone: "connected" };
}
function StatusBadge({ status }: { status: StatusInfo }) {
const klass: Record<StatusInfo["tone"], string> = {
connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
attention: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
paused: "border-border bg-muted text-muted-foreground",
};
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
klass[status.tone],
)}
>
{status.tone === "connected" && <Check className="h-3 w-3" />}
{status.label}
</span>
);
}
function enabledCatalogIds(profile: ToolProfileWithDetails | undefined): Set<string> {
const ids = new Set<string>();
for (const entry of profile?.entries ?? []) {
if (entry.effect === "include" && entry.catalogEntryId) ids.add(entry.catalogEntryId);
}
return ids;
}
function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set<string> {
const ids = new Set<string>();
for (const policy of policies) {
if (policy.policyType !== "require_approval" || policy.enabled === false) continue;
const config = (policy.config ?? {}) as { source?: unknown; connectionId?: unknown; catalogEntryId?: unknown };
if (config.source === "app_gallery_finish" && config.connectionId === connectionId && typeof config.catalogEntryId === "string") {
ids.add(config.catalogEntryId);
}
}
return ids;
}
/**
* Who may use this connection, read back from the app profile's bindings.
*
* `finishApp` replaces a profile's whole binding set, so every save from this
* page — including an action-permission toggle — has to restate this. The
* Permissions tab exposes the bindings as Agent access, while installs remain
* a separate "always loaded" choice. If the profile has no bindings yet, the
* install state remains the safest legacy fallback.
*
* That fallback is the important part. It used to return "all agents" for an
* unbound profile, which turned any unrelated save into a silent company-wide
* grant from a control the reader could not see. Installs authorize their
* targets, so mirroring the install state is both the truthful reading and the
* one that agrees with what the tab displays.
*/
function accessFrom(
profile: ToolProfileWithDetails | undefined,
install: InstallState,
): AccessDraft {
const bindings = profile?.bindings ?? [];
if (bindings.some((b) => b.targetType === "company")) {
return { mode: "all", agentIds: new Set() };
}
const agentIds = new Set(bindings.filter((b) => b.targetType === "agent").map((b) => b.targetId));
if (agentIds.size > 0) return { mode: "specific", agentIds };
return install.onAll
? { mode: "all", agentIds: new Set() }
: { mode: "specific", agentIds: new Set(install.agentIds) };
}
function accessIncludingInstalls(next: AccessDraft, install: InstallState): AccessDraft {
if (install.onAll || next.mode === "all") {
return { mode: "all", agentIds: new Set() };
}
return {
mode: "specific",
agentIds: new Set([...next.agentIds, ...install.agentIds]),
};
}
function galleryEntryFor(
apps: AppGalleryDisplayEntry[],
connection: ToolConnection | undefined,
application: ToolApplication | undefined,
): AppGalleryDisplayEntry | null {
if (!connection) return null;
const sourceSlug = appApplicationSourceSlug(application) ?? appConnectionSourceSlug(connection);
if (sourceSlug) {
const keyed = apps.find((app) => appDefinitionSlug(app) === sourceSlug);
if (keyed) return keyed;
}
const name = connection.name.toLowerCase();
return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ??
apps.find((app) => appDefinitionSlug(app) === name) ??
null;
}