mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents need source control access for repository work > - A shared token cannot preserve the responsible person's identity or an agent's dedicated identity > - GitHub App tokens also need durable refresh, repository access checks, and webhook delivery > - Paperclip already has managed connections, encrypted grants, run secret leases, and merge-confirmation behavior > - This pull request extends those systems with GitHub identities instead of adding a parallel credential system > - The benefit is durable GitHub access with explicit identity, repository, runtime, and webhook boundaries ## Linked Issues or Issue Description No public GitHub issue describes this connection change. This description follows the feature request template. **Subsystem affected** Connected Apps, connection grants, secret resolution, native Git runtime setup, webhook processing, and the Apps UI. **Problem or motivation** Users need to connect GitHub once and let agents use the correct GitHub identity. A run should use a dedicated agent account when one exists. Otherwise, it should use the responsible person's account. The connection must survive token expiry, repository access changes, and temporary instance downtime. **Proposed solution** Add user-owned and agent-owned GitHub grants to the existing connection model. Resolve one identity for MCP, Git, `gh`, health checks, and webhook bindings. Store provider tokens in the existing encrypted secret system. Refresh expiring token pairs under the existing lease and compare-and-swap path. Register signed Cloud webhook bindings and process normalized pull request and installation events through a durable local inbox. **Alternatives considered** An organization-wide GitHub token would lose person and agent attribution. Environment variables alone would bypass the managed connection and grant model. A new GitHub-only credential store would duplicate the existing secret and access systems. GitHub App installation tokens and private-key custody remain outside this first version. **Roadmap alignment** This change implements the Connected Apps direction. It also extends the shipped MCP Tool Gateway, per-agent secret access, and action-attribution systems. It does not add a repository catalog. The open repository catalog work in [#11234](https://github.com/paperclipai/paperclip/pull/11234) is related and complementary. ## What Changed - Added agent-owned connection grants and a per-agent credential policy with company and subject constraints. - Added a managed GitHub App method while keeping the personal access token method as an advanced fallback. - Added durable access-token and refresh-token handling with proactive rotation and one automatic recovery after a provider `401`. - Added GitHub identity and installation summaries without storing repository-name lists. - Added signed Cloud webhook binding, event lease, acknowledgement, local idempotency, pull request merge processing, and installation access handling. - Added one identity resolver for MCP, native Git, `gh`, checkout, health checks, and webhook bindings. - Added a class-3 run projection for `GH_TOKEN`, `GITHUB_TOKEN`, a `github.com`-only credential helper, SSH-to-HTTPS rewrite, and GitHub noreply commit attribution. - Added personal and dedicated-agent setup choices plus identity, repository, continuity, and webhook status in the Apps UI. - Added schema migrations, tests, and connection documentation. ## Verification - The current head is fully green in GitHub CI, including build, typecheck, all serialized/general server shards, all browser shards, policy, canary dry run, review, and security checks. - Live staging proof completed with a non-expiring GitHub App user token, selected-repository installation, repository add/remove refresh, managed MCP, native `gh`, HTTPS clone/push/delete, GitHub noreply commit attribution, signed merged-PR webhook acceptance, durable Cloud-to-instance delivery, and installation-access event processing. Temporary branches and temporary repository access were removed afterward. - `pnpm check:token-gates` passed. - `pnpm -r typecheck` passed before and after the rebase onto `origin/master`. - `pnpm build` passed. - The focused connector suite passed 285 tests after the rebase. - The full stable suite passed 5,790 tests and failed 22 tests across 8 general server files. The failures reproduced as shared-runner environment issues. They included `/tmp` versus `/private/tmp`, closed database connections, and invalid high ephemeral ports. The focused connection tests pass in isolation. ## Risks - Migrations add agent grant subjects and a durable connection-event inbox. Migration numbering and safety checks pass. - A raw GitHub user token enters the agent process for Git and `gh`. Per-tool Ask-first controls cannot limit those shell operations. The UI warns users about this boundary. - GitHub App user tokens can be non-expiring. Paperclip performs a continuity check every 30 days, but provider revocation still requires a reconnect. - The webhook path accepts only signed and bounded payloads. It stores a minimal normalized record and no raw provider payload. - GitHub repository permissions remain authoritative. Removed access can make a cached repository count temporarily stale, but runtime access fails immediately. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code execution, browser control, and multi-file repository editing. The context window size was not provided. ## 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 - [ ] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
786 lines
32 KiB
TypeScript
786 lines
32 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { HelpCircle, PackageCheck } from "lucide-react";
|
|
import type {
|
|
AgentDetail as AgentDetailRecord,
|
|
ConnectionGrant,
|
|
ToolCatalogEntry,
|
|
ToolConnection,
|
|
ToolPolicy,
|
|
} from "@paperclipai/shared";
|
|
import { Link } from "@/lib/router";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { toolsApi } from "../api/tools";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import { GithubIcon } from "@/components/icons/github-icon";
|
|
import { InlineBanner } from "@/components/InlineBanner";
|
|
import { EnforcementBanner } from "../components/EnforcementBanner";
|
|
import {
|
|
RiskBadge,
|
|
CapabilityBadges,
|
|
LoadingState as ToolsLoadingState,
|
|
ErrorState as ToolsErrorState,
|
|
} from "./tools/shared";
|
|
import { cn } from "../lib/utils";
|
|
import { brandChipBadge } from "../lib/status-colors";
|
|
import { installPayload, installStateFrom, isAgentInstalled, INSTALLED_HINT } from "../lib/tool-installs";
|
|
|
|
function isGitHubConnection(connection: ToolConnection): boolean {
|
|
return (connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey) === "github";
|
|
}
|
|
|
|
function isEligibleGitHubIdentityConnection(connection: ToolConnection, agentId: string): boolean {
|
|
return isGitHubConnection(connection)
|
|
&& connection.enabled
|
|
&& connection.status === "active"
|
|
&& isAgentInstalled(installStateFrom(connection.installs), agentId);
|
|
}
|
|
|
|
function GitHubIdentitySection({
|
|
agentName,
|
|
dedicatedIdentity,
|
|
personalIdentity,
|
|
loading,
|
|
loadError,
|
|
onRetry,
|
|
}: {
|
|
agentName: string;
|
|
dedicatedIdentity: { connection: ToolConnection; grant: ConnectionGrant } | null;
|
|
personalIdentity: { connection: ToolConnection; grant: ConnectionGrant } | null;
|
|
loading: boolean;
|
|
loadError: boolean;
|
|
onRetry: () => void;
|
|
}) {
|
|
const dedicatedLogin = dedicatedIdentity?.grant.providerTenant?.github?.login;
|
|
const personalLogin = personalIdentity?.grant.providerTenant?.github?.login;
|
|
|
|
return (
|
|
<section className="rounded-lg border border-border bg-card">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 px-3 py-3">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<div className="rounded-md border border-border bg-muted/40 p-2 text-foreground">
|
|
<GithubIcon className="h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-semibold text-foreground">GitHub identity</h3>
|
|
{loading ? (
|
|
<p className="mt-0.5 text-xs text-muted-foreground">Checking GitHub identity…</p>
|
|
) : loadError ? (
|
|
<>
|
|
<p className="mt-0.5 text-sm font-medium text-foreground">Could not load GitHub identity</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
Paperclip could not verify this agent's current connection. Your existing setup was not changed.
|
|
</p>
|
|
</>
|
|
) : dedicatedIdentity ? (
|
|
<>
|
|
<p className="mt-0.5 text-sm font-medium text-foreground">
|
|
{dedicatedLogin ? `@${dedicatedLogin}` : "Dedicated GitHub account"}
|
|
</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
Used only by {agentName}. This dedicated connection takes precedence over the responsible person's GitHub.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="mt-0.5 text-sm font-medium text-foreground">Use responsible person's GitHub</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
At run start, Paperclip uses the personal GitHub connection of the person responsible for the task.
|
|
</p>
|
|
{personalIdentity ? (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Your account is connected{personalLogin ? ` as @${personalLogin}` : ""}.
|
|
</p>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!loading ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{loadError ? (
|
|
<Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>
|
|
) : dedicatedIdentity ? (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to={`/apps/${dedicatedIdentity.connection.id}/permissions`}>Manage GitHub identity</Link>
|
|
</Button>
|
|
) : (
|
|
<>
|
|
{personalIdentity ? (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to={`/apps/${personalIdentity.connection.id}/permissions`}>Manage my GitHub</Link>
|
|
</Button>
|
|
) : (
|
|
<Button size="sm" asChild>
|
|
<Link to="/apps/connect?source=github">Connect my GitHub</Link>
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to="/apps/connect?source=github">Use a dedicated account</Link>
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** Normalize a selector value (string or string[]) into a flat string list. */
|
|
function selectorStringList(value: unknown): string[] {
|
|
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
|
|
if (Array.isArray(value)) {
|
|
return value.filter((v): v is string => typeof v === "string" && v.trim().length > 0);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* A policy governs this agent's allow list when it either explicitly names the
|
|
* agent, or carries no agent/actor restriction that would exclude agents. This
|
|
* mirrors how the tool gateway evaluates selectors server-side.
|
|
*/
|
|
export function policyGovernsAgent(policy: ToolPolicy, agentId: string): boolean {
|
|
const selectors = (policy.selectors ?? {}) as Record<string, unknown>;
|
|
const agentIds = [
|
|
...selectorStringList(selectors.agentId),
|
|
...selectorStringList(selectors.agentIds),
|
|
];
|
|
if (agentIds.length > 0) return agentIds.includes(agentId);
|
|
const actorTypes = [
|
|
...selectorStringList(selectors.actorType),
|
|
...selectorStringList(selectors.actorTypes),
|
|
];
|
|
if (actorTypes.length > 0 && !actorTypes.includes("agent")) return false;
|
|
return true;
|
|
}
|
|
|
|
export function mergeInstallDraft(
|
|
serverDraft: Record<string, boolean>,
|
|
currentDraft: Record<string, boolean>,
|
|
lastSavedDraft: Record<string, boolean>,
|
|
): { draft: Record<string, boolean>; hasPendingChanges: boolean } {
|
|
const draft = { ...serverDraft };
|
|
let hasPendingChanges = false;
|
|
for (const [connectionId, installed] of Object.entries(currentDraft)) {
|
|
if (installed === (lastSavedDraft[connectionId] ?? false)) continue;
|
|
draft[connectionId] = installed;
|
|
hasPendingChanges = true;
|
|
}
|
|
return { draft, hasPendingChanges };
|
|
}
|
|
|
|
function InstalledAppsSection({
|
|
agentId,
|
|
agentName,
|
|
connections,
|
|
draft,
|
|
permittedConnectionIds,
|
|
pendingConnectionId,
|
|
saving,
|
|
unsaved,
|
|
error,
|
|
onChange,
|
|
}: {
|
|
agentId: string;
|
|
agentName: string;
|
|
connections: ToolConnection[];
|
|
draft: Record<string, boolean>;
|
|
permittedConnectionIds: Set<string>;
|
|
pendingConnectionId: string | null;
|
|
saving: boolean;
|
|
unsaved: boolean;
|
|
error: boolean;
|
|
onChange: (connectionId: string, installed: boolean) => void;
|
|
}) {
|
|
return (
|
|
<section className="rounded-lg border border-border bg-card">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-3 py-2.5">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-foreground">Installed apps</h3>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
Installed apps load tools into {agentName}'s context on every run. Permitted-only apps do not add context cost.
|
|
</p>
|
|
</div>
|
|
<InstallSaveStatusChip pending={saving} unsaved={unsaved} error={error} />
|
|
</div>
|
|
|
|
<div className="space-y-3 p-3">
|
|
<InlineBanner tone="info" compact>
|
|
Has access means the app is permitted. Installed means its tools are added to this agent's runtime context.
|
|
</InlineBanner>
|
|
|
|
{connections.length === 0 ? (
|
|
<p className="rounded-md border border-border bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
|
|
No permitted apps yet. Bind an access profile to make apps available here.
|
|
</p>
|
|
) : (
|
|
<div className="divide-y divide-border rounded-md border border-border">
|
|
{connections.map((connection) => {
|
|
const installState = installStateFrom(connection.installs);
|
|
const installedForAll = installState.onAll;
|
|
const checked = installedForAll || (draft[connection.id] ?? installState.agentIds.has(agentId));
|
|
const permitted = permittedConnectionIds.has(connection.id);
|
|
const rowPending = pendingConnectionId === connection.id;
|
|
return (
|
|
<div key={connection.id} className="space-y-2 px-3 py-3">
|
|
<label className="flex items-start gap-3">
|
|
<Checkbox
|
|
checked={checked}
|
|
disabled={installedForAll || rowPending}
|
|
aria-label={`Install ${connection.name} on ${agentName}`}
|
|
onCheckedChange={(next) => onChange(connection.id, Boolean(next))}
|
|
/>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-foreground">{connection.name}</span>
|
|
<InstallBadge installed={checked} installedForAll={installedForAll} permitted={permitted} />
|
|
{rowPending ? <span className="text-xs text-muted-foreground">Saving...</span> : null}
|
|
</span>
|
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
{installedForAll
|
|
? "Installed from the app page for every agent. Remove the all-agents install there."
|
|
: checked
|
|
? "Loaded into this agent's runtime context."
|
|
: INSTALLED_HINT}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
{permitted && !checked ? (
|
|
<InlineBanner
|
|
tone="warning"
|
|
compact
|
|
className="ml-7"
|
|
actions={(
|
|
<Link
|
|
to={`/apps/${connection.id}/permissions`}
|
|
className="text-xs font-medium text-primary hover:underline"
|
|
>
|
|
Open permissions
|
|
</Link>
|
|
)}
|
|
>
|
|
Permitted but not installed — tools will not appear in runs.
|
|
</InlineBanner>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function InstallBadge({
|
|
installed,
|
|
installedForAll,
|
|
permitted,
|
|
}: {
|
|
installed: boolean;
|
|
installedForAll: boolean;
|
|
permitted: boolean;
|
|
}) {
|
|
const label = installed ? (installedForAll ? "Installed for all" : "Installed") : permitted ? "Permitted only" : "Not permitted";
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
|
installed ? brandChipBadge.green : brandChipBadge.gray,
|
|
)}
|
|
>
|
|
{installed ? <PackageCheck className="h-3 w-3" /> : null}
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function InstallSaveStatusChip({
|
|
pending,
|
|
unsaved,
|
|
error,
|
|
}: {
|
|
pending: boolean;
|
|
unsaved: boolean;
|
|
error: boolean;
|
|
}) {
|
|
if (pending) return <span className="text-xs text-muted-foreground">Saving...</span>;
|
|
if (error) return <span className="text-xs text-destructive">Could not save</span>;
|
|
if (unsaved) return <span className="text-xs text-muted-foreground">Unsaved changes</span>;
|
|
return <span className="text-xs text-muted-foreground">Saved</span>;
|
|
}
|
|
|
|
const POLICY_EFFECT_LABEL: Record<string, string> = {
|
|
allow: "allow",
|
|
block: "block",
|
|
deny: "deny",
|
|
require_approval: "require approval",
|
|
redact: "redact",
|
|
rate_limit: "rate limit",
|
|
};
|
|
|
|
const DENIED_TOOLS_DISPLAY_LIMIT = 30;
|
|
|
|
/**
|
|
* Agent detail · Tools tab (PAP-10788, surface 09 of the PAP-10771 v2 spec).
|
|
*
|
|
* Communicates the agent's resolved tool access: the banner makes clear the
|
|
* prompt can narrow the list but never expand it, and the side panel explains
|
|
* which access profiles and rules shape the final list.
|
|
*/
|
|
export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord; companyId: string }) {
|
|
const queryClient = useQueryClient();
|
|
const [installDraft, setInstallDraft] = useState<Record<string, boolean>>({});
|
|
const lastSavedInstallRef = useRef<Record<string, boolean>>({});
|
|
const skipNextInstallAutosaveRef = useRef(true);
|
|
const failedInstallDraftRef = useRef<{ connectionId: string; installed: boolean } | null>(null);
|
|
|
|
const effective = useQuery({
|
|
queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id),
|
|
queryFn: () => toolsApi.getEffectiveProfilesForAgent(companyId, agent.id),
|
|
});
|
|
|
|
const connectionsQuery = useQuery({
|
|
queryKey: queryKeys.tools.connections(companyId),
|
|
queryFn: () => toolsApi.listConnections(companyId),
|
|
});
|
|
|
|
const policiesQuery = useQuery({
|
|
queryKey: queryKeys.tools.policies(companyId),
|
|
queryFn: () => toolsApi.listPolicies(companyId),
|
|
});
|
|
|
|
const connectionList = connectionsQuery.data?.connections ?? [];
|
|
const eligibleGitHubConnections = useMemo(
|
|
() => connectionList.filter((connection) => isEligibleGitHubIdentityConnection(connection, agent.id)),
|
|
[agent.id, connectionList],
|
|
);
|
|
const githubGrantQueries = useQueries({
|
|
queries: eligibleGitHubConnections.map((connection) => ({
|
|
queryKey: queryKeys.tools.connectionGrants(connection.id),
|
|
queryFn: () => toolsApi.listConnectionGrants(connection.id),
|
|
staleTime: 30_000,
|
|
})),
|
|
});
|
|
const githubIdentityRows = eligibleGitHubConnections.flatMap((connection, index) =>
|
|
(githubGrantQueries[index]?.data?.grants ?? []).map((grant) => ({
|
|
connection,
|
|
grant,
|
|
currentUserId: githubGrantQueries[index]?.data?.currentUserId,
|
|
})),
|
|
);
|
|
const dedicatedGitHubIdentity = githubIdentityRows.find(({ grant }) => (
|
|
grant.kind === "agent"
|
|
&& grant.subjectAgentId === agent.id
|
|
&& grant.status === "active"
|
|
)) ?? null;
|
|
const personalGitHubIdentity = githubIdentityRows.find(({ grant, currentUserId }) => (
|
|
grant.kind === "user"
|
|
&& grant.subjectUserId === currentUserId
|
|
&& grant.status === "active"
|
|
)) ?? null;
|
|
const connectionInstallSignature = useMemo(
|
|
() =>
|
|
connectionList
|
|
.map((connection) => {
|
|
const installKey = (connection.installs ?? [])
|
|
.map((install) => `${install.targetType}:${install.targetId}`)
|
|
.sort()
|
|
.join(",");
|
|
return `${connection.id}=${installKey}`;
|
|
})
|
|
.join("|"),
|
|
[connectionList],
|
|
);
|
|
|
|
const syncInstall = useMutation({
|
|
mutationFn: ({ connection, installed }: { connection: ToolConnection; installed: boolean }) => {
|
|
const nextState = installStateFrom(connection.installs);
|
|
if (!nextState.onAll) {
|
|
if (installed) nextState.agentIds.add(agent.id);
|
|
else nextState.agentIds.delete(agent.id);
|
|
}
|
|
return toolsApi.putConnectionInstalls(
|
|
connection.id,
|
|
installPayload(connection.companyId ?? companyId, nextState),
|
|
);
|
|
},
|
|
onSuccess: async (_snapshot, variables) => {
|
|
failedInstallDraftRef.current = null;
|
|
lastSavedInstallRef.current = {
|
|
...lastSavedInstallRef.current,
|
|
[variables.connection.id]: variables.installed,
|
|
};
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(variables.connection.id) }),
|
|
]);
|
|
},
|
|
onError: (_error, variables) => {
|
|
failedInstallDraftRef.current = {
|
|
connectionId: variables.connection.id,
|
|
installed: variables.installed,
|
|
};
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
setInstallDraft({});
|
|
lastSavedInstallRef.current = {};
|
|
skipNextInstallAutosaveRef.current = true;
|
|
failedInstallDraftRef.current = null;
|
|
}, [agent.id, companyId]);
|
|
|
|
useEffect(() => {
|
|
const next = Object.fromEntries(
|
|
connectionList.map((connection) => [
|
|
connection.id,
|
|
isAgentInstalled(installStateFrom(connection.installs), agent.id),
|
|
]),
|
|
);
|
|
failedInstallDraftRef.current = null;
|
|
setInstallDraft((current) => {
|
|
const merged = mergeInstallDraft(next, current, lastSavedInstallRef.current);
|
|
lastSavedInstallRef.current = next;
|
|
skipNextInstallAutosaveRef.current = !merged.hasPendingChanges;
|
|
return merged.draft;
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [agent.id, connectionInstallSignature]);
|
|
|
|
useEffect(() => {
|
|
if (skipNextInstallAutosaveRef.current) {
|
|
skipNextInstallAutosaveRef.current = false;
|
|
return;
|
|
}
|
|
if (syncInstall.isPending) return;
|
|
|
|
const changedConnection = connectionList.find((connection) => {
|
|
const saved = lastSavedInstallRef.current[connection.id] ?? false;
|
|
const draft = installDraft[connection.id] ?? false;
|
|
const failed = failedInstallDraftRef.current;
|
|
return saved !== draft && !(failed?.connectionId === connection.id && failed.installed === draft);
|
|
});
|
|
if (!changedConnection) return;
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
const installed = installDraft[changedConnection.id] ?? false;
|
|
const saved = lastSavedInstallRef.current[changedConnection.id] ?? false;
|
|
const failed = failedInstallDraftRef.current;
|
|
if (saved !== installed && !(failed?.connectionId === changedConnection.id && failed.installed === installed)) {
|
|
syncInstall.mutate({ connection: changedConnection, installed });
|
|
}
|
|
}, 250);
|
|
|
|
return () => window.clearTimeout(timeout);
|
|
}, [connectionList, installDraft, syncInstall.isPending, syncInstall.mutate]);
|
|
|
|
// The effective endpoint returns the *allowed* slice of the catalog. To show
|
|
// "Denied tools (suppressed)" we need the full company catalog, which is only
|
|
// exposed per-connection — same aggregation the Applications tab uses.
|
|
const catalogQueries = useQueries({
|
|
queries: connectionList.map((connection) => ({
|
|
queryKey: queryKeys.tools.catalog(connection.id),
|
|
queryFn: () => toolsApi.listCatalog(connection.id),
|
|
staleTime: 60_000,
|
|
})),
|
|
});
|
|
|
|
const connectionNameById = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const connection of connectionList) map.set(connection.id, connection.name);
|
|
return map;
|
|
}, [connectionList]);
|
|
|
|
const allowedTools = useMemo(
|
|
() =>
|
|
[...(effective.data?.allowedTools ?? [])].sort((a, b) =>
|
|
a.toolName.localeCompare(b.toolName),
|
|
),
|
|
[effective.data?.allowedTools],
|
|
);
|
|
|
|
const permittedConnectionIds = useMemo(
|
|
() => new Set([
|
|
...(effective.data?.entries ?? [])
|
|
.filter((entry) => entry.effect === "include" && entry.connectionId)
|
|
.map((entry) => entry.connectionId!),
|
|
...allowedTools.map((tool) => tool.connectionId),
|
|
]),
|
|
[allowedTools, effective.data?.entries],
|
|
);
|
|
const installedAppConnections = useMemo(
|
|
() =>
|
|
connectionList
|
|
.filter((connection) => permittedConnectionIds.has(connection.id) || (installDraft[connection.id] ?? false))
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
[connectionList, installDraft, permittedConnectionIds],
|
|
);
|
|
const hasInstallUnsavedChanges = installedAppConnections.some(
|
|
(connection) => (installDraft[connection.id] ?? false) !== (lastSavedInstallRef.current[connection.id] ?? false),
|
|
);
|
|
|
|
const catalogStamp = catalogQueries.map((q) => q.dataUpdatedAt).join(",");
|
|
const deniedTools = useMemo(() => {
|
|
const allowedIds = new Set((effective.data?.allowedTools ?? []).map((tool) => tool.id));
|
|
const seen = new Set<string>();
|
|
const denied: ToolCatalogEntry[] = [];
|
|
for (const result of catalogQueries) {
|
|
for (const entry of result.data?.catalog ?? []) {
|
|
if (entry.status !== "active") continue;
|
|
if (allowedIds.has(entry.id) || seen.has(entry.id)) continue;
|
|
seen.add(entry.id);
|
|
denied.push(entry);
|
|
}
|
|
}
|
|
return denied.sort((a, b) => a.toolName.localeCompare(b.toolName));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [effective.data?.allowedTools, catalogStamp]);
|
|
|
|
const governingPolicies = useMemo(() => {
|
|
const policies = policiesQuery.data?.policies ?? [];
|
|
return policies
|
|
.map((policy, index) => ({ policy, order: index + 1 }))
|
|
.filter(({ policy }) => policy.enabled && policyGovernsAgent(policy, agent.id));
|
|
}, [policiesQuery.data?.policies, agent.id]);
|
|
|
|
const profiles = effective.data?.profiles ?? [];
|
|
const catalogLoading = catalogQueries.some((q) => q.isLoading);
|
|
|
|
if (effective.isLoading) return <ToolsLoadingState label="Resolving effective access…" />;
|
|
if (effective.error) {
|
|
return <ToolsErrorState error={effective.error} onRetry={() => effective.refetch()} />;
|
|
}
|
|
|
|
const profilesHref = "/apps/advanced/profiles";
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<EnforcementBanner
|
|
tone="info"
|
|
title="Effective access"
|
|
body={
|
|
<>
|
|
This is exactly the tool set Paperclip will accept for{" "}
|
|
<span className="font-medium">{agent.name}</span>. Profile and policy edits are
|
|
reflected within ~5 seconds. The agent's prompt can narrow this list but{" "}
|
|
<span className="font-medium">cannot expand it</span> — everything else is blocked by
|
|
default.
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<GitHubIdentitySection
|
|
agentName={agent.name}
|
|
dedicatedIdentity={dedicatedGitHubIdentity}
|
|
personalIdentity={personalGitHubIdentity}
|
|
loading={connectionsQuery.isLoading || githubGrantQueries.some((query) => query.isLoading)}
|
|
loadError={connectionsQuery.isError || githubGrantQueries.some((query) => query.isError)}
|
|
onRetry={() => {
|
|
if (connectionsQuery.isError) void connectionsQuery.refetch();
|
|
for (const query of githubGrantQueries) {
|
|
if (query.isError) void query.refetch();
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<InstalledAppsSection
|
|
agentId={agent.id}
|
|
agentName={agent.name}
|
|
connections={installedAppConnections}
|
|
draft={installDraft}
|
|
permittedConnectionIds={permittedConnectionIds}
|
|
pendingConnectionId={syncInstall.isPending ? syncInstall.variables?.connection.id ?? null : null}
|
|
saving={syncInstall.isPending}
|
|
unsaved={hasInstallUnsavedChanges}
|
|
error={syncInstall.isError && hasInstallUnsavedChanges}
|
|
onChange={(connectionId, installed) => {
|
|
failedInstallDraftRef.current = null;
|
|
setInstallDraft((current) => ({ ...current, [connectionId]: installed }));
|
|
}}
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
{/* Allowed tools table */}
|
|
<div className="lg:col-span-2">
|
|
<div className="rounded-lg border border-border">
|
|
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
|
|
<h3 className="text-sm font-semibold text-foreground">Allowed tools</h3>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{allowedTools.length} {allowedTools.length === 1 ? "tool" : "tools"}
|
|
</span>
|
|
</div>
|
|
{allowedTools.length === 0 ? (
|
|
<p className="px-3 py-6 text-sm text-muted-foreground">
|
|
No tools are allowed for this agent. Bind a tool profile to grant access.
|
|
</p>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border text-left text-xs text-muted-foreground">
|
|
<th className="px-3 py-2 font-medium">Tool</th>
|
|
<th className="px-3 py-2 font-medium">Capability</th>
|
|
<th className="px-3 py-2 font-medium">Risk</th>
|
|
<th className="px-3 py-2 font-medium">Source</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{allowedTools.map((tool) => (
|
|
<tr key={tool.id} className="align-top">
|
|
<td className="px-3 py-2">
|
|
<div className="font-mono text-xs text-foreground">{tool.toolName}</div>
|
|
{tool.title ? (
|
|
<div className="text-(length:--text-micro) text-muted-foreground">{tool.title}</div>
|
|
) : null}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<CapabilityBadges
|
|
isReadOnly={tool.isReadOnly}
|
|
isWrite={tool.isWrite}
|
|
isDestructive={tool.isDestructive}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<RiskBadge risk={tool.riskLevel} />
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
|
{connectionNameById.get(tool.connectionId) ?? "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Why these tools? side panel */}
|
|
<aside className="space-y-4">
|
|
<div className="rounded-lg border border-border bg-background/60 p-3">
|
|
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
|
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
|
Why these tools?
|
|
</h3>
|
|
|
|
{/* Access profiles */}
|
|
<div className="mt-3 space-y-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
|
Access profiles
|
|
</div>
|
|
<Link
|
|
to={`${profilesHref}?check=1`}
|
|
className="text-(length:--text-micro) font-medium text-primary hover:underline"
|
|
>
|
|
Check access
|
|
</Link>
|
|
</div>
|
|
{profiles.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
No active profile applies to this agent, so it has no allowed tools.
|
|
</p>
|
|
) : (
|
|
profiles.map((profile) => {
|
|
return (
|
|
<div key={profile.id} className="rounded-md border border-border/70 px-2.5 py-2">
|
|
<Link
|
|
to={`${profilesHref}/${profile.id}`}
|
|
className="text-xs font-medium text-primary hover:underline"
|
|
>
|
|
{profile.name}
|
|
</Link>
|
|
{profile.summary.isCompanyDefault ? (
|
|
<div className="mt-1">
|
|
<span className="rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
|
|
Organization default
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Policies mutating the allow list */}
|
|
<div className="mt-3 space-y-1.5">
|
|
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
|
Active policies
|
|
</div>
|
|
{policiesQuery.isLoading ? (
|
|
<p className="text-xs text-muted-foreground">Loading policies…</p>
|
|
) : governingPolicies.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
No enabled policy currently mutates this agent's allow list.
|
|
</p>
|
|
) : (
|
|
governingPolicies.map(({ policy, order }) => (
|
|
<div key={policy.id} className="rounded-md border border-border/70 px-2.5 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span
|
|
className="truncate text-xs font-medium text-foreground"
|
|
title={`Policy #${order}: ${policy.name}`}
|
|
>
|
|
#{order} {policy.name}
|
|
</span>
|
|
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
|
|
{POLICY_EFFECT_LABEL[policy.policyType] ?? policy.policyType}
|
|
</span>
|
|
</div>
|
|
{policy.description ? (
|
|
<div className="mt-0.5 truncate text-(length:--text-micro) text-muted-foreground">
|
|
{policy.description}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Unavailable tools */}
|
|
<div className="mt-3 space-y-1.5">
|
|
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
|
Unavailable tools
|
|
</div>
|
|
{catalogLoading ? (
|
|
<p className="text-xs text-muted-foreground">Checking tools…</p>
|
|
) : deniedTools.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Every known tool this agent could name is allowed.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<p className="text-(length:--text-micro) text-muted-foreground">
|
|
Tools the agent could name but Paperclip would block:
|
|
</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{deniedTools.slice(0, DENIED_TOOLS_DISPLAY_LIMIT).map((tool) => (
|
|
<span
|
|
key={tool.id}
|
|
className="inline-flex items-center gap-1 rounded border border-border/70 bg-muted/40 px-1.5 py-0.5 font-mono text-(length:--text-micro) text-muted-foreground"
|
|
title={connectionNameById.get(tool.connectionId) ?? undefined}
|
|
>
|
|
{tool.toolName}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{deniedTools.length > DENIED_TOOLS_DISPLAY_LIMIT ? (
|
|
<p className="text-(length:--text-micro) text-muted-foreground">
|
|
+{deniedTools.length - DENIED_TOOLS_DISPLAY_LIMIT} more
|
|
</p>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|