Wire P3 agent-config section upgrades (PAP-14944)

Frontend-only section upgrades inside the P1 configuration shell:

Environment: consolidated "Execution environment" field with explicit
inherit/override ("Inherit from company — Local (default)"); env editor rows
gain a per-row SOURCE badge (plain / secret / user / locked) and render
reserved PAPERCLIP_* keys as locked rows instead of silently stripping them
(PAP-14732).

Schedule & Runs: humanized "Every [N] [unit]" cadence with a live
"≈ 288 runs/day" preview (still stored as seconds); wake-on-demand,
max-concurrency and cooldown are now first-class (the Advanced Run Policy
collapse is dissolved); continuation stays a distinct card with gated inputs.

Danger & Legacy: skip-permissions / bypass-sandbox toggles now require a
confirm-on-enable consequence dialog; the Legacy card renders only when a
deprecated field still carries a value, each row paired with a migration
action ("Clear — use workspaces", "Move to instructions…").

Access & Governance: trust preset is now radio cards with plain-language
consequences; derived grants ("Can assign tasks") show a lock icon.

Consequence/trust copy is taken verbatim from the wireframes and centralized
for SecurityEngineer sign-off (PAP-14947).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
committed 2026-07-22 19:30:26 +00:00
1 parent ef6e897ada
commit bae9e01f4b
10 files changed
+373 -179

No files matched your search

@@ -7,6 +7,7 @@ import {
help,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { DangerToggleField } from "../../components/DangerToggleField";
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
const inputClass =
@@ -30,9 +31,13 @@ export function ClaudeLocalConfigFields({
}: AdapterConfigFieldsProps) {
if (configurationSection === "danger") {
return (
<ToggleField
label="Skip permissions"
hint={help.dangerouslySkipPermissions}
<DangerToggleField
label="Skip permission prompts"
description="Runs unattended by auto-approving every adapter permission prompt. The agent can take any action its environment allows without asking."
toggleTestId="danger-skip-permissions"
confirmTitle="Skip permission prompts?"
confirmBody="Runs unattended by auto-approving every adapter permission prompt. The agent can take any action its environment allows without asking."
confirmActionLabel="Skip permissions"
checked={
isCreate
? values!.dangerouslySkipPermissions
@@ -7,6 +7,7 @@ import {
help,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { DangerToggleField } from "../../components/DangerToggleField";
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
import {
CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS,
@@ -56,9 +57,13 @@ export function CodexLocalConfigFields({
if (configurationSection === "danger") {
return (
<ToggleField
label="Bypass sandbox"
hint={help.dangerouslyBypassSandbox}
<DangerToggleField
label="Bypass sandbox (Codex)"
description="Removes filesystem/network restrictions."
toggleTestId="danger-bypass-sandbox"
confirmTitle="Bypass sandbox?"
confirmBody="Removes filesystem/network restrictions. Codex runs can read, write, and reach the network without sandbox containment."
confirmActionLabel="Bypass sandbox"
checked={
isCreate
? values!.dangerouslyBypassSandbox
@@ -1,11 +1,10 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
ToggleField,
DraftInput,
help,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { DangerToggleField } from "../../components/DangerToggleField";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@@ -24,9 +23,13 @@ export function OpenCodeLocalConfigFields({
}: AdapterConfigFieldsProps) {
if (configurationSection === "danger") {
return (
<ToggleField
label="Skip permissions"
hint={help.dangerouslySkipPermissions}
<DangerToggleField
label="Skip permission prompts"
description="Runs unattended by auto-approving every adapter permission prompt. The agent can take any action its environment allows without asking."
toggleTestId="danger-skip-permissions"
confirmTitle="Skip permission prompts?"
confirmBody="Runs unattended by auto-approving every adapter permission prompt. The agent can take any action its environment allows without asking."
confirmActionLabel="Skip permissions"
checked={
isCreate
? values!.dangerouslySkipPermissions
+241 -152
View File
@@ -25,7 +25,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
import { FolderOpen, Heart, ChevronDown, X, AlertTriangle } from "lucide-react";
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
import { extractModelName, extractProviderId } from "../lib/model-utils";
import { queryKeys } from "../lib/queryKeys";
@@ -40,6 +40,7 @@ import {
help,
adapterLabels,
} from "./agent-config-primitives";
import { CadenceField } from "./CadenceField";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters";
@@ -54,7 +55,7 @@ import {
} from "./environment-variables-editor";
import { AgentSecretAccessEditor } from "./AgentSecretAccessEditor";
import { AGENT_ACCESS_CONFIG_PATH_PREFIX } from "../lib/secret-delivery";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { hasLegacyWorkingDirectory, shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
@@ -586,8 +587,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
hideInstructionsFile,
};
// Section toggle state — advanced always starts collapsed
const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false);
// Popover states
const [modelOpen, setModelOpen] = useState(false);
const [cheapModelOpen, setCheapModelOpen] = useState(false);
@@ -1005,6 +1004,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const hasAdapterDangerFields =
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local";
// Legacy card visibility — derived from the *effective* (draft-aware) values
// so clearing a deprecated field hides the card immediately, and a clean
// agent never renders it at all (wireframe 07).
const legacyCwdValue = !isCreate && isLocal ? eff("adapterConfig", "cwd", String(config.cwd ?? "")) : "";
const legacyBootstrapValue = !isCreate
? eff("adapterConfig", "bootstrapPromptTemplate", String(config.bootstrapPromptTemplate ?? ""))
: "";
const hasLegacyCwdValue = hasLegacyWorkingDirectory(legacyCwdValue);
const hasLegacyBootstrapValue = legacyBootstrapValue.trim().length > 0;
const showLegacyCard = hasLegacyCwdValue || hasLegacyBootstrapValue;
function updateMaxTurnContinuation(patch: Record<string, unknown>) {
mark("heartbeat", "maxTurnContinuation", {
...maxTurnContinuation,
@@ -1482,11 +1492,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
)}
{/* ---- Execution ---- */}
{configurationShell && sectionVisible("environment") ? (
<div id="config-environment" className="scroll-mt-24" />
) : null}
{sectionVisible("environment") && (forcedKubernetes ? (
{/* ---- Execution environment (non-shell / create) ---- */}
{!configurationShell && sectionVisible("environment") && (forcedKubernetes ? (
// Instance execution policy forces the managed Kubernetes sandbox
// (executionMode=kubernetes): never offer local / non-Kubernetes targets.
// Render the environment read-only instead of the selectable picker.
@@ -1548,34 +1555,93 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
) : null)}
{configurationShell && sectionVisible("environment") && isLocal && (
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Environment variables" hint={help.envVars}>
<EnvironmentVariablesEditor
ref={environmentVariablesEditorRef}
value={
isCreate
? ((val!.envBindings ?? EMPTY_ENV) as Record<string, EnvBinding>)
: eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>)
}
secrets={availableSecrets}
userSecretDefinitions={userSecretDefinitions}
onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })}
onChange={(env) =>
isCreate
? set!({ envBindings: env ?? {}, envVars: "" })
: mark("adapterConfig", "env", env)
}
/>
</Field>
{!isCreate && (
<Field label="Secret access" hint={help.secretAccess}>
<AgentSecretAccessEditor
config={{ ...config, ...overlay.adapterConfig }}
secrets={availableSecrets}
onChange={applyAccessGrants}
/>
{/* ---- Environment (configuration shell) ---- */}
{configurationShell && sectionVisible("environment") && (
<div id="config-environment" className="scroll-mt-24 space-y-4">
<CardSectionHeading className="text-sm font-medium mb-1">Environment</CardSectionHeading>
{/* Execution target — inherit vs override is always explicit; the
closed select names the inherited value (wireframe 04). */}
<div className="rounded-lg border border-border p-4 space-y-3">
<Field label="Execution environment" hint="Where runs execute.">
{forcedKubernetes ? (
kubernetesEnvironment ? (
<div className={cn(inputClass, "flex items-center text-muted-foreground")}>
{kubernetesEnvironment.name} · Kubernetes sandbox
</div>
) : (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
This instance requires the Kubernetes sandbox, but no managed Kubernetes
environment is available for this company yet.
</div>
)
) : showEnvironmentOverrideControl ? (
<select
className={inputClass}
value={currentDefaultEnvironmentId}
onChange={(event) => {
const nextValue = event.target.value;
if (isCreate) {
set!({ defaultEnvironmentId: nextValue });
return;
}
mark("identity", "defaultEnvironmentId", nextValue || null);
}}
>
<option value="">
Inherit from company — {inheritedEnvironmentLabel}{instanceDefaultEnvironment ? "" : " (default)"}
</option>
{environmentOptions.map((environment) => (
<option key={environment.id} value={environment.id}>
Override — {environment.name} · {environment.driver}
</option>
))}
</select>
) : (
<div className={cn(inputClass, "flex items-center text-muted-foreground")}>
Inherit from company — {inheritedEnvironmentLabel}{instanceDefaultEnvironment ? "" : " (default)"}
</div>
)}
</Field>
</div>
{/* Variables & secrets — table of per-row source badges. Reserved
PAPERCLIP_* keys render as locked rows instead of being silently
stripped at run time (see PAP-14732). */}
{isLocal && (
<div className="rounded-lg border border-border p-4 space-y-3">
<div className="space-y-1">
<div className="text-sm">Environment variables &amp; secrets</div>
<p className="text-xs text-muted-foreground">
Injected into the adapter process on every run. Bind secrets by reference — values never shown here.
</p>
</div>
<EnvironmentVariablesEditor
ref={environmentVariablesEditorRef}
value={
isCreate
? ((val!.envBindings ?? EMPTY_ENV) as Record<string, EnvBinding>)
: eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>)
}
secrets={availableSecrets}
userSecretDefinitions={userSecretDefinitions}
onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })}
onChange={(env) =>
isCreate
? set!({ envBindings: env ?? {}, envVars: "" })
: mark("adapterConfig", "env", env)
}
/>
{!isCreate && (
<Field label="Secret access" hint={help.secretAccess}>
<AgentSecretAccessEditor
config={{ ...config, ...overlay.adapterConfig }}
secrets={availableSecrets}
onChange={applyAccessGrants}
/>
</Field>
)}
</div>
)}
</div>
)}
@@ -1608,98 +1674,83 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <CardSectionHeading className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> {configurationShell ? "Schedule & Runs" : "Run Policy"}</CardSectionHeading>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
}
<div className={cn(cards ? "border border-border rounded-lg overflow-hidden" : "")}>
<div className={cn(cards ? "p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<ToggleWithNumber
label="Heartbeat on interval"
<div className={cn(cards ? "space-y-4" : "px-4 pb-3 space-y-4")}>
{/* Heartbeat — humanized cadence with a live runs/day preview.
Stored in seconds; the unit select is a display convenience. */}
<div className={cn(cards && "rounded-lg border border-border p-4")}>
<CadenceField
hint={help.heartbeatInterval}
checked={eff("heartbeat", "enabled", heartbeat.enabled === true)}
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
numberLabel="sec"
numberPrefix="Run heartbeat every"
numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)}
enabled={eff("heartbeat", "enabled", heartbeat.enabled === true)}
onEnabledChange={(v) => mark("heartbeat", "enabled", v)}
intervalSec={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
onIntervalSecChange={(v) => mark("heartbeat", "intervalSec", v)}
/>
</div>
<CollapsibleSection
title="Advanced Run Policy"
bordered={cards}
open={runPolicyAdvancedOpen}
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
>
<div className="space-y-3">
{/* Waking & concurrency — first-class (the old "Advanced Run
Policy" collapse is dissolved per wireframe 05). */}
<div className={cn("space-y-3", cards && "rounded-lg border border-border p-4")}>
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Waking &amp; concurrency</div>
<ToggleField
label="Wake on demand"
hint={help.wakeOnDemand}
checked={eff(
"heartbeat",
"wakeOnDemand",
heartbeat.wakeOnDemand !== false,
)}
checked={eff("heartbeat", "wakeOnDemand", heartbeat.wakeOnDemand !== false)}
onChange={(v) => mark("heartbeat", "wakeOnDemand", v)}
/>
<Field label="Cooldown (sec)" hint={help.cooldownSec}>
<DraftNumberInput
value={eff(
"heartbeat",
"cooldownSec",
Number(heartbeat.cooldownSec ?? 10),
)}
onCommit={(v) => mark("heartbeat", "cooldownSec", v)}
immediate
className={inputClass}
/>
</Field>
<Field label="Max concurrent runs" hint={help.maxConcurrentRuns}>
<DraftNumberInput
value={eff(
"heartbeat",
"maxConcurrentRuns",
Number(heartbeat.maxConcurrentRuns ?? AGENT_DEFAULT_MAX_CONCURRENT_RUNS),
)}
value={eff("heartbeat", "maxConcurrentRuns", Number(heartbeat.maxConcurrentRuns ?? AGENT_DEFAULT_MAX_CONCURRENT_RUNS))}
onCommit={(v) => mark("heartbeat", "maxConcurrentRuns", v)}
immediate
className={inputClass}
/>
</Field>
<div className="rounded-md border border-border/70 px-3 py-2">
<ToggleField
label="Continue after max-turn stop"
hint={help.maxTurnContinuationEnabled}
checked={maxTurnContinuationEnabled}
onChange={(v) => updateMaxTurnContinuation({ enabled: v })}
<Field label="Cooldown between runs (sec)" hint={help.cooldownSec}>
<DraftNumberInput
value={eff("heartbeat", "cooldownSec", Number(heartbeat.cooldownSec ?? 10))}
onCommit={(v) => mark("heartbeat", "cooldownSec", v)}
immediate
className={inputClass}
/>
{maxTurnContinuationEnabled ? (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Field label="Continuation attempts" hint={help.maxTurnContinuationMaxAttempts}>
<DraftNumberInput
value={maxTurnContinuationMaxAttempts}
onCommit={(v) =>
updateMaxTurnContinuation({
maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP),
})}
immediate
className={inputClass}
/>
</Field>
<Field label="Continuation delay (sec)" hint={help.maxTurnContinuationDelaySec}>
<DraftNumberInput
value={maxTurnContinuationDelaySec}
onCommit={(v) =>
updateMaxTurnContinuation({
delayMs: clampDelayMsFromSeconds(v),
})}
immediate
className={inputClass}
/>
</Field>
</div>
) : null}
</Field>
</div>
{/* Continuation after max-turn stop — a distinct card; niche
behavior with inputs gated behind the enable toggle. */}
<div className={cn(cards ? "rounded-lg border border-border p-4" : "rounded-md border border-border/70 px-3 py-2")}>
<ToggleField
label="Continue after max-turn stop"
hint={help.maxTurnContinuationEnabled}
checked={maxTurnContinuationEnabled}
onChange={(v) => updateMaxTurnContinuation({ enabled: v })}
/>
<div className={cn("mt-3 grid gap-3 sm:grid-cols-2", !maxTurnContinuationEnabled && "opacity-50")}>
<Field label="Attempts" hint={help.maxTurnContinuationMaxAttempts}>
<DraftNumberInput
value={maxTurnContinuationMaxAttempts}
onCommit={(v) =>
updateMaxTurnContinuation({
maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP),
})}
immediate
disabled={!maxTurnContinuationEnabled}
className={inputClass}
/>
</Field>
<Field label="Delay (sec)" hint={help.maxTurnContinuationDelaySec}>
<DraftNumberInput
value={maxTurnContinuationDelaySec}
onCommit={(v) =>
updateMaxTurnContinuation({
delayMs: clampDelayMsFromSeconds(v),
})}
immediate
disabled={!maxTurnContinuationEnabled}
className={inputClass}
/>
</Field>
</div>
</div>
</CollapsibleSection>
</div>
</div>
) : null)}
@@ -1707,54 +1758,64 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{configurationShell ? props.configurationShellAfterSchedule : null}
{configurationShell && sectionVisible("danger") && (
<div id="config-danger" className="scroll-mt-24">
<CardSectionHeading className="text-sm font-medium mb-3">Danger &amp; Legacy</CardSectionHeading>
<div className="border border-border rounded-lg p-4 space-y-3">
<div id="config-danger" className="scroll-mt-24 space-y-4">
<CardSectionHeading className="text-sm font-medium mb-1">Danger &amp; Legacy</CardSectionHeading>
{/* Danger zone — high-consequence toggles, isolated with a warm
border. Each enable requires a confirm-on-enable dialog. */}
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-destructive" />
<h3 className="text-sm font-medium">Danger zone</h3>
</div>
{hasAdapterDangerFields ? (
<uiAdapter.ConfigFields {...adapterFieldProps} configurationSection="danger" />
) : (
<p className="text-xs text-muted-foreground">This adapter has no permission or sandbox bypass settings.</p>
)}
{showLegacyWorkingDirectoryField && (
<Field label="Working directory (deprecated)" hint={help.cwd}>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<DraftInput
value={isCreate ? val!.cwd : eff("adapterConfig", "cwd", String(config.cwd ?? ""))}
onCommit={(value) =>
isCreate ? set!({ cwd: value }) : mark("adapterConfig", "cwd", value || undefined)
}
immediate
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
placeholder="/path/to/project"
/>
<ChoosePathButton />
</div>
</Field>
)}
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
<>
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={eff("adapterConfig", "bootstrapPromptTemplate", config.bootstrapPromptTemplate)}
onChange={(value) => mark("adapterConfig", "bootstrapPromptTemplate", value || undefined)}
placeholder="Optional initial setup prompt for the first run"
contentClassName="min-h-(--sz-44px) text-sm font-mono"
imageUploadHandler={async (file) => {
const asset = await uploadMarkdownImage.mutateAsync({
file,
namespace: `agents/${props.agent.id}/bootstrap-prompt`,
});
return asset.contentPath;
}}
/>
</Field>
<p className="text-xs text-muted-foreground">
Legacy bootstrap content remains supported, but new behavior should live in the prompt template or instructions file.
</p>
</>
)}
<p className="text-xs text-muted-foreground">
Enabling either opens a confirm dialog stating the consequence. The change is part of the draft changeset, so it lands in History like everything else.
</p>
</div>
{/* Legacy settings — rendered ONLY when a deprecated field still
carries a value; each row is paired with a migration action. */}
{showLegacyCard && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">Legacy settings</h3>
<p className="text-xs text-muted-foreground">
Deprecated fields that still carry a value. Migrate them off — a clean agent never sees this card.
</p>
</div>
{hasLegacyCwdValue && (
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-background/60 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-1.5 text-sm">
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Working directory (deprecated)</span>
</div>
<p className="truncate font-mono text-xs text-muted-foreground" title={legacyCwdValue}>{legacyCwdValue}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 px-2.5 text-xs"
onClick={() => (isCreate ? set!({ cwd: "" }) : mark("adapterConfig", "cwd", undefined))}
>
Clear — use workspaces
</Button>
</div>
)}
{hasLegacyBootstrapValue && (
<LegacyBootstrapRow
value={legacyBootstrapValue}
onMove={() => mark("adapterConfig", "bootstrapPromptTemplate", undefined)}
/>
)}
</div>
)}
</div>
)}
@@ -1762,6 +1823,34 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
);
}
/**
* Legacy bootstrap-prompt row with a "Move to instructions…" migration action.
* Copies the prompt to the clipboard and clears the field from the draft; once
* cleared the surrounding Legacy card auto-hides (no deprecated value remains).
*/
function LegacyBootstrapRow({ value, onMove }: { value: string; onMove: () => void }) {
const charCount = value.trim().length;
async function handleMove() {
try {
await navigator.clipboard?.writeText(value);
} catch {
// Clipboard may be blocked (permissions/headless); still migrate off.
}
onMove();
}
return (
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-background/60 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="text-sm">Bootstrap prompt (legacy)</div>
<p className="text-xs text-muted-foreground">{charCount} chars · sent only on fresh sessions</p>
</div>
<Button type="button" variant="outline" size="sm" className="h-7 shrink-0 px-2.5 text-xs" onClick={handleMove}>
Move to instructions…
</Button>
</div>
);
}
export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) {
const statusLabel =
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
+44 -13
View File
@@ -13,7 +13,7 @@ import {
lowTrustBoundaryHasScope,
setSingleLowTrustBoundaryTarget,
summarizeLowTrustBoundaryTarget,
TRUST_PRESET_DESCRIPTIONS,
TRUST_PRESET_CONSEQUENCES,
TRUST_PRESET_LABELS,
type LowTrustBoundaryTarget,
} from "../lib/trust-policy-ui";
@@ -106,18 +106,49 @@ export function TrustPresetSection({
<div>
<h3 className="mb-3 text-sm font-medium">Trust</h3>
<div className="rounded-lg border border-border p-4 space-y-3">
<Field label="Trust preset" hint="Choose how broadly this agent can read and act on Paperclip work objects.">
<select
className={inputClass}
value={preset}
onChange={(event) => handlePresetChange(event.target.value)}
disabled={disabled}
>
<option value="standard">{TRUST_PRESET_LABELS.standard}</option>
<option value="low_trust_review">{TRUST_PRESET_LABELS.low_trust_review}</option>
</select>
</Field>
<p className="text-xs text-muted-foreground">{TRUST_PRESET_DESCRIPTIONS[preset]}</p>
<div>
<div className="mb-1.5 flex items-center gap-1.5">
<label className="text-xs text-muted-foreground">Trust preset</label>
</div>
<div role="radiogroup" aria-label="Trust preset" className="grid gap-2 sm:grid-cols-2">
{(["standard", "low_trust_review"] as TrustPreset[]).map((value) => {
const selected = preset === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => handlePresetChange(value)}
className={cn(
"flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:bg-accent/40",
disabled && "pointer-events-none opacity-60",
)}
>
<span className="flex items-center gap-2 text-sm font-medium">
<span
aria-hidden="true"
className={cn(
"flex size-3.5 shrink-0 items-center justify-center rounded-full border",
selected ? "border-primary" : "border-muted-foreground/40",
)}
>
{selected ? <span className="size-1.5 rounded-full bg-primary" /> : null}
</span>
{TRUST_PRESET_LABELS[value]}
</span>
<span className="text-xs leading-5 text-muted-foreground">
{TRUST_PRESET_CONSEQUENCES[value]}
</span>
</button>
);
})}
</div>
</div>
{lowTrust ? (
<div
@@ -4,6 +4,14 @@ import type { Agent, AgentEnvConfig } from "@paperclipai/shared";
import { adapterLabels, help } from "./agent-config-primitives";
import { Badge } from "@/components/ui/badge";
import { cn } from "../lib/utils";
import { CADENCE_UNIT_LABELS, secondsToCadence } from "../lib/cadence";
/** Humanized cadence for the effective-config chip, e.g. "Every 5 minutes". */
function formatCadenceSummary(intervalSec: number): string {
const { value, unit } = secondsToCadence(intervalSec);
const unitLabel = value === 1 ? CADENCE_UNIT_LABELS[unit].replace(/s$/, "") : CADENCE_UNIT_LABELS[unit];
return `Every ${value} ${unitLabel}`;
}
export type AgentConfigurationSectionId =
| "runtime"
@@ -148,7 +156,7 @@ export function resolveEffectiveConfiguration(agent: Agent, apiKeyCount = 0) {
cheapInherited: !readString(cheapConfig.model),
environment: agent.defaultEnvironmentId ? "Override" : "Company default",
environmentInherited: !agent.defaultEnvironmentId,
cadence: heartbeat.enabled === true ? `Every ${Number(heartbeat.intervalSec ?? 300)} sec` : "On demand",
cadence: heartbeat.enabled === true ? formatCadenceSummary(Number(heartbeat.intervalSec ?? 300)) : "On demand",
trust: agent.permissions?.trustPreset === "low_trust_review" ? "Low-trust review" : "Standard",
apiKeyCount,
environmentVariableCount: Object.keys(env).length,
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import {
ChevronDown,
KeyRound,
Lock,
MoreHorizontal,
ShieldAlert,
Type as TypeIcon,
@@ -37,6 +38,34 @@ const nameInputClass =
const valueTextInputClass =
"min-w-0 flex-1 bg-transparent px-2 py-1.5 text-sm font-mono outline-none placeholder:text-muted-foreground/40";
/**
* Per-row SOURCE badge (wireframe 04): plain / secret / user / locked.
* `locked` marks reserved (e.g. PAPERCLIP_*) keys so they read as
* runtime-provided rather than silently stripped (PAP-14732).
*/
function SourceBadge({ source, reserved }: { source: RowSource; reserved?: boolean }) {
const spec = reserved
? { label: "locked", Icon: Lock, cls: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300" }
: source === "secret"
? { label: "secret", Icon: KeyRound, cls: "border-violet-500/40 bg-violet-500/10 text-violet-700 dark:text-violet-300" }
: source === "user_secret"
? { label: "user", Icon: UserRound, cls: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300" }
: { label: "plain", Icon: TypeIcon, cls: "border-border bg-muted/40 text-muted-foreground" };
const { Icon } = spec;
return (
<span
aria-label={`Source: ${spec.label}`}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-(length:--text-nano) font-medium",
spec.cls,
)}
>
<Icon className="size-3" />
{spec.label}
</span>
);
}
type SecretPopoverState = { mode: "create" | "store"; name: string; value: string } | null;
export interface EnvironmentVariableDirtyFields {
name: boolean;
@@ -50,6 +79,8 @@ export interface EnvironmentVariableRowProps {
userSecretDefinitions?: readonly UserSecretDefinition[];
recentlyUsedSecrets?: readonly CompanySecret[];
disabled?: boolean;
/** Name matches a reserved prefix (e.g. PAPERCLIP_*) — render as a locked row. */
reserved?: boolean;
nameIssue: NameIssue | null;
showNameIssue: boolean;
dirtyFields: EnvironmentVariableDirtyFields;
@@ -74,6 +105,7 @@ export function EnvironmentVariableRow({
userSecretDefinitions,
recentlyUsedSecrets,
disabled,
reserved,
nameIssue,
showNameIssue,
dirtyFields,
@@ -561,7 +593,8 @@ export function EnvironmentVariableRow({
) : null}
{/* Actions cell — mobile col 2 line 1 / desktop col 3 */}
<div className="col-start-2 row-start-1 flex items-center justify-end gap-0.5 self-start @[40rem]/env:col-start-3 @[40rem]/env:self-center">
<div className="col-start-2 row-start-1 flex items-center justify-end gap-1 self-start @[40rem]/env:col-start-3 @[40rem]/env:self-center">
<SourceBadge source={row.source} reserved={reserved} />
{row.source === "text" && !sensitive && (row.name.trim() || row.textValue) ? (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled}>
@@ -454,6 +454,8 @@ export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorH
{rows.map((row, index) => {
const issue = validateName(row.name, duplicateNames, reservedPrefixes);
const trimmedName = row.name.trim();
const reserved = trimmedName.length > 0 && reservedPrefixes.some((prefix) => prefix && trimmedName.startsWith(prefix));
const touched = touchedNames.has(row.name.trim());
return (
<EnvironmentVariableRow
@@ -464,6 +466,7 @@ export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorH
userSecretDefinitions={userSecretDefinitions}
recentlyUsedSecrets={recentlyUsedSecrets}
disabled={disabled}
reserved={reserved}
nameIssue={issue}
showNameIssue={touched}
dirtyFields={rowDirtyFields(row, committedRowsById.get(row.id))}
+11
View File
@@ -26,6 +26,17 @@ export const TRUST_PRESET_DESCRIPTIONS: Record<TrustPreset, string> = {
"Contained for hostile or untrusted input. Narrow Paperclip API, quarantined output. Use for PR review and external-content triage.",
};
/**
* Plain-language consequence copy shown on the trust-preset radio cards
* (wireframe 06). SecurityEngineer owns the final wording (PAP-14947) — keep
* this in sync with the wireframe rather than inventing new text inline.
*/
export const TRUST_PRESET_CONSEQUENCES: Record<TrustPreset, string> = {
standard: "Reads and acts on all company work objects it's assigned. Default for trusted teammates.",
low_trust_review:
"Confined to a boundary you pick below; writes require review. For experimental or externally-sourced agents.",
};
export function getTrustPreset(permissions: Partial<AgentPermissions> | null | undefined): TrustPreset {
return permissions?.trustPreset === LOW_TRUST_REVIEW_PRESET ? LOW_TRUST_REVIEW_PRESET : DEFAULT_TRUST_PRESET;
}
+7 -1
View File
@@ -93,6 +93,7 @@ import {
HelpCircle,
FolderOpen,
AlertTriangle,
Lock,
} from "lucide-react";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -2037,7 +2038,12 @@ function ConfigurationTab({
</div>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
<div>Can assign tasks</div>
<div className="flex items-center gap-1.5">
<span>Can assign tasks</span>
{taskAssignLocked ? (
<Lock className="h-3 w-3 text-muted-foreground" aria-label="Derived — locked" />
) : null}
</div>
<p className="text-xs text-muted-foreground">{taskAssignHint}</p>
</div>
<ToggleSwitch