mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Governed MCP access spans contracts, runtime enforcement, adapters, UI surfaces, and operator verification > - The parity reference PR #9534 is too large for effective automated or human review > - The feature therefore needs a linear stack whose individual diffs stay below the 100-file review limit > - This pull request is split 6/8 and focuses on UI API, shared components, and Tools/Profile surfaces > - The benefit is a standalone, testable review boundary while preserving byte-for-byte parity at the top of the stack ## Linked Issues or Issue Description - Related parity reference: #9534 - Problem: Operators need typed clients and administration surfaces that compile independently before navigation exposes them. - Proposed solution: Adds UI APIs, hooks, libraries, shared components, Tools/Profiles pages, and the plugin settings consumer required by the new company-scoped API. - Alternatives considered: keeping #9534 as one 403-file review, or rewriting the feature to manufacture seams; both were rejected in favor of path extraction plus compile-driven boundary moves. - Roadmap alignment: this advances the existing governed MCP/tool-access work already represented by #9534; it does not introduce a separate roadmap initiative. - Stack position: base branch is `pap10341-split/05-runtime-integration`. - Merge policy: merge bottom-up, in order, only after the complete eight-PR stack has been reviewed and the top-of-stack parity gate remains empty. - Requested review: UXDesigner sanity pass on Tools/Profile surfaces; Greptile on every PR. ## What Changed - Adds UI APIs, hooks, libraries, shared components, Tools/Profiles pages, and the plugin settings consumer required by the new company-scoped API. - Keeps this PR below 100 changed files and independently typecheckable. - Preserves the final tree from #9534 when combined with the other seven stack levels. ## Verification - `pnpm typecheck` - `pnpm check:token-gates` — all gates clean - Focused UI Vitest run with `NODE_ENV=test` — 28 files, 183 tests passed ## Risks - Large dead-code UI additions can drift from activation routes; PR 7 supplies the registration layer and top-level parity catches omissions. - Stack risk: merging out of order can expose incomplete layers; mitigate by following the documented bottom-up merge policy. - Parity risk: later edits to an intermediate branch can drift from #9534; mitigate by re-running the empty top-of-stack diff before merge. > 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, exact model ID `gpt-5.4`; runtime-managed context window; medium reasoning with repository, shell, Git, GitHub CLI, and code-execution tools enabled. ## 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] Internal references are omitted except the execution-plan link explicitly required for this coordinated split stack - [x] My branch name describes the change 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Stack Coordination - Internal execution plan: [PAP-13874](/PAP/issues/PAP-13874#document-plan) - Parity reference: #9534 - Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563 - Merge bottom-up only after full-stack review and an empty parity diff at #9563. ## UI Evidence QA captured these from the live Garden MCP split stack at 1440px and verified clean rendering:    --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import type { ReactNode } from "react";
|
||
import { Clock, Pencil, ShieldCheck } from "lucide-react";
|
||
import type { ToolRiskLevel } from "@paperclipai/shared";
|
||
import { cn } from "@/lib/utils";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import { EnforcementBanner } from "@/components/EnforcementBanner";
|
||
import { CapabilityBadges, DecisionBadge, RiskBadge } from "@/pages/tools/shared";
|
||
|
||
/**
|
||
* Action approval card (PAP-10787 / PAP-10778, surfaces 11/12/99).
|
||
*
|
||
* The card an agent's run posts into the issue thread when a governed tool
|
||
* call needs human approval. Two hard requirements from the PAP-10400 security
|
||
* hardening must never regress:
|
||
*
|
||
* 1. The **signed payload sha256 + expiry** are always surfaced, so a reviewer
|
||
* approves exactly the bytes that were signed and can see when the request
|
||
* lapses.
|
||
* 2. The server-driven **stale** variant disables Approve and shows the
|
||
* catalog-hash mismatch (previous hash struck through next to current), so
|
||
* re-issuance is visibly required — an approval can never be granted
|
||
* against a catalog the orchestrator no longer trusts.
|
||
*/
|
||
|
||
export type ActionCardVariant = "pending" | "stale";
|
||
|
||
/** One key/value row in the {@link BindingsTable}. */
|
||
export interface BindingRow {
|
||
label: string;
|
||
value: ReactNode;
|
||
/** Render the value in the mono catalog/hash treatment. */
|
||
mono?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Two-column key/value block with mono values. Lives inside {@link ActionCard}
|
||
* and is reused in the audit row drilldown, so it takes raw rows rather than a
|
||
* baked-in binding shape. `labelWidth` narrows to 70px on mobile (surface 99).
|
||
*/
|
||
export function BindingsTable({
|
||
rows,
|
||
labelWidth = 132,
|
||
className,
|
||
}: {
|
||
rows: BindingRow[];
|
||
labelWidth?: number;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<dl className={cn("divide-y divide-border rounded-md border border-border text-sm", className)}>
|
||
{rows.map((row) => (
|
||
<div key={row.label} className="flex gap-3 px-3 py-2">
|
||
<dt
|
||
className="shrink-0 pt-0.5 text-xs font-medium uppercase tracking-normal text-muted-foreground"
|
||
style={{ width: labelWidth }}
|
||
>
|
||
{row.label}
|
||
</dt>
|
||
<dd className={cn("min-w-0 flex-1 break-all text-foreground", row.mono && "font-mono text-xs")}>
|
||
{row.value}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
);
|
||
}
|
||
|
||
/** Truncate a sha to the standard `sha256:abcd…1234` review form. */
|
||
export function shortSha(sha: string): string {
|
||
const hex = sha.replace(/^sha256:/, "");
|
||
if (hex.length <= 16) return `sha256:${hex}`;
|
||
return `sha256:${hex.slice(0, 10)}…${hex.slice(-6)}`;
|
||
}
|
||
|
||
export interface ActionCardBinding {
|
||
/** Application the tool belongs to. */
|
||
application: string;
|
||
/** Manifest version the catalog was discovered at. */
|
||
manifestVersion: string;
|
||
/** Connection label (mono URL / command). */
|
||
connection: string;
|
||
/** Current catalog sha256 the gateway will enforce against. */
|
||
catalogSha256: string;
|
||
/** sha256 of the signed argument payload — never elided. */
|
||
payloadSha256: string;
|
||
/**
|
||
* Previous catalog sha256, only present on the stale variant. Rendered struck
|
||
* through next to {@link catalogSha256} so the mismatch is obvious.
|
||
*/
|
||
previousCatalogSha256?: string;
|
||
}
|
||
|
||
export interface ActionCardProps {
|
||
/** Requesting agent — defaults to "Coder" to match the spec copy. */
|
||
agentName?: string;
|
||
agentAvatarUrl?: string | null;
|
||
/** Tool the agent is asking to call, e.g. `slack.post_message`. */
|
||
toolName: string;
|
||
risk: ToolRiskLevel;
|
||
isReadOnly?: boolean;
|
||
isWrite?: boolean;
|
||
isDestructive?: boolean;
|
||
binding: ActionCardBinding;
|
||
/** Raw tool input, rendered as pretty JSON in a mono block. */
|
||
input: unknown;
|
||
/** Free-form "why I'm asking" explanation. */
|
||
reason: ReactNode;
|
||
/** Policy number the explanation references, e.g. `7` → "Policy #7". */
|
||
policyNumber?: number | string;
|
||
/** Footrow expiry copy, e.g. "expires in 23h 51m". */
|
||
expiresInLabel?: string;
|
||
variant?: ActionCardVariant;
|
||
/** Mobile (390×844) layout: stacked full-width buttons + 70px label column. */
|
||
mobile?: boolean;
|
||
onApprove?: () => void;
|
||
onDeny?: () => void;
|
||
onEditResign?: () => void;
|
||
className?: string;
|
||
}
|
||
|
||
function initials(name: string): string {
|
||
return name
|
||
.split(/\s+/)
|
||
.map((part) => part[0])
|
||
.filter(Boolean)
|
||
.slice(0, 2)
|
||
.join("")
|
||
.toUpperCase();
|
||
}
|
||
|
||
function bindingRows(binding: ActionCardBinding, isStale: boolean): BindingRow[] {
|
||
const catalogValue = isStale && binding.previousCatalogSha256 ? (
|
||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||
<span className="text-muted-foreground line-through decoration-amber-500" title="Previous catalog hash">
|
||
{shortSha(binding.previousCatalogSha256)}
|
||
</span>
|
||
<span className="text-amber-600 dark:text-amber-400" title="Current catalog hash">
|
||
{shortSha(binding.catalogSha256)}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
shortSha(binding.catalogSha256)
|
||
);
|
||
|
||
return [
|
||
{
|
||
label: "Application",
|
||
value: (
|
||
<span>
|
||
{binding.application}
|
||
<span className="ml-1.5 text-xs text-muted-foreground">manifest v{binding.manifestVersion}</span>
|
||
</span>
|
||
),
|
||
},
|
||
{ label: "Connection", value: binding.connection, mono: true },
|
||
{ label: "Catalog", value: catalogValue, mono: !isStale },
|
||
{
|
||
label: "Payload",
|
||
value: (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||
<span>{shortSha(binding.payloadSha256)}</span>
|
||
<span className="font-sans text-(length:--text-micro) uppercase tracking-normal text-muted-foreground">signed</span>
|
||
</span>
|
||
),
|
||
mono: true,
|
||
},
|
||
];
|
||
}
|
||
|
||
export function ActionCard({
|
||
agentName = "Coder",
|
||
agentAvatarUrl,
|
||
toolName,
|
||
risk,
|
||
isReadOnly,
|
||
isWrite,
|
||
isDestructive,
|
||
binding,
|
||
input,
|
||
reason,
|
||
policyNumber,
|
||
expiresInLabel,
|
||
variant = "pending",
|
||
mobile = false,
|
||
onApprove,
|
||
onDeny,
|
||
onEditResign,
|
||
className,
|
||
}: ActionCardProps) {
|
||
const isStale = variant === "stale";
|
||
const json = typeof input === "string" ? input : JSON.stringify(input, null, 2);
|
||
|
||
// Surface 99: buttons stack full-width in the order Approve / Deny /
|
||
// Edit & re-sign; desktop keeps them inline as Edit & re-sign / Deny / Approve.
|
||
const approveButton = (
|
||
<Button
|
||
size="sm"
|
||
onClick={onApprove}
|
||
disabled={isStale}
|
||
className={mobile ? "w-full" : undefined}
|
||
title={isStale ? "Re-issue the request before approving — the catalog hash changed." : undefined}
|
||
>
|
||
Approve
|
||
</Button>
|
||
);
|
||
const denyButton = (
|
||
<Button size="sm" variant="outline" onClick={onDeny} className={mobile ? "w-full" : undefined}>
|
||
Deny
|
||
</Button>
|
||
);
|
||
const editButton = (
|
||
<Button size="sm" variant="outline" onClick={onEditResign} className={mobile ? "w-full" : undefined}>
|
||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||
Edit & re-sign
|
||
</Button>
|
||
);
|
||
|
||
return (
|
||
<Card
|
||
className={cn(
|
||
"gap-0 py-0",
|
||
isStale && "border-amber-500/50 dark:border-amber-500/40",
|
||
className,
|
||
)}
|
||
data-variant={variant}
|
||
>
|
||
<CardContent className="space-y-3 p-4">
|
||
{/* Header: avatar + request line + outcome pill */}
|
||
<div className="flex items-start gap-3">
|
||
<Avatar size="sm" className="shrink-0">
|
||
{agentAvatarUrl ? <AvatarImage src={agentAvatarUrl} alt={agentName} /> : null}
|
||
<AvatarFallback>{initials(agentName)}</AvatarFallback>
|
||
</Avatar>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm text-foreground">
|
||
<span className="font-medium">{agentName}</span> requested approval to call
|
||
</p>
|
||
<p className="mt-0.5 font-mono text-xs text-muted-foreground break-all">{toolName}</p>
|
||
</div>
|
||
<div className="shrink-0">
|
||
<DecisionBadge decision="require_approval" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Body: tool name + risk / capability pills */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-mono text-sm text-foreground">{toolName}</span>
|
||
<RiskBadge risk={risk} />
|
||
<CapabilityBadges isReadOnly={isReadOnly} isWrite={isWrite} isDestructive={isDestructive} />
|
||
</div>
|
||
|
||
{/* Stale banner (PAP-10400 hardening) */}
|
||
{isStale ? (
|
||
<EnforcementBanner
|
||
tone="warning"
|
||
title="Catalog changed since this request was signed."
|
||
body="The application's tool catalog hash no longer matches the one this approval was issued against. Approval is disabled — the agent must edit & re-sign to request again."
|
||
/>
|
||
) : null}
|
||
|
||
{/* Bindings table */}
|
||
<BindingsTable rows={bindingRows(binding, isStale)} labelWidth={mobile ? 70 : 132} />
|
||
|
||
{/* JSON input */}
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Input</p>
|
||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed text-foreground">
|
||
{json}
|
||
</pre>
|
||
</div>
|
||
|
||
{/* Why I'm asking */}
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Why I'm asking</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{reason}
|
||
{policyNumber != null ? (
|
||
<>
|
||
{" "}
|
||
<span className="font-medium text-foreground">Policy #{policyNumber}</span> requires approval here.
|
||
</>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Footrow: expiry + actions */}
|
||
<div
|
||
className={cn(
|
||
"flex gap-2 border-t border-border p-4",
|
||
mobile ? "flex-col" : "flex-wrap items-center justify-between",
|
||
)}
|
||
>
|
||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||
{expiresInLabel ?? "no expiry set"}
|
||
</span>
|
||
{mobile ? (
|
||
<div className="flex flex-col gap-2">
|
||
{approveButton}
|
||
{denyButton}
|
||
{editButton}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
{editButton}
|
||
{denyButton}
|
||
{approveButton}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
/** Mobile (390×844) presentation of {@link ActionCard}. */
|
||
export function ActionCardMobile(props: Omit<ActionCardProps, "mobile">) {
|
||
return <ActionCard {...props} mobile />;
|
||
}
|