From 5c259a94704da3a95795b6bd8d89b2d6df85fa51 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 19 Feb 2026 15:44:05 -0600 Subject: [PATCH] UI: secrets-aware env editor, issue hiding, and adapter env bindings Replace plain text env var editor with structured EnvVarEditor supporting plain values and secret references with inline "Seal" to convert a value to a managed secret. Add issue hide action via popover menu on issue detail. Adapters now emit envBindings with typed plain/secret_ref entries instead of raw KEY=VALUE strings. Co-Authored-By: Claude Opus 4.6 --- .../claude-local/src/ui/build-config.ts | 36 ++- .../codex-local/src/ui/build-config.ts | 36 ++- ui/src/api/secrets.ts | 25 ++ ui/src/components/AgentConfigForm.tsx | 269 +++++++++++++++--- ui/src/components/agent-config-primitives.tsx | 2 +- ui/src/lib/queryKeys.ts | 4 + ui/src/pages/AgentDetail.tsx | 8 + ui/src/pages/IssueDetail.tsx | 33 ++- 8 files changed, 365 insertions(+), 48 deletions(-) create mode 100644 ui/src/api/secrets.ts diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index ca5bf199..96604e59 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -22,6 +22,34 @@ function parseEnvVars(text: string): Record { return env; } +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + export function buildClaudeLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; @@ -30,7 +58,13 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.maxTurnsPerRun = v.maxTurnsPerRun; ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index fbbdb7f6..b2690eab 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -22,6 +22,34 @@ function parseEnvVars(text: string): Record { return env; } +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + export function buildCodexLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; @@ -30,7 +58,13 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.search = v.search; ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox; diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts new file mode 100644 index 00000000..b782687f --- /dev/null +++ b/ui/src/api/secrets.ts @@ -0,0 +1,25 @@ +import type { CompanySecret, SecretProviderDescriptor, SecretProvider } from "@paperclip/shared"; +import { api } from "./client"; + +export const secretsApi = { + list: (companyId: string) => api.get(`/companies/${companyId}/secrets`), + providers: (companyId: string) => + api.get(`/companies/${companyId}/secret-providers`), + create: ( + companyId: string, + data: { + name: string; + value: string; + provider?: SecretProvider; + description?: string | null; + externalRef?: string | null; + }, + ) => api.post(`/companies/${companyId}/secrets`, data), + rotate: (id: string, data: { value: string; externalRef?: string | null }) => + api.post(`/secrets/${id}/rotate`, data), + update: ( + id: string, + data: { name?: string; description?: string | null; externalRef?: string | null }, + ) => api.patch(`/secrets/${id}`, data), + remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`), +}; diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c54ccb34..a31620b4 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclip/shared"; -import type { Agent } from "@paperclip/shared"; +import type { Agent, CompanySecret, EnvBinding } from "@paperclip/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; +import { secretsApi } from "../api/secrets"; import { Popover, PopoverContent, @@ -12,6 +13,8 @@ import { import { Button } from "@/components/ui/button"; import { FolderOpen, Heart, ChevronDown, X } from "lucide-react"; import { cn } from "../lib/utils"; +import { queryKeys } from "../lib/queryKeys"; +import { useCompany } from "../context/CompanyContext"; import { Field, ToggleField, @@ -46,6 +49,7 @@ export const defaultCreateValues: CreateConfigValues = { args: "", extraArgs: "", envVars: "", + envBindings: {}, url: "", bootstrapPrompt: "", maxTurnsPerRun: 80, @@ -134,6 +138,25 @@ function extractPickedDirectoryPath(handle: unknown): string | null { export function AgentConfigForm(props: AgentConfigFormProps) { const { mode, adapterModels: externalModels } = props; const isCreate = mode === "create"; + const { selectedCompanyId } = useCompany(); + const queryClient = useQueryClient(); + + const { data: availableSecrets = [] } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], + queryFn: () => secretsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + const createSecret = useMutation({ + mutationFn: (input: { name: string; value: string }) => { + if (!selectedCompanyId) throw new Error("Select a company to create secrets"); + return secretsApi.create(selectedCompanyId, input); + }, + onSuccess: () => { + if (!selectedCompanyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); + }, + }); // ---- Edit mode: overlay for dirty tracking ---- const [overlay, setOverlay] = useState(emptyOverlay); @@ -510,19 +533,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) { - {isCreate ? ( - set!({ envVars: v })} - minRows={3} - /> - ) : ( - )} - onChange={(env) => mark("adapterConfig", "env", env)} - /> - )} + ) + : ((eff("adapterConfig", "env", config.env ?? {}) as Record) + ) + } + secrets={availableSecrets} + onCreateSecret={async (name, value) => { + const created = await createSecret.mutateAsync({ name, value }); + return created; + }} + onChange={(env) => + isCreate + ? set!({ envBindings: env ?? {}, envVars: "" }) + : mark("adapterConfig", "env", env) + } + /> {/* Edit-only: timeout + grace period */} @@ -697,20 +725,75 @@ function AdapterTypeDropdown({ function EnvVarEditor({ value, + secrets, + onCreateSecret, onChange, }: { - value: Record; - onChange: (env: Record | undefined) => void; + value: Record; + secrets: CompanySecret[]; + onCreateSecret: (name: string, value: string) => Promise; + onChange: (env: Record | undefined) => void; }) { - type Row = { key: string; value: string }; + type Row = { + key: string; + source: "plain" | "secret"; + plainValue: string; + secretId: string; + }; - function toRows(rec: Record | null | undefined): Row[] { - if (!rec || typeof rec !== "object") return [{ key: "", value: "" }]; - const entries = Object.entries(rec).map(([k, v]) => ({ key: k, value: String(v) })); - return [...entries, { key: "", value: "" }]; + function toRows(rec: Record | null | undefined): Row[] { + if (!rec || typeof rec !== "object") { + return [{ key: "", source: "plain", plainValue: "", secretId: "" }]; + } + const entries = Object.entries(rec).map(([k, binding]) => { + if (typeof binding === "string") { + return { + key: k, + source: "plain" as const, + plainValue: binding, + secretId: "", + }; + } + if ( + typeof binding === "object" && + binding !== null && + "type" in binding && + (binding as { type?: unknown }).type === "secret_ref" + ) { + const recBinding = binding as { secretId?: unknown }; + return { + key: k, + source: "secret" as const, + plainValue: "", + secretId: typeof recBinding.secretId === "string" ? recBinding.secretId : "", + }; + } + if ( + typeof binding === "object" && + binding !== null && + "type" in binding && + (binding as { type?: unknown }).type === "plain" + ) { + const recBinding = binding as { value?: unknown }; + return { + key: k, + source: "plain" as const, + plainValue: typeof recBinding.value === "string" ? recBinding.value : "", + secretId: "", + }; + } + return { + key: k, + source: "plain" as const, + plainValue: "", + secretId: "", + }; + }); + return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }]; } const [rows, setRows] = useState(() => toRows(value)); + const [sealError, setSealError] = useState(null); const valueRef = useRef(value); // Sync when value identity changes (overlay reset after save) @@ -722,50 +805,151 @@ function EnvVarEditor({ }, [value]); function emit(nextRows: Row[]) { - const rec: Record = {}; + const rec: Record = {}; for (const row of nextRows) { const k = row.key.trim(); - if (k) rec[k] = row.value; + if (!k) continue; + if (row.source === "secret") { + if (!row.secretId) continue; + rec[k] = { type: "secret_ref", secretId: row.secretId, version: "latest" }; + } else { + rec[k] = { type: "plain", value: row.plainValue }; + } } onChange(Object.keys(rec).length > 0 ? rec : undefined); } - function updateRow(i: number, field: "key" | "value", v: string) { - const next = rows.map((r, idx) => (idx === i ? { ...r, [field]: v } : r)); - if (next[next.length - 1].key || next[next.length - 1].value) { - next.push({ key: "", value: "" }); + function updateRow(i: number, patch: Partial) { + const withPatch = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)); + if ( + withPatch[withPatch.length - 1].key || + withPatch[withPatch.length - 1].plainValue || + withPatch[withPatch.length - 1].secretId + ) { + withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" }); + } + setRows(withPatch); + emit(withPatch); + } + + function removeRow(i: number) { + const next = rows.filter((_, idx) => idx !== i); + if ( + next.length === 0 || + next[next.length - 1].key || + next[next.length - 1].plainValue || + next[next.length - 1].secretId + ) { + next.push({ key: "", source: "plain", plainValue: "", secretId: "" }); } setRows(next); emit(next); } - function removeRow(i: number) { - const next = rows.filter((_, idx) => idx !== i); - if (next.length === 0 || next[next.length - 1].key || next[next.length - 1].value) { - next.push({ key: "", value: "" }); + function defaultSecretName(key: string): string { + return key + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 64); + } + + async function sealRow(i: number) { + const row = rows[i]; + if (!row) return; + const key = row.key.trim(); + const plain = row.plainValue; + if (!key || plain.length === 0) return; + + const suggested = defaultSecretName(key) || "secret"; + const name = window.prompt("Secret name", suggested)?.trim(); + if (!name) return; + + try { + setSealError(null); + const created = await onCreateSecret(name, plain); + updateRow(i, { + source: "secret", + secretId: created.id, + }); + } catch (err) { + setSealError(err instanceof Error ? err.message : "Failed to create secret"); } - setRows(next); - emit(next); } return (
{rows.map((row, i) => { - const isTrailing = i === rows.length - 1 && !row.key && !row.value; + const isTrailing = + i === rows.length - 1 && + !row.key && + !row.plainValue && + !row.secretId; return (
updateRow(i, "key", e.target.value)} - /> - updateRow(i, "value", e.target.value)} + onChange={(e) => updateRow(i, { key: e.target.value })} /> + + {row.source === "secret" ? ( + <> + + + + ) : ( + <> + updateRow(i, { plainValue: e.target.value })} + /> + + + )} {!isTrailing ? ( + + + + +