From bae9e01f4b1475e30606485388afeabb8a9f09e3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 18:56:41 +0000 Subject: [PATCH] Wire P3 agent-config section upgrades (PAP-14944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Co-Authored-By: Paperclip --- .../adapters/claude-local/config-fields.tsx | 11 +- ui/src/adapters/codex-local/config-fields.tsx | 11 +- .../adapters/opencode-local/config-fields.tsx | 13 +- ui/src/components/AgentConfigForm.tsx | 393 +++++++++++------- ui/src/components/TrustPresetSection.tsx | 57 ++- .../components/agent-configuration-shell.tsx | 10 +- .../environment-variables-editor/Row.tsx | 35 +- .../environment-variables-editor/index.tsx | 3 + ui/src/lib/trust-policy-ui.ts | 11 + ui/src/pages/AgentDetail.tsx | 8 +- 10 files changed, 373 insertions(+), 179 deletions(-) diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index fb2e052c8d8..ad4aa911463 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -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 ( - 0; + const showLegacyCard = hasLegacyCwdValue || hasLegacyBootstrapValue; + function updateMaxTurnContinuation(patch: Record) { mark("heartbeat", "maxTurnContinuation", { ...maxTurnContinuation, @@ -1482,11 +1492,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {/* ---- Execution ---- */} - {configurationShell && sectionVisible("environment") ? ( -
- ) : 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) {
) : null)} - {configurationShell && sectionVisible("environment") && isLocal && ( -
- - ) - : eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record) - } - secrets={availableSecrets} - userSecretDefinitions={userSecretDefinitions} - onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })} - onChange={(env) => - isCreate - ? set!({ envBindings: env ?? {}, envVars: "" }) - : mark("adapterConfig", "env", env) - } - /> - - {!isCreate && ( - - + {/* ---- Environment (configuration shell) ---- */} + {configurationShell && sectionVisible("environment") && ( +
+ Environment + + {/* Execution target — inherit vs override is always explicit; the + closed select names the inherited value (wireframe 04). */} +
+ + {forcedKubernetes ? ( + kubernetesEnvironment ? ( +
+ {kubernetesEnvironment.name} · Kubernetes sandbox +
+ ) : ( +
+ This instance requires the Kubernetes sandbox, but no managed Kubernetes + environment is available for this company yet. +
+ ) + ) : showEnvironmentOverrideControl ? ( + + ) : ( +
+ Inherit from company — {inheritedEnvironmentLabel}{instanceDefaultEnvironment ? "" : " (default)"} +
+ )}
+
+ + {/* 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 && ( +
+
+
Environment variables & secrets
+

+ Injected into the adapter process on every run. Bind secrets by reference — values never shown here. +

+
+ ) + : eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record) + } + secrets={availableSecrets} + userSecretDefinitions={userSecretDefinitions} + onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })} + onChange={(env) => + isCreate + ? set!({ envBindings: env ?? {}, envVars: "" }) + : mark("adapterConfig", "env", env) + } + /> + {!isCreate && ( + + + + )} +
)}
)} @@ -1608,98 +1674,83 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? {configurationShell ? "Schedule & Runs" : "Run Policy"} :
Run Policy
} -
-
- + {/* Heartbeat — humanized cadence with a live runs/day preview. + Stored in seconds; the unit select is a display convenience. */} +
+ 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)} />
- setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)} - > -
+ + {/* Waking & concurrency — first-class (the old "Advanced Run + Policy" collapse is dissolved per wireframe 05). */} +
+
Waking & concurrency
mark("heartbeat", "wakeOnDemand", v)} /> - - mark("heartbeat", "cooldownSec", v)} - immediate - className={inputClass} - /> - mark("heartbeat", "maxConcurrentRuns", v)} immediate className={inputClass} /> -
- updateMaxTurnContinuation({ enabled: v })} + + mark("heartbeat", "cooldownSec", v)} + immediate + className={inputClass} /> - {maxTurnContinuationEnabled ? ( -
- - - updateMaxTurnContinuation({ - maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP), - })} - immediate - className={inputClass} - /> - - - - updateMaxTurnContinuation({ - delayMs: clampDelayMsFromSeconds(v), - })} - immediate - className={inputClass} - /> - -
- ) : null} +
+
+ + {/* Continuation after max-turn stop — a distinct card; niche + behavior with inputs gated behind the enable toggle. */} +
+ updateMaxTurnContinuation({ enabled: v })} + /> +
+ + + updateMaxTurnContinuation({ + maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP), + })} + immediate + disabled={!maxTurnContinuationEnabled} + className={inputClass} + /> + + + + updateMaxTurnContinuation({ + delayMs: clampDelayMsFromSeconds(v), + })} + immediate + disabled={!maxTurnContinuationEnabled} + className={inputClass} + /> +
-
) : null)} @@ -1707,54 +1758,64 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {configurationShell ? props.configurationShellAfterSchedule : null} {configurationShell && sectionVisible("danger") && ( -
- Danger & Legacy -
+
+ Danger & Legacy + + {/* Danger zone — high-consequence toggles, isolated with a warm + border. Each enable requires a confirm-on-enable dialog. */} +
+
+ +

Danger zone

+
{hasAdapterDangerFields ? ( ) : (

This adapter has no permission or sandbox bypass settings.

)} - {showLegacyWorkingDirectoryField && ( - -
- - - 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" - /> - -
-
- )} - {!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && ( - <> - - 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; - }} - /> - -

- Legacy bootstrap content remains supported, but new behavior should live in the prompt template or instructions file. -

- - )} +

+ 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. +

+ + {/* Legacy settings — rendered ONLY when a deprecated field still + carries a value; each row is paired with a migration action. */} + {showLegacyCard && ( +
+
+

Legacy settings

+

+ Deprecated fields that still carry a value. Migrate them off — a clean agent never sees this card. +

+
+ {hasLegacyCwdValue && ( +
+
+
+ + Working directory (deprecated) +
+

{legacyCwdValue}

+
+ +
+ )} + {hasLegacyBootstrapValue && ( + mark("adapterConfig", "bootstrapPromptTemplate", undefined)} + /> + )} +
+ )}
)} @@ -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 ( +
+
+
Bootstrap prompt (legacy)
+

{charCount} chars · sent only on fresh sessions

+
+ +
+ ); +} + export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) { const statusLabel = result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed"; diff --git a/ui/src/components/TrustPresetSection.tsx b/ui/src/components/TrustPresetSection.tsx index e7c60b679d6..a448e075fd8 100644 --- a/ui/src/components/TrustPresetSection.tsx +++ b/ui/src/components/TrustPresetSection.tsx @@ -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({

Trust

- - - -

{TRUST_PRESET_DESCRIPTIONS[preset]}

+
+
+ +
+
+ {(["standard", "low_trust_review"] as TrustPreset[]).map((value) => { + const selected = preset === value; + return ( + + ); + })} +
+
{lowTrust ? (
+ + {spec.label} + + ); +} + 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 */} -
+
+ {row.source === "text" && !sensitive && (row.name.trim() || row.textValue) ? ( diff --git a/ui/src/components/environment-variables-editor/index.tsx b/ui/src/components/environment-variables-editor/index.tsx index 5bed51534ec..d29f59c9303 100644 --- a/ui/src/components/environment-variables-editor/index.tsx +++ b/ui/src/components/environment-variables-editor/index.tsx @@ -454,6 +454,8 @@ export const EnvironmentVariablesEditor = forwardRef { 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 ( = { "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 = { + 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 | null | undefined): TrustPreset { return permissions?.trustPreset === LOW_TRUST_REVIEW_PRESET ? LOW_TRUST_REVIEW_PRESET : DEFAULT_TRUST_PRESET; } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index ef8ac2e15b7..33965752acd 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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({
-
Can assign tasks
+
+ Can assign tasks + {taskAssignLocked ? ( + + ) : null} +

{taskAssignHint}