mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip lets people manage agent work through the board. > - The board reads task, agent, settings, plugin, and run data from the server. > - A deploy can interrupt those reads after content is already visible. > - Local error branches can replace that content with an error or show raw codes. > - This pull request moves the remaining reads to the shared recovery behavior. > - The query-error guard prevents new local error patterns from returning. ## Linked Issues or Issue Description **What happened?** Loaded lists, settings, task panels, and manual reads could clear their content or show errors during a server restart. Some reads also disabled the shared retry policy. **Expected behavior** Keep loaded content during a temporary failure. Show the shared connection banner. Retry reads after recovery. Replace content after a real access denial or missing resource, with readable copy. **Steps to reproduce** Open a task, settings page, or run log. Return 503 with `tenant_app_unavailable` from the API. Restore the API and check that content refreshes. Repeat with 403 and 404 to check the failure state. Related merged work: Refs #15754, Refs #15772, Refs #17960. This change covers remaining reads and enforcement. Browser persistence and the outage browser suite follow separately. ## What Changed - Repair an existing PostgreSQL deadline fixture at UTC boundaries. A daily cap defers a heartbeat until both its original interval and the next UTC day allow it. Keep the counted run in its seeded day and cover noon, just before midnight and the first second after midnight with explicit clocks and real PostgreSQL. Production scheduling is unchanged. - Integrate the saved list, settings, shell, task, and manual-read recovery changes against current master. Preserve the current task, Inbox, and run-log structure. - Add `useResilientFetch` for component-local reads. Preserve loaded data, cancel stale requests, and retry through the shared connectivity policy. Keep browser and pairing capabilities in memory. - Retain run log chunks across stream changes and catch up after recovery without duplicate chunks or stalled requests. - Keep genuine authorization and missing-resource failures visible. Translate known single-word codes such as `forbidden`. Show known skills and disconnected-app failures before sibling loading states. Keep Retry bound to its failed request. - Make the query-error guard enforce by default and run it in the CI policy job. Document the narrow exceptions and the read recovery contract. - Invalidate pending OAuth discovery when the operator leaves setup or its context changes. Keep reserved-popup cleanup bound to the current attempt. ## Verification - Current read head `09d1db29dea` is merge-ready: all 59 check runs are completed (54 successful, three skipped, two neutral), the legacy Snyk status is successful, and both required `ci / verify` and `ci / e2e` gates pass. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads; the branch has no merge conflicts. The current CI run is `41748339602`. - Current read head `09d1db29dea` includes master `683fa4725e9`. The latest change touches only the existing server deadline test fixture. Application source remains the previously verified `86376fa751e` revision: all 60 affected skills/disconnected-app tests and both application UI typechecks passed, along with token gates and the enforcing query-error guard. Those UI suites were not repeated for this test-only repair. - The fixture repair passes all seven deadline integration cases and the complete server typecheck, including the server test layer. The old assertion deterministically fails at 23:59:06.888 UTC; its one-second-old run also fails the first-second-of-day probe. The corrected fixture passes noon and both midnight probes. The permanent three-case coverage uses seeded 2099 clocks. - Twelve new regressions fail against the previous implementation. Both skills variants and disconnected-app pages show either request's 403 or 429 while the sibling pauses or remains fetching. Permission failures stay visible. Retry stays usable for its failed request and recovers independently. An initial test-fixture query-key typo was corrected before the passing run. The finding is replied to and resolved. Greptile reviewed prior application revision `86376fa751e` at 5/5 with no new actionable findings or unresolved threads. The current test-only head has now received its own Apex 5/5 review with no unresolved threads, and its CI is successful as recorded above. - The preceding OAuth fix passed all 237 setup tests on both application branches. Its ten regressions prove that Back, Cancel, unmount, or context changes retire initial discovery without creating a connection, caching old identities, navigating an abandoned popup, or closing a newer attempt. - Earlier master integration `e992f770339` passed all 309 tests across eight affected Slack, recovery, retry, submission and task files, plus UI typecheck, token gates, enforcing query-error scan and seven guard tests. The integration preserves Slack workspace reconnect and shared recovery, readable execution errors, source-inspection company scoping, and current submission behavior. Newly inherited retained-file recovery controls keep cached data through transient failures and hide it after definitive denial. - The preceding cache head `97c0bc3966f` passed 1,038 focused cache/write/editor/account/task/API tests. All 22 built-UI outage/write/loading cases passed in 4.7 minutes on exact browser head `b7deaaee5da`, with strict checking of all three specs. The latest skills/app rendering fix is covered by its affected component suites; those broader cases were not repeated for this separate read path. - Historical broader UI, CLI and build results and unresolved aggregate server/DB/adapter failures remain in the PR discussion. Interrupted CI runs were audited and recorded separately; they are not counted as passes. Controlled local outages do not prove hosted authentication or a real Cloud restart/deploy. Real hosted acceptance remains required after merge and deployment. Commands for the latest fix: ```sh pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx pnpm --filter @paperclipai/ui typecheck pnpm check:token-gates node scripts/check-query-error-rendering.mjs --enforce --summary ``` ## Risks This migration touches many read surfaces. The main risks are initial loading, request cancellation, retained authorization state, and run-log paging. The tests cover those states. There are no server, schema, dependency, or API contract changes. The follow-up browser suite and hosted deploy acceptance are required before release acceptance. Recovered commits retain their original authors. If squash-merging, preserve `Co-Authored-By: Bender (Fable) <bender@agents.paperclip.ing>` and the Paperclip trailer. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool use, and code execution. The context window size is not exposed. Saved source commits keep their original authorship. ## 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 described the issue in-PR following the bug report template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] The affected local tests pass; broader limitations are recorded above - [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 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: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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} // query-error-ok: syncInstall is a save mutation result, not a query read
|
|
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>
|
|
);
|
|
}
|