Rework secrets dialog and add in-sheet agent access (#9797)

## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents and the credentials those agents need for work.
> - The secrets UI is responsible for making credential creation
understandable and for showing where credentials are used.
> - The create flow previously exposed an editable generated key too
early, used an unnatural field order, and rendered uneven provider tab
rows.
> - The detail sheet also lacked an in-context way to see and manage
which agents reference a selected secret.
> - This pull request makes the create dialog follow a predictable
name-to-value flow and adds agent access management directly to the
secret details sheet.
> - The benefit is a clearer secrets workflow with fewer accidental key
edits and less navigation when granting or revoking agent access.

## Linked Issues or Issue Description

**Subsystem affected:** `ui/` — React + Vite board UI

**Problem or motivation:** Creating a secret currently makes its
generated key look immediately editable, places fields in an awkward
keyboard order, and applies provider-tab overrides that produce uneven
rows. After creation, operators cannot inspect or change agent access
from the selected secret's detail sheet.

**Proposed solution:** Generate the key from the path-style name and
keep it read-only until an explicit Edit action; place Value directly
after Name; use the standard tab sizing; and add an Agent access section
that reads and updates `secret_ref` / `user_secret_ref` environment
bindings through agent adapter configuration.

**Alternatives considered:** Keeping access management only on agent
configuration screens was rejected because it hides a secret-centric
question—“which agents can use this?”—and requires repetitive
navigation. Keeping the key always editable was rejected because the
generated value should be the safe default.

**Roadmap alignment:** No matching item was found in `ROADMAP.md`; this
is a focused usability and access-management improvement to the existing
secrets surface.

**Additional context:** GitHub search found no duplicate PR for this
dialog and in-sheet access change. PR #9321 also mentions user-secret
resolution but addresses unrelated skills-route behavior.

## What Changed

- Auto-generate the create-secret key from Name, keep it read-only by
default, and expose an explicit Edit action.
- Use a path-style Name placeholder (`/dev/foo/bar`) and place Value
immediately after Name for natural keyboard navigation.
- Remove tab sizing/whitespace overrides that caused uneven provider-tab
row heights.
- Add an Agent access section to the Details tab that lists referencing
agents and grants or revokes `secret_ref` / `user_secret_ref`
environment bindings in place.
- Cover company secrets and each-user definitions with focused render
tests.

## Verification

- `pnpm exec vitest run ui/src/pages/Secrets.render.test.tsx` — 17/17
passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm check:token-gates` — changed files are clean; the
repository-wide command currently reports five pre-existing `#9627`
violations in unrelated files (`Sidebar.tsx`, `Inbox.tsx`,
`IssueDetail.tsx`, `Issues.tsx`, and `Routines.tsx`).
- Additional secrets verification completed during implementation: 35/35
adjacent secrets tests passed, and the flow was checked in a real
browser in light and dark themes.

## Risks

- Agent access mutations update adapter environment configuration, so
malformed legacy env entries could affect how a binding is displayed or
revoked.
- Each-user definitions use `user_secret_ref` rather than `secret_ref`;
focused tests cover selecting the correct binding type.
- No database or API contract changes are included.

> 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

- Anthropic Claude Fable 5 assisted with the implementation using
repository-aware code editing and test execution.
- OpenAI GPT-5.5 via Codex CLI assisted with PR preparation, branch
hygiene, focused verification, GitHub operations, and review/check
loops.

## 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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-18 14:39:06 -05:00 committed by GitHub
parent 83765f08d1
commit 67616dd7ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 669 additions and 164 deletions

View File

@ -52,6 +52,12 @@ const mockSecretsApi = vi.hoisted(() => ({
removeMyUserSecret: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
update: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
@ -59,6 +65,10 @@ vi.mock("../api/secrets", () => ({
secretsApi: mockSecretsApi,
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
@ -346,6 +356,7 @@ describe("Secrets page layout", () => {
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockAgentsApi.list.mockResolvedValue([]);
});
afterEach(() => {
@ -673,6 +684,18 @@ describe("Secrets page layout", () => {
});
await flushReact();
const companyKeyInput = document.getElementById("new-secret-key") as HTMLInputElement;
expect(companyKeyInput.readOnly).toBe(true);
const editKeyButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Edit",
) as HTMLButtonElement | undefined;
await act(async () => {
editKeyButton?.click();
});
await flushReact();
expect(companyKeyInput.readOnly).toBe(false);
const eachUserButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Each user",
) as HTMLButtonElement | undefined;
@ -686,6 +709,12 @@ describe("Secrets page layout", () => {
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
const keyInput = document.getElementById("new-secret-key") as HTMLInputElement;
const usageGuidance = document.getElementById("new-secret-usage-guidance") as HTMLTextAreaElement;
expect(keyInput.readOnly).toBe(true);
expect(
Array.from(document.body.querySelectorAll("button")).some(
(button) => button.textContent?.trim() === "Edit",
),
).toBe(true);
expect(document.getElementById("new-secret-provider")).toBeNull();
expect(document.getElementById("new-secret-vault")).toBeNull();
expect(document.getElementById("new-secret-value")).toBeNull();
@ -1191,6 +1220,185 @@ describe("Secrets page layout", () => {
});
});
it("auto-generates the key from the name and keeps it read-only until Edit", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
) as HTMLButtonElement | undefined;
await act(async () => {
newSecretButton?.click();
});
await flushReact();
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
const keyInput = document.getElementById("new-secret-key") as HTMLInputElement;
const valueTextarea = document.getElementById("new-secret-value") as HTMLTextAreaElement;
// Path-style placeholder and value directly after name for natural tab order.
expect(nameInput.placeholder).toBe("/dev/foo/bar");
expect(
nameInput.compareDocumentPosition(valueTextarea) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
valueTextarea.compareDocumentPosition(keyInput) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(keyInput.readOnly).toBe(true);
await act(async () => {
setInputValue(nameInput, "OpenAI API Key");
});
await flushReact();
expect(keyInput.value).toBe("openai-api-key");
const editKeyButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Edit",
) as HTMLButtonElement | undefined;
expect(editKeyButton).toBeDefined();
await act(async () => {
editKeyButton?.click();
});
await flushReact();
expect((document.getElementById("new-secret-key") as HTMLInputElement).readOnly).toBe(false);
await act(async () => {
setInputValue(document.getElementById("new-secret-key") as HTMLInputElement, "custom-key");
setInputValue(nameInput, "OpenAI API Key v2");
});
await flushReact();
// Once edited, the key stops following the name.
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe("custom-key");
await act(async () => {
root.unmount();
});
});
it("grants and revokes agent access from the secret detail sheet", async () => {
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-openai", bindings: [] });
mockSecretsApi.accessEvents.mockResolvedValue([]);
const coder = {
id: "agent-coder",
name: "CodexCoder",
status: "active",
adapterConfig: {},
};
const reviewer = {
id: "agent-reviewer",
name: "Reviewer",
status: "active",
adapterConfig: {
env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai" } },
},
};
mockAgentsApi.list.mockResolvedValue([coder, reviewer]);
mockAgentsApi.get.mockImplementation(async (id: string) =>
id === "agent-coder" ? coder : reviewer,
);
mockAgentsApi.update.mockImplementation(async (id: string) =>
id === "agent-coder" ? coder : reviewer,
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const companyRow = Array.from(container.querySelectorAll("[role='row']")).find(
(row) => row.textContent?.includes("OPENAI_API_KEY"),
) as HTMLElement | undefined;
await act(async () => {
companyRow?.click();
});
await flushReact();
await flushReact();
// Existing access is listed right in the Details tab.
expect(document.body.textContent).toContain("Agent access");
expect(document.body.textContent).toContain("Reviewer");
const agentSelect = document.getElementById("agent-access-agent") as HTMLSelectElement;
const envKeyInput = document.getElementById("agent-access-env-key") as HTMLInputElement;
expect(envKeyInput.value).toBe("OPENAI_API_KEY");
// Agents that already have access are not offered again.
expect(Array.from(agentSelect.options).map((option) => option.textContent)).not.toContain("Reviewer");
await act(async () => {
setSelectValue(agentSelect, "agent-coder");
});
await flushReact();
const addButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Add",
) as HTMLButtonElement | undefined;
await act(async () => {
addButton?.click();
});
await flushReact();
await flushReact();
expect(mockAgentsApi.update).toHaveBeenCalledWith(
"agent-coder",
{
adapterConfig: {
env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai" } },
},
replaceAdapterConfig: true,
},
"company-1",
);
const revokeButton = document.body.querySelector(
'button[aria-label="Remove access for Reviewer"]',
) as HTMLButtonElement | null;
expect(revokeButton).not.toBeNull();
await act(async () => {
revokeButton?.click();
});
await flushReact();
await flushReact();
expect(mockAgentsApi.update).toHaveBeenCalledWith(
"agent-reviewer",
{ adapterConfig: { env: {} }, replaceAdapterConfig: true },
"company-1",
);
await act(async () => {
root.unmount();
});
});
it("shows an empty AWS discovery result without blocking manual entry", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(
makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }),

View File

@ -58,6 +58,8 @@ import {
} from "../api/secrets";
import { ApiError } from "../api/client";
import { accessApi, type CompanyUserDirectoryEntry } from "../api/access";
import { agentsApi } from "../api/agents";
import { envKeyFromSecretName } from "../components/environment-variables-editor/model";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { Button } from "@/components/ui/button";
@ -615,6 +617,7 @@ export function Secrets() {
const [createMode, setCreateMode] = useState<CreateMode>("managed");
const [editingDefinition, setEditingDefinition] = useState<UserSecretDefinition | null>(null);
const [createKeyDirty, setCreateKeyDirty] = useState(false);
const [createKeyEditable, setCreateKeyEditable] = useState(false);
const [createForm, setCreateForm] = useState({
name: "",
key: "",
@ -712,6 +715,14 @@ export function Secrets() {
() => userDefinitions.find((definition) => definition.id === selectedDefinitionId) ?? null,
[selectedDefinitionId, userDefinitions],
);
const selectedSecretAccessReference = useMemo<AgentAccessReference | null>(
() => selectedSecret ? { kind: "company", secret: selectedSecret } : null,
[selectedSecret],
);
const selectedDefinitionAccessReference = useMemo<AgentAccessReference | null>(
() => selectedDefinition ? { kind: "user", definition: selectedDefinition } : null,
[selectedDefinition],
);
const selectedDefinitionMyEntry = useMemo(() => {
if (!selectedDefinition) return null;
return myUserSecrets.find((entry) => entry.definition.id === selectedDefinition.id) ?? {
@ -845,6 +856,7 @@ export function Secrets() {
setSecretValueProvider("company");
setCreateMode("managed");
setCreateKeyDirty(false);
setCreateKeyEditable(false);
setCreateError(null);
setCreateForm({
name: "",
@ -864,6 +876,7 @@ export function Secrets() {
setSecretValueProvider("user");
setCreateMode("managed");
setCreateKeyDirty(true);
setCreateKeyEditable(false);
setCreateError(null);
setCreateForm({
name: definition.name,
@ -933,6 +946,7 @@ export function Secrets() {
setEditingDefinition(null);
setSecretValueProvider("company");
setCreateKeyDirty(false);
setCreateKeyEditable(false);
setCreateForm({
name: "",
key: "",
@ -1835,12 +1849,18 @@ export function Secrets() {
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<TabsContent value="details">
<SecretDetailsTab
secret={selectedSecret}
providers={providers}
providerConfigs={providerConfigs}
onViewUsage={() => setSecretDetailTab("usage")}
/>
<div className="space-y-3">
<AgentAccessSection
companyId={selectedCompanyId}
reference={selectedSecretAccessReference!}
/>
<SecretDetailsTab
secret={selectedSecret}
providers={providers}
providerConfigs={providerConfigs}
onViewUsage={() => setSecretDetailTab("usage")}
/>
</div>
</TabsContent>
<TabsContent value="usage">
<SecretUsageTab loading={usageQuery.isPending} bindings={usageQuery.data?.bindings ?? []} />
@ -1968,11 +1988,17 @@ export function Secrets() {
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<TabsContent value="details">
<UserSecretDetailsTab
companyId={selectedCompanyId}
definition={selectedDefinition}
onViewCoverage={() => setSecretDetailTab("coverage")}
/>
<div className="space-y-3">
<AgentAccessSection
companyId={selectedCompanyId}
reference={selectedDefinitionAccessReference!}
/>
<UserSecretDetailsTab
companyId={selectedCompanyId}
definition={selectedDefinition}
onViewCoverage={() => setSecretDetailTab("coverage")}
/>
</div>
</TabsContent>
<TabsContent value="coverage">
<UserSecretCoverageTab
@ -2045,53 +2071,167 @@ export function Secrets() {
Choose who provides the value. Shared fields keep their values when you switch modes.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="text-xs font-medium" htmlFor="new-secret-name">Name</label>
<Input
id="new-secret-name"
value={createForm.name}
onChange={(event) => {
const name = event.target.value;
<div className="space-y-4">
{!editingDefinition ? (
<div className="space-y-1.5">
<p className="text-xs font-medium text-foreground">Who provides the value?</p>
<Tabs
value={secretValueProvider}
onValueChange={(value) => {
const next = value as SecretValueProvider;
setSecretValueProvider(next);
setCreateKeyEditable(false);
setCreateForm((current) => ({
...current,
name,
key: createKeyDirty
? current.key
: secretValueProvider === "user"
? normalizeUserSecretKeyForPreview(name)
: normalizeSecretKeyForPreview(name),
: next === "user"
? normalizeUserSecretKeyForPreview(current.name)
: normalizeSecretKeyForPreview(current.name),
}));
}}
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "OPENAI_API_KEY"}
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-key">
Key {secretValueProvider === "company" ? <span className="text-muted-foreground/70">(optional)</span> : null}
</label>
<Input
id="new-secret-key"
value={createForm.key}
onChange={(event) => {
setCreateKeyDirty(true);
setCreateForm((current) => ({ ...current, key: event.target.value }));
}}
placeholder={secretValueProvider === "user" ? "PERSONAL_GH_TOKEN" : "auto from name"}
disabled={Boolean(editingDefinition)}
className={secretValueProvider === "user" ? "font-mono text-sm" : undefined}
/>
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
{secretValueProvider === "user"
? editingDefinition
? "Stable env binding key. Cannot be changed."
: "Env-style key used by user-secret bindings."
: "Shared secret keys keep lowercase dash normalization."}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="company">Company</TabsTrigger>
<TabsTrigger value="user">Each user</TabsTrigger>
</TabsList>
</Tabs>
<p className="text-(length:--text-micro) text-muted-foreground">
Company stores one shared value. Each user lets every member supply their own value under My secrets.
</p>
</div>
) : null}
{secretValueProvider === "company" && !editingDefinition ? (
<Tabs value={createMode} onValueChange={(value) => setCreateMode(value as CreateMode)}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="managed">Managed value</TabsTrigger>
<TabsTrigger value="external">External reference</TabsTrigger>
</TabsList>
</Tabs>
) : null}
<div>
<label className="text-xs font-medium" htmlFor="new-secret-name">Name</label>
<Input
id="new-secret-name"
value={createForm.name}
onChange={(event) => {
const name = event.target.value;
setCreateForm((current) => ({
...current,
name,
key: createKeyDirty
? current.key
: secretValueProvider === "user"
? normalizeUserSecretKeyForPreview(name)
: normalizeSecretKeyForPreview(name),
}));
}}
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "/dev/foo/bar"}
autoFocus
/>
</div>
{secretValueProvider === "company" && createMode === "managed" ? (
<div>
<label className="text-xs font-medium" htmlFor="new-secret-value">Value</label>
<Textarea
id="new-secret-value"
value={createForm.value}
onChange={(event) =>
setCreateForm((current) => ({ ...current, value: event.target.value }))
}
rows={3}
className="min-w-0 overflow-x-hidden break-all font-mono text-xs"
placeholder="Stored once, never re-displayed"
/>
</div>
) : null}
{secretValueProvider === "company" && createMode === "external" ? (
<div>
<label className="text-xs font-medium" htmlFor="new-secret-ref">External reference</label>
<Input
id="new-secret-ref"
value={createForm.externalRef}
onChange={(event) =>
setCreateForm((current) => ({ ...current, externalRef: event.target.value }))
}
placeholder="arn:aws:secretsmanager:..."
className="font-mono text-xs"
/>
<p className="text-(length:--text-micro) text-muted-foreground mt-1">
Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider,
then update this reference only if the path, ARN, or version changes.
</p>
</div>
) : null}
{secretValueProvider === "user" ? (
<>
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 p-2 text-(length:--text-micro) text-violet-800 dark:text-violet-200">
Every member supplies their own value under My secrets. Agents resolve the responsible
user&apos;s value at runtime.
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-foreground" htmlFor="new-secret-usage-guidance">
Usage guidance <span className="text-muted-foreground/70">(optional)</span>
</label>
<Textarea
id="new-secret-usage-guidance"
value={createForm.usageGuidance}
onChange={(event) =>
setCreateForm((current) => ({ ...current, usageGuidance: event.target.value }))
}
placeholder="Tell members how to create their token, required scopes, etc."
className="min-h-(--sz-70px) text-sm"
/>
</div>
</>
) : null}
<div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium" htmlFor="new-secret-key">Key</label>
{!createKeyEditable && !editingDefinition ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 px-1.5 text-(length:--text-micro) text-muted-foreground"
onClick={() => setCreateKeyEditable(true)}
>
<Pencil className="mr-1 h-3 w-3" /> Edit
</Button>
) : null}
</div>
<Input
id="new-secret-key"
value={createForm.key}
readOnly={!createKeyEditable}
tabIndex={createKeyEditable && !editingDefinition ? undefined : -1}
onChange={(event) => {
if (!createKeyEditable || editingDefinition) return;
setCreateKeyDirty(true);
setCreateForm((current) => ({ ...current, key: event.target.value }));
}}
placeholder={secretValueProvider === "user" ? "PERSONAL_GH_TOKEN" : "auto from name"}
disabled={Boolean(editingDefinition)}
className={cn(
"font-mono text-sm",
!createKeyEditable && !editingDefinition && "border-dashed bg-muted/40 text-muted-foreground",
)}
/>
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
{editingDefinition
? "Stable env binding key. Cannot be changed."
: !createKeyEditable
? "Generated from the name."
: secretValueProvider === "user"
? "Env-style key used by user-secret bindings."
: "Shared secret keys keep lowercase dash normalization."}
</p>
</div>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-description">
Description <span className="text-muted-foreground/70">(optional)</span>
@ -2106,53 +2246,9 @@ export function Secrets() {
/>
</div>
{!editingDefinition ? (
<div className="space-y-1">
<p className="text-xs font-medium text-foreground">Who provides the value?</p>
<Tabs
value={secretValueProvider}
onValueChange={(value) => {
const next = value as SecretValueProvider;
setSecretValueProvider(next);
setCreateForm((current) => ({
...current,
key: createKeyDirty
? current.key
: next === "user"
? normalizeUserSecretKeyForPreview(current.name)
: normalizeSecretKeyForPreview(current.name),
}));
}}
>
<TabsList className="grid h-auto w-full grid-cols-2">
<TabsTrigger value="company">Company</TabsTrigger>
<TabsTrigger value="user">Each user</TabsTrigger>
</TabsList>
</Tabs>
<p className="text-(length:--text-micro) text-muted-foreground">
Company stores one shared value. Each user lets every member supply their own value under My secrets.
</p>
</div>
) : null}
{secretValueProvider === "company" ? (
<>
<Tabs value={createMode} onValueChange={(value) => setCreateMode(value as CreateMode)}>
<TabsList className="grid h-auto w-full grid-cols-2">
<TabsTrigger
value="managed"
className="min-h-9 whitespace-normal px-1.5 text-center text-xs leading-tight sm:text-sm"
>
Managed value
</TabsTrigger>
<TabsTrigger
value="external"
className="min-h-9 whitespace-normal px-1.5 text-center text-xs leading-tight sm:text-sm"
>
External reference
</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="text-xs font-medium" htmlFor="new-secret-provider">Provider</label>
<select
@ -2226,81 +2322,25 @@ export function Secrets() {
</select>
{selectedCreateProviderConfig ? (
<ProviderVaultInlineWarning config={selectedCreateProviderConfig} />
) : (
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
Existing deployment-level provider settings stay available for backwards compatibility.
</p>
)}
</div>
{createMode === "managed" ? (
<>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2 text-(length:--text-micro) text-emerald-700 dark:text-emerald-300">
Paperclip-managed secrets are created in the selected provider and future rotations
write a new provider version through Paperclip.
{awsManagedPathPreview ? (
<div className="mt-1">
AWS managed path:{" "}
<code className="break-all rounded bg-background/70 px-1 py-0.5">
{awsManagedPathPreview}
</code>
</div>
) : null}
</div>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-value">Value</label>
<Textarea
id="new-secret-value"
value={createForm.value}
onChange={(event) =>
setCreateForm((current) => ({ ...current, value: event.target.value }))
}
rows={3}
className="min-w-0 overflow-x-hidden break-all font-mono text-xs"
placeholder="Stored once, never re-displayed"
/>
</div>
</>
) : (
<div>
<label className="text-xs font-medium" htmlFor="new-secret-ref">External reference</label>
<Input
id="new-secret-ref"
value={createForm.externalRef}
onChange={(event) =>
setCreateForm((current) => ({ ...current, externalRef: event.target.value }))
}
placeholder="arn:aws:secretsmanager:..."
className="font-mono text-xs"
/>
<p className="text-(length:--text-micro) text-muted-foreground mt-1">
Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider,
then update this reference only if the path, ARN, or version changes.
</p>
{createMode === "managed" ? (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2 text-(length:--text-micro) text-emerald-700 dark:text-emerald-300">
Paperclip-managed secrets are created in the selected provider and future rotations
write a new provider version through Paperclip.
{awsManagedPathPreview ? (
<div className="mt-1">
AWS managed path:{" "}
<code className="break-all rounded bg-background/70 px-1 py-0.5">
{awsManagedPathPreview}
</code>
</div>
) : null}
</div>
)}
) : null}
</>
) : (
<>
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 p-2 text-(length:--text-micro) text-violet-800 dark:text-violet-200">
Every member supplies their own value under My secrets. Agents resolve the responsible
user&apos;s value at runtime.
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-foreground" htmlFor="new-secret-usage-guidance">
Usage guidance <span className="text-muted-foreground/70">(optional)</span>
</label>
<Textarea
id="new-secret-usage-guidance"
value={createForm.usageGuidance}
onChange={(event) =>
setCreateForm((current) => ({ ...current, usageGuidance: event.target.value }))
}
placeholder="Tell members how to create their token, required scopes, etc."
className="min-h-(--sz-70px) text-sm"
/>
</div>
</>
)}
) : null}
{createError ? (
<SecretCreateError
error={createError}
@ -3719,6 +3759,263 @@ function UserSecretAccessEventsTab() {
);
}
type AgentAccessReference =
| { kind: "company"; secret: CompanySecret }
| { kind: "user"; definition: UserSecretDefinition };
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** Env keys in an agent's env config that resolve to this secret/definition. */
function envKeysReferencingSecret(env: unknown, reference: AgentAccessReference): string[] {
if (typeof env !== "object" || env === null || Array.isArray(env)) return [];
return Object.entries(env as Record<string, unknown>)
.filter(([, binding]) => {
if (typeof binding !== "object" || binding === null) return false;
const record = binding as Record<string, unknown>;
return reference.kind === "company"
? record.type === "secret_ref" && record.secretId === reference.secret.id
: record.type === "user_secret_ref" && record.key === reference.definition.key;
})
.map(([key]) => key)
.sort();
}
function AgentAccessSection({
companyId,
reference,
}: {
companyId: string;
reference: AgentAccessReference;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [selectedAgentId, setSelectedAgentId] = useState("");
const [envKey, setEnvKey] = useState("");
const [envKeyDirty, setEnvKeyDirty] = useState(false);
const [accessError, setAccessError] = useState<string | null>(null);
const referenceId = reference.kind === "company" ? reference.secret.id : reference.definition.id;
const referenceName = reference.kind === "company" ? reference.secret.name : reference.definition.name;
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
staleTime: 30_000,
});
const agents = useMemo(
() => (agentsQuery.data ?? []).filter((agent) => agent.status !== "terminated"),
[agentsQuery.data],
);
const agentAccess = useMemo(
() =>
agents
.map((agent) => ({
agent,
envKeys: envKeysReferencingSecret(
(agent.adapterConfig as Record<string, unknown> | null)?.env,
reference,
),
}))
.filter((entry) => entry.envKeys.length > 0),
[agents, reference],
);
const grantableAgents = useMemo(
() => agents.filter((agent) => !agentAccess.some((entry) => entry.agent.id === agent.id)),
[agents, agentAccess],
);
const effectiveEnvKey = envKeyDirty
? envKey
: reference.kind === "user"
? reference.definition.key
: envKeyFromSecretName(referenceName);
useEffect(() => {
setSelectedAgentId("");
setEnvKey("");
setEnvKeyDirty(false);
setAccessError(null);
}, [referenceId]);
function invalidateAfterChange(agentId: string) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
if (reference.kind === "company") {
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.usage(reference.secret.id) });
}
}
const grantMutation = useMutation({
mutationFn: async ({ agentId, key }: { agentId: string; key: string }) => {
// Re-fetch right before patching so we merge into the freshest env config.
const detail = await agentsApi.get(agentId, companyId);
const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record<string, unknown>) };
const env = { ...((adapterConfig.env ?? {}) as Record<string, unknown>) };
if (env[key] !== undefined) {
throw new Error(`${detail.name} already has an env var named ${key}.`);
}
env[key] =
reference.kind === "company"
? { type: "secret_ref", secretId: reference.secret.id }
: { type: "user_secret_ref", key: reference.definition.key };
return agentsApi.update(
agentId,
{ adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true },
companyId,
);
},
onSuccess: (agent, variables) => {
setSelectedAgentId("");
setEnvKey("");
setEnvKeyDirty(false);
setAccessError(null);
invalidateAfterChange(variables.agentId);
pushToast({ title: "Access granted", body: `${agent.name} now receives ${variables.key}`, tone: "success" });
},
onError: (error) => setAccessError(readableErrorMessage(error)),
});
const revokeMutation = useMutation({
mutationFn: async ({ agentId }: { agentId: string }) => {
const detail = await agentsApi.get(agentId, companyId);
const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record<string, unknown>) };
const env = { ...((adapterConfig.env ?? {}) as Record<string, unknown>) };
const keys = envKeysReferencingSecret(env, reference);
if (keys.length === 0) return detail;
for (const key of keys) delete env[key];
return agentsApi.update(
agentId,
{ adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true },
companyId,
);
},
onSuccess: (agent, variables) => {
setAccessError(null);
invalidateAfterChange(variables.agentId);
pushToast({ title: "Access removed", body: agent.name, tone: "info" });
},
onError: (error) => setAccessError(readableErrorMessage(error)),
});
const envKeyValid = ENV_KEY_PATTERN.test(effectiveEnvKey);
const canGrant = Boolean(selectedAgentId) && envKeyValid && !grantMutation.isPending;
return (
<section className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<h3 className="text-xs font-medium text-foreground">Agent access</h3>
</div>
<p className="mt-0.5 text-(length:--text-micro) text-muted-foreground">
{reference.kind === "company"
? "These agents receive this secret as an environment variable at run start."
: "These agents resolve the responsible user's value as an environment variable at run start."}
</p>
{agentsQuery.isPending ? (
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">Loading agents</p>
) : agentsQuery.isError ? (
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">
Agent list unavailable. Manage access from each agent&apos;s configuration instead.
</p>
) : (
<>
{agentAccess.length > 0 ? (
<ul className="mt-2 space-y-1">
{agentAccess.map(({ agent, envKeys }) => (
<li
key={agent.id}
className="flex items-center gap-2 rounded border border-border/60 bg-background px-2 py-1"
>
<span className="min-w-0 flex-1 truncate text-xs font-medium">{agent.name}</span>
<code className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
{envKeys.join(", ")}
</code>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 shrink-0 p-0 text-muted-foreground"
aria-label={`Remove access for ${agent.name}`}
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate({ agentId: agent.id })}
>
<X className="h-3 w-3" />
</Button>
</li>
))}
</ul>
) : (
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">No agents have access yet.</p>
)}
<div className="mt-2 flex items-end gap-2">
<div className="min-w-0 flex-1">
<label
className="text-(length:--text-micro) font-medium text-muted-foreground"
htmlFor="agent-access-agent"
>
Agent
</label>
<select
id="agent-access-agent"
className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs outline-none"
value={selectedAgentId}
onChange={(event) => setSelectedAgentId(event.target.value)}
>
<option value="">Select agent</option>
{grantableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
<div className="min-w-0 flex-1">
<label
className="text-(length:--text-micro) font-medium text-muted-foreground"
htmlFor="agent-access-env-key"
>
Env var
</label>
<Input
id="agent-access-env-key"
value={effectiveEnvKey}
onChange={(event) => {
setEnvKeyDirty(true);
setEnvKey(event.target.value.toUpperCase());
}}
className="h-8 font-mono text-xs"
placeholder="MY_SECRET"
/>
</div>
<Button
type="button"
size="sm"
className="h-8 shrink-0"
disabled={!canGrant}
onClick={() => grantMutation.mutate({ agentId: selectedAgentId, key: effectiveEnvKey })}
>
{grantMutation.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1 h-3.5 w-3.5" />
)}
Add
</Button>
</div>
{effectiveEnvKey && !envKeyValid ? (
<p className="mt-1 text-(length:--text-micro) text-destructive">
Env keys use letters, digits, and underscores, and cannot start with a digit.
</p>
) : null}
{accessError ? (
<p className="mt-1 text-(length:--text-micro) text-destructive">{accessError}</p>
) : null}
</>
)}
</section>
);
}
function SecretDetailsTab({
secret,
providers,