mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
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:
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
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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";
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user