import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Agent, AdapterAuthSessionPrompt, AdapterAuthSessionStatus, AdapterEnvironmentTestResult, CompanySecret, EnvBinding, EnvSecretRefBinding, Environment, } from "@paperclipai/shared"; import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter, isValidBrowserCode, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; import { ApiError } from "../api/client"; import { environmentsApi } from "../api/environments"; import { instanceSettingsApi } from "../api/instanceSettings"; import { secretsApi } from "../api/secrets"; import { assetsApi } from "../api/assets"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local"; import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert } from "lucide-react"; import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils"; import { copyTextToClipboard } from "../lib/clipboard"; import { resolveAdapterTestEnvironmentId, resolveLocalDefaultEnvironmentId, resolveManagedSandboxEnvironmentId, } from "../lib/adapter-test-environment"; import { environmentDisplayLabel } from "../lib/managed-sandbox-environment"; import { extractModelName, extractProviderId } from "../lib/model-utils"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { Field, ToggleField, ToggleWithNumber, CollapsibleSection, DraftInput, DraftNumberInput, help, adapterLabels, } from "./agent-config-primitives"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { defaultCreateValues } from "./agent-config-defaults"; import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { ReportsToPicker } from "./ReportsToPicker"; import { EnvironmentVariablesEditor, type EnvironmentVariablesEditorHandle, } from "./environment-variables-editor"; import { buildFixedClaudeOAuthBinding, CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model"; import { AgentSecretAccessEditor } from "./AgentSecretAccessEditor"; import { useProposalReview } from "../pages/secrets/proposal-review"; import { AGENT_ACCESS_CONFIG_PATH_PREFIX } from "../lib/secret-delivery"; import { 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"; import { buildAgentUpdatePatch, omitUndefinedEntries, type AgentConfigOverlay } from "../lib/agent-config-patch"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment"; /* ---- Create mode values ---- */ // Canonical type lives in @paperclipai/adapter-utils; re-exported here // so existing imports from this file keep working. export type { CreateConfigValues } from "@paperclipai/adapter-utils"; import type { CreateConfigValues } from "@paperclipai/adapter-utils"; import { Badge } from "@/components/ui/badge"; /* ---- Props ---- */ type AgentConfigFormProps = { adapterModels?: AdapterModel[]; onDirtyChange?: (dirty: boolean) => void; onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; onTestActionChange?: (test: (() => void) | null) => void; onTestActionStateChange?: (state: { disabled: boolean; pending: boolean }) => void; onTestFeedbackChange?: (feedback: { errorMessage: string | null; result: AdapterEnvironmentTestResult | null; // The login panel descriptor when the current target is a sandbox with no // ready authentication, otherwise null. A parent that lifts the test // feedback must render `AdapterLoginPanel` from this descriptor. The inline // feedback branch renders the panel itself, so this descriptor is the only // way the panel reaches a parent that hides the inline branch. login: AdapterLoginDescriptor | null; }) => void; hideInlineSave?: boolean; showAdapterTypeField?: boolean; showAdapterTestEnvironmentButton?: boolean; showCreateRunPolicySection?: boolean; hideInstructionsFile?: boolean; /** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */ hidePromptTemplate?: boolean; /** Render the main configuration sections or the dedicated edit-only Secrets surface. */ content?: "configuration" | "secrets"; /** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */ sectionLayout?: "inline" | "cards"; } & ( | { mode: "create"; values: CreateConfigValues; onChange: (patch: Partial) => void; } | { mode: "edit"; agent: Agent; onSave: (patch: Record) => void | Promise; isSaving?: boolean; } ); /* ---- Edit mode overlay (dirty tracking) ---- */ const emptyOverlay: AgentConfigOverlay = { identity: {}, adapterConfig: {}, heartbeat: {}, runtime: {}, }; /** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */ const EMPTY_ENV: Record = {}; export function supportsAdapterModelRefresh(adapterType: string): boolean { return adapterType === "claude_local" || adapterType === "codex_local"; } function isOverlayDirty(o: AgentConfigOverlay): boolean { return ( Object.keys(o.identity).length > 0 || o.adapterType !== undefined || Object.keys(o.adapterConfig).length > 0 || Object.keys(o.heartbeat).length > 0 || Object.keys(o.runtime).length > 0 || o.modelProfiles?.cheap !== undefined ); } /* ---- Shared input class ---- */ 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"; function parseCommaArgs(value: string): string[] { return value .split(",") .map((item) => item.trim()) .filter(Boolean); } function formatArgList(value: unknown): string { if (Array.isArray(value)) { return value .filter((item): item is string => typeof item === "string") .join(", "); } return typeof value === "string" ? value : ""; } const codexThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, { id: "xhigh", label: "X-High" }, ] as const; const openCodeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, { id: "xhigh", label: "X-High" }, { id: "max", label: "Max" }, ] as const; const cursorModeOptions = [ { id: "", label: "Auto" }, { id: "plan", label: "Plan" }, { id: "ask", label: "Ask" }, ] as const; const claudeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ] as const; // Kimi exposes low/high/max (no "medium") via each model's support_efforts; // the kimi_local adapter maps a legacy "medium" onto "high" at runtime. const kimiThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "low", label: "Low" }, { id: "high", label: "High" }, { id: "max", label: "Max" }, ] as const; const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2; const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10; const MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC = 1; const MAX_TURN_CONTINUATION_MAX_DELAY_SEC = 300; function clampInteger(value: number, min: number, max: number) { return Math.max(min, Math.min(max, Math.floor(value))); } function clampDelayMsFromSeconds(value: number) { return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000; } /* ---- Form ---- */ export function AgentConfigForm(props: AgentConfigFormProps) { const { mode, adapterModels: externalModels } = props; const isCreate = mode === "create"; const cards = props.sectionLayout === "cards"; const showAdapterTypeField = props.showAdapterTypeField ?? true; const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true; const showInlineAdapterTestEnvironmentButton = showAdapterTestEnvironmentButton && !props.onTestActionChange; const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange; const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; const hideInstructionsFile = props.hideInstructionsFile ?? false; const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const environmentVariablesEditorRef = useRef(null); // Sync disabled adapter types from server so dropdown filters them out const disabledTypes = useDisabledAdaptersSync(); const { data: availableSecrets = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], queryFn: () => secretsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId), }); // User-secret definitions power the "User secret" env binding source. Requires // secret-admin; non-admins simply get the free-text key fallback in the editor. const { data: userSecretDefinitions = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.userDefinitions(selectedCompanyId) : ["user-secret-definitions", "none"], queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!), enabled: Boolean(selectedCompanyId), retry: false, }); // Pending binding proposals targeting this agent (PAP-14731). Board-only route; // non-permitted viewers simply get an empty list. const editAgentId = !isCreate ? props.agent.id : null; const { data: pendingProposals = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.proposals(selectedCompanyId, "pending") : ["secret-proposals", "none"], queryFn: () => secretsApi.listProposals(selectedCompanyId!, "pending"), enabled: Boolean(selectedCompanyId) && !isCreate, retry: false, }); const agentBindingProposals = useMemo( () => pendingProposals.filter( (proposal) => proposal.kind === "binding" && proposal.target?.id === editAgentId, ), [pendingProposals, editAgentId], ); const proposalReview = useProposalReview(selectedCompanyId, []); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); const environmentsEnabled = experimentalSettings?.enableEnvironments === true; // Managed-sandbox-only policy: every agent runs in the platform-managed // environment, so the form hides each host filesystem path and each // execution-engine choice. Declared here because the field gates below and // the adapter field props both read it. const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true; // The gate the host-path fields use. It fails closed whenever the policy is // unknown — in flight and also on a failed read: an unresolved policy reads as // "not managed", which would show a stored working directory or // instructions-file path. const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly; // Instance execution policy (general settings). When `executionMode` is // "kubernetes" the instance FORCES all execution onto the managed Kubernetes // sandbox; "any"/absent leaves the full environment/adapter choice intact. // Reuses the same general-settings query the rest of the UI uses. const { data: generalSettings } = useQuery({ queryKey: queryKeys.instance.generalSettings, queryFn: () => instanceSettingsApi.getGeneral(), retry: false, }); const { data: instanceSettings } = useQuery({ queryKey: queryKeys.instance.settings, queryFn: () => instanceSettingsApi.get(), retry: false, }); const { data: environments = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"], queryFn: () => environmentsApi.list(selectedCompanyId!), // Load environments when the picker is enabled OR when execution is forced // onto Kubernetes (so we can resolve and default to the managed K8s env even // when the experimental environments picker is otherwise hidden). enabled: Boolean(selectedCompanyId) && (environmentsEnabled || generalSettings?.executionMode === "kubernetes"), }); // Setting-driven: resolve whether the instance forces Kubernetes execution and // which loaded environment is the managed Kubernetes sandbox. const { forced: forcedKubernetes, kubernetesEnvironment } = useMemo( () => resolveForcedKubernetesEnvironment(generalSettings?.executionMode, environments), [generalSettings?.executionMode, environments], ); const createSecret = useMutation({ mutationFn: (input: { name: string; value: string }) => { if (!selectedCompanyId) throw new Error("Select an organization to create secrets"); return secretsApi.create(selectedCompanyId, input); }, onSuccess: () => { if (!selectedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); }, }); const uploadMarkdownImage = useMutation({ mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { if (!selectedCompanyId) throw new Error("Select an organization to upload images"); return assetsApi.uploadImage(selectedCompanyId, file, namespace); }, }); // ---- Edit mode: overlay for dirty tracking ---- const [overlay, setOverlay] = useState(emptyOverlay); const agentRef = useRef(null); // Clear overlay when agent data refreshes (after save) useEffect(() => { if (!isCreate) { if (agentRef.current !== null && props.agent !== agentRef.current) { setOverlay({ ...emptyOverlay }); } agentRef.current = props.agent; } }, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps const isDirty = !isCreate && isOverlayDirty(overlay); type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime"; /** Read effective value: overlay if dirty, else original */ function eff(group: RecordOverlayGroup, field: string, original: T): T { const o = overlay[group]; if (field in o) return o[field] as T; return original; } /** Mark field dirty in overlay */ function mark(group: RecordOverlayGroup, field: string, value: unknown) { setOverlay((prev) => ({ ...prev, [group]: { ...prev[group], [field]: value }, })); } function flushEnvironmentDraft() { return environmentVariablesEditorRef.current?.flushPendingDraft() ?? null; } /** * Replace the agent's API-access grants (top-level `access.` keys) with * the complete set emitted by the Secret access editor. Added/changed aliases * are marked into the overlay; aliases dropped from the set are marked * `undefined` so `buildAgentUpdatePatch` strips them. */ const applyAccessGrants = useCallback((next: Record) => { if (isCreate) return; setOverlay((prev) => { const effective = { ...(props.agent.adapterConfig ?? {}), ...prev.adapterConfig } as Record; const nextAdapterConfig: Record = { ...prev.adapterConfig }; for (const [alias, binding] of Object.entries(next)) { nextAdapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`] = binding; } for (const key of Object.keys(effective)) { if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue; const alias = key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length); if (!(alias in next)) nextAdapterConfig[key] = undefined; } return { ...prev, adapterConfig: nextAdapterConfig }; }); }, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps /** Build accumulated patch and send to parent */ const handleCancel = useCallback(() => { setOverlay({ ...emptyOverlay }); }, []); const handleSave = useCallback(async () => { if (isCreate) return; const flushedEnv = flushEnvironmentDraft(); const nextOverlay = flushedEnv ? { ...overlay, adapterConfig: { ...overlay.adapterConfig, env: flushedEnv, }, } : overlay; if (!isOverlayDirty(nextOverlay)) return; await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay)); }, [isCreate, isDirty, overlay, props]); useEffect(() => { if (!isCreate) { props.onDirtyChange?.(isDirty); props.onSaveActionChange?.(handleSave); props.onCancelActionChange?.(handleCancel); } }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]); useEffect(() => { if (isCreate) return; return () => { props.onSaveActionChange?.(null); props.onCancelActionChange?.(null); props.onDirtyChange?.(false); }; }, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]); // ---- Resolve values ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record) : {}; const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; const getCapabilities = useAdapterCapabilities(); const adapterCaps = getCapabilities(adapterType); const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; // The legacy working directory is an absolute path on the host, so the // managed-sandbox-only policy hides it. A stored value stays untouched; it is // inert while every run happens in the platform-managed environment. const showLegacyWorkingDirectoryField = isLocal && !hideHostPaths && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const supportedEnvironmentDrivers = useMemo( () => new Set(supportedEnvironmentDriversForAdapter(adapterType)), [adapterType], ); const val = isCreate ? props.values : null; const set = isCreate ? (patch: Partial) => props.onChange(patch) : null; // Create mode holds the non-secret stored-session claim after a Claude // subscription login reaches the server `stored` state. The claim marks the // fixed `CLAUDE_CODE_OAUTH_TOKEN` binding as present. The form sends the claim // in the create request; the server binds and enforces the token // independently. The claim never carries a token value. const claudeStoredSessionId = isCreate ? val!.claudeStoredSessionId ?? null : null; // After a login binds CLAUDE_CODE_OAUTH_TOKEN, the user-secret-definitions list // in the cache is stale. The form reads that list once at page load, so it does // not contain the definition the login just bound. The bound row then compares // its key against the stale list and shows a false "no longer exists" health // error. Invalidate the list so the row reads the fresh definitions and clears // the error. const invalidateUserSecretDefinitions = () => { if (!selectedCompanyId) return; void queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(selectedCompanyId), }); }; // Add the fixed binding to the create-mode form state after the login stores. // Keep every unrelated binding. Hold the claim so the create request sends it. const handleClaudeLoginStored = (storedSessionId: string) => { if (!isCreate || !set) return; const existingBindings = (val!.envBindings ?? {}) as Record; set({ envBindings: { ...existingBindings, ...buildFixedClaudeOAuthBinding() }, claudeStoredSessionId: storedSessionId, }); invalidateUserSecretDefinitions(); }; // Edit mode: after a login stores the token, add the fixed binding to the // existing agent and persist it at once. The server already stored the token; // this step binds `CLAUDE_CODE_OAUTH_TOKEN` to the agent's environment through // the normal agent-update patch path, so no manual bind step remains. Flush any // pending editor draft first, keep every unrelated binding, and mark the merged // set into the overlay so the editor and the saved patch agree. // // An update can never consume a fresh login's stored-session claim -- only the // create and hire paths can, per `enforceClaudeOAuthBindingClaim`. So this save // must set `applyStoredClaudeLogin`, the same way `handleApplyStoredClaudeLoginEdit` // does, or the server rejects the patch with the fixed claim error even though // the login already stored the token. Without the flag every fresh login on an // existing agent's own page fails to save the binding. const handleClaudeLoginStoredEdit = async () => { if (isCreate) return; const flushedEnv = flushEnvironmentDraft(); const baseEnv = flushedEnv ?? (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record)); const nextEnv = { ...baseEnv, ...buildFixedClaudeOAuthBinding() }; const nextOverlay: AgentConfigOverlay = { ...overlay, adapterConfig: { ...overlay.adapterConfig, env: nextEnv }, }; setOverlay(nextOverlay); await props.onSave({ ...buildAgentUpdatePatch(props.agent, nextOverlay), applyStoredClaudeLogin: true, }); invalidateUserSecretDefinitions(); }; // Create mode: bind the fixed reference to an existing stored login with no new // login round trip. Add the fixed binding and set the apply-existing flag. The // create request sends the flag; the server binds the token only for a user // actor and only when a stored value exists. Keep every unrelated binding. const handleApplyStoredClaudeLogin = () => { if (!isCreate || !set) return; const existingBindings = (val!.envBindings ?? {}) as Record; set({ envBindings: { ...existingBindings, ...buildFixedClaudeOAuthBinding() }, claudeApplyStoredLogin: true, }); invalidateUserSecretDefinitions(); }; // Edit mode: bind the fixed reference to an existing stored login with no new // login round trip, then persist it at once. Add the fixed binding to the // existing agent and save the patch with the apply-existing flag. Flush any // pending editor draft first and keep every unrelated binding. const handleApplyStoredClaudeLoginEdit = async () => { if (isCreate) return; const flushedEnv = flushEnvironmentDraft(); const baseEnv = flushedEnv ?? (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record)); const nextEnv = { ...baseEnv, ...buildFixedClaudeOAuthBinding() }; const nextOverlay: AgentConfigOverlay = { ...overlay, adapterConfig: { ...overlay.adapterConfig, env: nextEnv }, }; setOverlay(nextOverlay); await props.onSave({ ...buildAgentUpdatePatch(props.agent, nextOverlay), applyStoredClaudeLogin: true, }); invalidateUserSecretDefinitions(); }; const rawCurrentDefaultEnvironmentId = isCreate ? val!.defaultEnvironmentId ?? "" : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); const currentDefaultEnvironmentId = useMemo(() => { if (!rawCurrentDefaultEnvironmentId) return ""; const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null; return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId; }, [environments, rawCurrentDefaultEnvironmentId]); const currentDefaultEnvironment = useMemo( () => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null, [currentDefaultEnvironmentId, environments], ); const instanceDefaultEnvironmentId = useMemo(() => { const environmentId = instanceSettings?.defaultEnvironmentId ?? null; if (!environmentId) return ""; const selected = environments.find((environment) => environment.id === environmentId) ?? null; return selected?.driver === "local" ? "" : environmentId; }, [environments, instanceSettings?.defaultEnvironmentId]); const instanceDefaultEnvironment = useMemo( () => environments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null, [environments, instanceDefaultEnvironmentId], ); // The environment a login session runs in. It mirrors the Test resolution: the // agent's own environment wins, otherwise the instance default, otherwise the // local default. The login affordance shows only when this environment is a // sandbox, because the canonical auth-missing check comes only from a sandbox // target. // // The resolution passes the same managed-sandbox-only policy inputs as the // adapter Test target, so both resolve to the same environment. Under the // policy a resolution that lands on the local environment redirects to the // managed sandbox the real run uses. Without the redirect the login target // stays local while the Test and the real run use the managed sandbox, so the // login affordance reads the wrong target. The resolver throws when the policy // is on but no managed sandbox is available; a render must not throw, so this // resolution catches that case and resolves no login environment. The Test // mutation surfaces the same case as a fail-closed error. const effectiveLoginEnvironmentId = useMemo(() => { try { return resolveAdapterTestEnvironmentId({ agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null, instanceDefaultEnvironmentId: instanceSettings?.defaultEnvironmentId ?? null, localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environments), managedSandboxOnly: experimentalSettings?.enableManagedSandboxOnly === true, managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environments), // The policy hides the local environment, so an agent default that still // points at the hidden local row names no visible environment. Pass the // visible ids so the resolver redirects that stale local default to the // managed sandbox instead of the hidden local id. visibleEnvironmentIds: environments.map((environment) => environment.id), }); } catch { return null; } }, [ rawCurrentDefaultEnvironmentId, instanceSettings?.defaultEnvironmentId, environments, experimentalSettings?.enableManagedSandboxOnly, ]); const effectiveLoginEnvironment = useMemo( () => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null, [environments, effectiveLoginEnvironmentId], ); // Load the sandbox provider capabilities. A login that runs on a real // pseudo-terminal needs a provider that advertises the login pseudo-terminal // capability. The login panel gate reads this to hide the panel for a provider // without the capability. Enable the query when the adapter declares a login // capability: every login runs on a real pseudo-terminal, so every login // consults this data. The gate is advisory; the server resolves the capability // again and fails closed. const { data: environmentCapabilities } = useQuery({ queryKey: selectedCompanyId ? queryKeys.environments.capabilities(selectedCompanyId) : ["environment-capabilities", "none"], queryFn: () => environmentsApi.capabilities(selectedCompanyId!), enabled: Boolean(selectedCompanyId) && adapterCaps.login != null, }); // When the instance forces Kubernetes execution, new agents must default to the // managed Kubernetes sandbox environment (never the implicit local default). // Only applies in create mode and only once the K8s environment is loaded; if // none is available the UI surfaces a notice instead of silently selecting it. useEffect(() => { if (!isCreate || !set || !forcedKubernetes || !kubernetesEnvironment) return; if (currentDefaultEnvironmentId === kubernetesEnvironment.id) return; set({ defaultEnvironmentId: kubernetesEnvironment.id }); }, [isCreate, set, forcedKubernetes, kubernetesEnvironment, currentDefaultEnvironmentId]); const runnableEnvironments = useMemo( () => environments.filter((environment) => { if (!supportedEnvironmentDrivers.has(environment.driver)) return false; if (environment.driver === "local") return false; if (environment.driver !== "sandbox") return true; const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; return provider !== null && provider !== "fake"; }), [environments, supportedEnvironmentDrivers], ); const environmentOptions = useMemo(() => { if (!currentDefaultEnvironment) return runnableEnvironments; if (runnableEnvironments.some((environment) => environment.id === currentDefaultEnvironment.id)) { return runnableEnvironments; } return [...runnableEnvironments, currentDefaultEnvironment]; }, [currentDefaultEnvironment, runnableEnvironments]); // `runnableEnvironments` excludes the always-available Local environment, so a // single entry already means the user has more than one environment configured // (Local + that environment) and the override selector is meaningful. const showEnvironmentOverrideControl = environmentsEnabled && ( forcedKubernetes || currentDefaultEnvironmentId.length > 0 || runnableEnvironments.length >= 1 ); const inheritedEnvironmentLabel = instanceDefaultEnvironment ? environmentDisplayLabel(instanceDefaultEnvironment) : managedSandboxOnly ? "Paperclip Computer" : "Local"; // Fetch adapter models for the effective adapter type const modelQueryKey = selectedCompanyId ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null) : ["agents", "none", "adapter-models", adapterType]; const { data: fetchedModels, error: fetchedModelsError, } = useQuery({ queryKey: modelQueryKey, queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, { environmentId: currentDefaultEnvironmentId || null, }), enabled: Boolean(selectedCompanyId), }); const [refreshModelsError, setRefreshModelsError] = useState(null); const [refreshingModels, setRefreshingModels] = useState(false); const models = fetchedModels ?? externalModels ?? []; const adapterCommandField = "command"; const { data: detectedModelData, refetch: refetchDetectedModel, } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.detectModel(selectedCompanyId, adapterType) : ["agents", "none", "detect-model", adapterType], queryFn: () => { if (!selectedCompanyId) { throw new Error("Select an organization to detect the model"); } return agentsApi.detectModel(selectedCompanyId, adapterType); }, enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local"), }); const detectedModel = detectedModelData?.model ?? null; const detectedModelCandidates = detectedModelData?.candidates ?? []; const { data: companyAgents = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], queryFn: () => agentsApi.list(selectedCompanyId!), enabled: Boolean(!isCreate && selectedCompanyId), }); /** Props passed to adapter-specific config field components */ const adapterFieldProps = { mode, isCreate, adapterType, values: isCreate ? props.values : null, set: isCreate ? (patch: Partial) => props.onChange(patch) : null, config, eff: eff as (group: "adapterConfig", field: string, original: T) => T, mark: mark as (group: "adapterConfig", field: string, value: unknown) => void, models, // Resolve the effective instructions-file gate once. The instructions file // is an absolute host path, so the managed-sandbox-only policy hides it for // every adapter without a per-adapter edit. hideInstructionsFile: hideInstructionsFile || hideHostPaths, managedSandboxOnly: hideHostPaths, }; // Section toggle state — advanced always starts collapsed const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false); // Popover states const [modelOpen, setModelOpen] = useState(false); const [cheapModelOpen, setCheapModelOpen] = useState(false); const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false); // Cheap model profile state — only relevant when the adapter advertises // `supportsModelProfiles`. Defaults are sourced from the adapter's // /model-profiles endpoint so the UI does not encode adapter-specific // cheap defaults. const supportsModelProfiles = adapterCaps.supportsModelProfiles; const { data: adapterCheapProfileDefinitions } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.adapterModelProfiles(selectedCompanyId, adapterType) : ["agents", "none", "adapter-model-profiles", adapterType], queryFn: () => agentsApi.adapterModelProfiles(selectedCompanyId!, adapterType), enabled: Boolean(selectedCompanyId) && supportsModelProfiles, }); const adapterCheapDefault = useMemo(() => { return (adapterCheapProfileDefinitions ?? []).find((profile) => profile.key === "cheap") ?? null; }, [adapterCheapProfileDefinitions]); const adapterCheapDefaultModel = useMemo(() => { const adapterConfig = adapterCheapDefault?.adapterConfig ?? {}; const value = (adapterConfig as Record).model; return typeof value === "string" ? value : ""; }, [adapterCheapDefault]); function buildAdapterConfigForTest(adapterConfigPatch?: Record): Record { if (isCreate) { const next = uiAdapter.buildAdapterConfig(val!); if (adapterConfigPatch) { Object.assign(next, adapterConfigPatch); } return omitUndefinedEntries(next); } const base = config as Record; const next = { ...base, ...overlay.adapterConfig }; if (adapterConfigPatch) { Object.assign(next, adapterConfigPatch); } return omitUndefinedEntries(next); } function buildCheapAdapterConfigForTest(adapterConfigPatch?: Record): Record { const adapterDefaultConfig = asObject(adapterCheapDefault?.adapterConfig); const createCheapModel = isCreate ? (val!.cheapModel ?? "").trim() : ""; const cheapAdapterConfig = isCreate ? { ...adapterDefaultConfig, ...(createCheapModel ? { model: createCheapModel } : {}), } : { ...adapterDefaultConfig, ...cheapProfileFromAgent.adapterConfig, ...asObject(cheapOverlay?.adapterConfig), }; return buildAdapterConfigForTest({ ...cheapAdapterConfig, ...adapterConfigPatch }); } function getCheapModelTestCase(adapterConfigPatch?: Record): { model: string; adapterConfig: Record } | null { if (!currentCheapEnabled) return null; const adapterConfig = buildCheapAdapterConfigForTest(adapterConfigPatch); const configModel = typeof adapterConfig.model === "string" ? adapterConfig.model.trim() : ""; const model = configModel || currentCheapModel.trim(); if (!model) return null; adapterConfig.model = model; return { model, adapterConfig }; } function prefixEnvironmentTestChecks( result: AdapterEnvironmentTestResult, label: string, model: string | null, ): AdapterEnvironmentTestResult { const modelLabel = model ? ` (${model})` : ""; return { ...result, checks: [ { code: `${label.toLowerCase().replace(/[^a-z0-9]+/g, "_")}_test_started`, level: "info", message: `${label} test${modelLabel}`, }, ...result.checks.map((check) => ({ ...check, message: `${label} test${modelLabel}: ${check.message}`, })), ], }; } async function runEnvironmentTestCase( label: string, model: string | null, adapterConfig: Record, environmentId: string | null, ): Promise { const result = await agentsApi.testEnvironment(selectedCompanyId!, adapterType, { adapterConfig, environmentId, }); return prefixEnvironmentTestChecks(result, label, model); } function mergeEnvironmentTestResults( results: AdapterEnvironmentTestResult[], ): AdapterEnvironmentTestResult { const checks = results.flatMap((result) => result.checks); const status = results.some((result) => result.status === "fail") ? "fail" : results.some((result) => result.status === "warn") ? "warn" : "pass"; const testedAt = results[results.length - 1]?.testedAt ?? new Date().toISOString(); return { adapterType, status, checks, testedAt, }; } const testEnvironment = useMutation({ mutationFn: async () => { if (!selectedCompanyId) { throw new Error("Select an organization to test adapter environment"); } const flushedEnv = flushEnvironmentDraft(); const adapterConfigPatch = flushedEnv ? { env: flushedEnv } : undefined; const primaryModel = currentModelId.trim() || null; const cheapTestCase = getCheapModelTestCase(adapterConfigPatch); // Probe where a real run would actually execute: the agent's own // environment, else the instance default. Testing the host for an // agent that runs in the instance-default sandbox reports failures // (e.g. a CLI that only exists in the sandbox image) a real run would // never hit. The raw id is sent even for a local environment — the // server resolves the driver and probes the host in that case. // // Test can be clicked before the settings query settles (or after it // failed with retry:false), so when the agent relies on the instance // default, resolve the settings here rather than trusting the // render-time cache. A fetch that still fails FAILS the test with an // honest diagnostic — silently probing the host instead would report // the exact false command-not-found failure this resolution exists to // fix. Agents with their own environment never need the settings. let settings = instanceSettings; let environmentList = environments; let managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true; if (!rawCurrentDefaultEnvironmentId) { // The agent has no own environment, so the Test resolves the instance // default, the local default, or the managed sandbox. Resolve the // settings, the environment list, and the managed-sandbox-only policy // here, because the render-time queries can be unsettled, or the // environments query can be disabled under the managed-sandbox-only // policy. A failure surfaces an honest error, not a silent host probe // that reports a false result. try { const [resolvedSettings, resolvedEnvironments, resolvedExperimental] = await Promise.all([ queryClient.ensureQueryData({ queryKey: queryKeys.instance.settings, queryFn: () => instanceSettingsApi.get(), }), queryClient.ensureQueryData({ queryKey: queryKeys.environments.list(selectedCompanyId), queryFn: () => environmentsApi.list(selectedCompanyId), }), queryClient.ensureQueryData({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }), ]); settings = resolvedSettings; environmentList = resolvedEnvironments; managedSandboxOnly = resolvedExperimental?.enableManagedSandboxOnly === true; } catch { throw new Error( "Could not load environment settings to determine which environment to test in. Retry the test.", ); } } // Mirror the server run-time resolution, including the managed-sandbox-only // redirect: when the resolution lands on the local environment and the // policy is on, probe the managed sandbox the real run uses instead. The // resolver throws when no managed sandbox is available, which the mutation // surfaces as a fail-closed error rather than a local host probe. const environmentId = resolveAdapterTestEnvironmentId({ agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null, instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null, localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList), managedSandboxOnly, managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environmentList), // The policy hides the local environment, so an agent default that still // points at the hidden local row names no visible environment. Pass the // visible ids so the resolver redirects that stale local default to the // managed sandbox instead of sending the hidden local id to the server. visibleEnvironmentIds: environmentList.map((environment) => environment.id), }); const testResults: Array<{ label: string; model: string | null; result: AdapterEnvironmentTestResult }> = [ { label: "Primary model", model: primaryModel, result: await runEnvironmentTestCase( "Primary model", primaryModel, buildAdapterConfigForTest(adapterConfigPatch), environmentId, ), }, ]; if (cheapTestCase) { testResults.push({ label: "Cheap model", model: cheapTestCase.model, result: await runEnvironmentTestCase( "Cheap model", cheapTestCase.model, cheapTestCase.adapterConfig, environmentId, ), }); } return testResults.length > 1 ? mergeEnvironmentTestResults(testResults.map(({ result }) => result)) : testResults[0]!.result; }, }); const [testActionPending, setTestActionPending] = useState(false); const [testActionError, setTestActionError] = useState(null); const testActionLabel = "Test"; const isSavePending = !isCreate && Boolean(props.isSaving); const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId; // Drop a stale Test result when the adapter type or the effective environment // changes. A held result would keep the login affordance visible for a target // the user no longer selected. The reset unmounts the login panel too, so its // session state clears with it. Hold `reset` in a ref so the effect does not // re-run on every render (the mutation object has a new identity each render). const resetTestEnvironmentRef = useRef(testEnvironment.reset); resetTestEnvironmentRef.current = testEnvironment.reset; // Clear a stale Claude login claim when the adapter type or the effective // environment changes. A login binds `CLAUDE_CODE_OAUTH_TOKEN` to one specific // environment. After the environment changes, the held claim and the fixed // binding no longer match the new target. The create request would still send // the claim, and the server would reject it. So drop the claim, the // apply-existing flag, and the fixed binding, and return the login panel to // its initial state. Only create mode holds this form state; edit mode saves // the binding at login time. Hold the clear in a ref so the effect does not // re-run on every render (`set` and `val` have a new identity each render). const clearClaudeLoginClaimRef = useRef<() => void>(() => {}); clearClaudeLoginClaimRef.current = () => { if (!isCreate || !set) return; const bindings = (val!.envBindings ?? {}) as Record; const hasClaim = val!.claudeStoredSessionId != null || val!.claudeApplyStoredLogin === true; const hasBinding = CLAUDE_OAUTH_TOKEN_ENV_KEY in bindings; if (!hasClaim && !hasBinding) return; const nextBindings = { ...bindings }; delete nextBindings[CLAUDE_OAUTH_TOKEN_ENV_KEY]; set({ envBindings: nextBindings, claudeStoredSessionId: null, claudeApplyStoredLogin: false, }); }; useEffect(() => { resetTestEnvironmentRef.current(); setTestActionError(null); clearClaudeLoginClaimRef.current(); }, [adapterType, effectiveLoginEnvironmentId]); // Show the login affordance only for a current sandbox adapter that declares a // login capability, and whose most recent Test result carries the canonical // auth-missing check. The form reads the projected capability, not the adapter // name. The result keeps its own `adapterType`, so the form reads the // capability for that result adapter; a result from an adapter with no login // capability never gates the panel. const adapterSupportsSandboxLogin = adapterCaps.login != null; const testResult = testEnvironment.data; const testResultSupportsSandboxLogin = testResult != null && getCapabilities(testResult.adapterType).login != null; const authMissingCheck = testResult && testResultSupportsSandboxLogin ? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null : null; // A login runs on a real pseudo-terminal, so it needs a provider that // advertises the login pseudo-terminal capability. Read the capability for the // effective environment provider. The form reads the adapter login capability, // not the adapter name: every adapter login gates on this provider capability. const effectiveLoginProvider = typeof effectiveLoginEnvironment?.config?.provider === "string" ? effectiveLoginEnvironment.config.provider : null; const providerSupportsLoginPty = effectiveLoginProvider != null && environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true; const loginNeedsPty = adapterCaps.login != null; const showAdapterLogin = adapterSupportsSandboxLogin && effectiveLoginEnvironment?.driver === "sandbox" && Boolean(effectiveLoginEnvironmentId) && Boolean(selectedCompanyId) && Boolean(authMissingCheck) && (!loginNeedsPty || providerSupportsLoginPty); const runEnvironmentTest = useCallback(async () => { if (!selectedCompanyId) { throw new Error("Select an organization to test adapter environment"); } setTestActionPending(true); setTestActionError(null); testEnvironment.reset(); try { return await testEnvironment.mutateAsync(); } catch (error) { setTestActionError(error instanceof Error ? error.message : "Environment test failed"); throw error; } finally { setTestActionPending(false); } }, [selectedCompanyId, testEnvironment]); // `runEnvironmentTest` (and `testEnvironmentDisabled`) change identity on every // render because `useMutation` returns a fresh result object each time. Hold the // latest behavior in a ref so the trigger handed to the parent stays referentially // stable — otherwise the `onTestActionChange` effect below re-runs every render, // pushing a new function into parent state and causing an infinite update loop. const triggerRef = useRef<() => void>(() => {}); useEffect(() => { triggerRef.current = () => { if (testEnvironmentDisabled) return; void runEnvironmentTest().catch(() => undefined); }; }, [runEnvironmentTest, testEnvironmentDisabled]); const triggerTestEnvironment = useCallback(() => { triggerRef.current(); }, []); useEffect(() => { if (!showAdapterTestEnvironmentButton || !props.onTestActionChange) return; props.onTestActionChange(triggerTestEnvironment); return () => { props.onTestActionChange?.(null); }; }, [showAdapterTestEnvironmentButton, props.onTestActionChange, triggerTestEnvironment]); useEffect(() => { if (!showAdapterTestEnvironmentButton || !props.onTestActionStateChange) return; props.onTestActionStateChange({ disabled: testEnvironmentDisabled, pending: testActionPending, }); return () => { props.onTestActionStateChange?.({ disabled: true, pending: false }); }; }, [ showAdapterTestEnvironmentButton, props.onTestActionStateChange, testEnvironmentDisabled, testActionPending, ]); useEffect(() => { if (!props.onTestFeedbackChange) return; props.onTestFeedbackChange({ errorMessage: testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : testEnvironment.error ? "Environment test failed" : null), result: testEnvironment.data ?? null, // `showAdapterLogin` already requires a selected company and a non-empty // environment id, so both are present here. login: showAdapterLogin && selectedCompanyId && effectiveLoginEnvironmentId ? { companyId: selectedCompanyId, adapterType, environmentId: effectiveLoginEnvironmentId } : null, }); return () => { props.onTestFeedbackChange?.({ errorMessage: null, result: null, login: null }); }; }, [ props.onTestFeedbackChange, testActionError, testEnvironment.data, testEnvironment.error, showAdapterLogin, selectedCompanyId, adapterType, effectiveLoginEnvironmentId, ]); // Current model for display const currentModelValue = isCreate ? val!.model ?? "" : eff("adapterConfig", "model", String(config.model ?? "")); const currentModelId = typeof currentModelValue === "string" ? currentModelValue : ""; async function handleRefreshModels() { if (!selectedCompanyId) return; setRefreshingModels(true); setRefreshModelsError(null); try { const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true }); queryClient.setQueryData(modelQueryKey, refreshed); } catch (error) { setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models."); } finally { setRefreshingModels(false); } } const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : adapterType === "cursor" ? "mode" : adapterType === "opencode_local" ? "variant" : "effort"; const thinkingEffortOptions = adapterType === "codex_local" ? codexThinkingEffortOptions : adapterType === "cursor" ? cursorModeOptions : adapterType === "opencode_local" ? openCodeThinkingEffortOptions : adapterType === "kimi_local" ? kimiThinkingEffortOptions : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" ? eff( "adapterConfig", "modelReasoningEffort", String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""), ) : adapterType === "cursor" ? eff("adapterConfig", "mode", String(config.mode ?? "")) : adapterType === "opencode_local" ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", "effort", String(config.effort ?? "")); const showThinkingEffort = adapterType !== "gemini_local" && adapterType !== "cursor_cloud" && adapterType !== "paperclip_runner"; const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; // Cheap profile read/write helpers. Edit-mode values come from // runtimeConfig.modelProfiles.cheap with overlay overrides on top; create-mode // values come straight from CreateConfigValues (cheapModel + cheapModelEnabled). const cheapProfileFromAgent = useMemo(() => { const profiles = (runtimeConfig.modelProfiles ?? {}) as Record; const cheap = (profiles.cheap ?? {}) as Record; const cheapAdapterConfig = asObject(cheap.adapterConfig); return { enabled: cheap.enabled !== false, adapterConfig: cheapAdapterConfig, model: typeof cheapAdapterConfig.model === "string" ? cheapAdapterConfig.model : "", }; }, [runtimeConfig]); const cheapOverlay = !isCreate ? overlay.modelProfiles?.cheap : undefined; const currentCheapEnabled = isCreate ? val!.cheapModelEnabled ?? false : cheapOverlay?.enabled ?? cheapProfileFromAgent.enabled; const currentCheapModel = isCreate ? val!.cheapModel ?? "" : (() => { const overlayModel = (cheapOverlay?.adapterConfig as Record | undefined)?.model; if (typeof overlayModel === "string") return overlayModel; return cheapProfileFromAgent.model; })(); function setCheapEnabled(next: boolean) { if (isCreate) { set!({ cheapModelEnabled: next }); return; } setOverlay((prev) => ({ ...prev, modelProfiles: { cheap: { ...(prev.modelProfiles?.cheap ?? {}), enabled: next, }, }, })); } function setCheapModel(next: string) { if (isCreate) { set!({ cheapModel: next }); return; } setOverlay((prev) => { const existing = prev.modelProfiles?.cheap ?? {}; const nextAdapterConfig = { ...((existing.adapterConfig ?? {}) as Record), model: next || undefined, }; return { ...prev, modelProfiles: { cheap: { ...existing, adapterConfig: nextAdapterConfig, }, }, }; }); } const effectiveRuntimeConfig = useMemo(() => { if (isCreate) { return { heartbeat: { enabled: val!.heartbeatEnabled, intervalSec: val!.intervalSec, }, }; } const mergedHeartbeat = { ...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object" ? runtimeConfig.heartbeat as Record : {}), ...overlay.heartbeat, }; return { ...runtimeConfig, heartbeat: mergedHeartbeat, }; }, [isCreate, overlay.heartbeat, runtimeConfig, val]); const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat); const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation); const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true); const maxTurnContinuationMaxAttempts = clampInteger( asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS), 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP, ); const maxTurnContinuationDelaySec = clampInteger( asFiniteNumber(maxTurnContinuation.delayMs, MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC * 1000) / 1000, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC, ); function updateMaxTurnContinuation(patch: Record) { mark("heartbeat", "maxTurnContinuation", { ...maxTurnContinuation, ...patch, }); } if (!isCreate && props.content === "secrets") { return (
{isDirty && !props.hideInlineSave && (
Unsaved changes
)}
{cards ?

Secret access

:
Secret access
}

{help.secretAccess}

createSecret.mutateAsync({ name, value })} proposals={agentBindingProposals} onApproveProposal={proposalReview.requestApprove} onRejectProposal={proposalReview.requestReject} /> {proposalReview.dialogs}
); } return (
{/* ---- Floating Save button (edit mode, when dirty) ---- */} {isDirty && !props.hideInlineSave && (
Unsaved changes
)} {/* ---- Identity (edit only) ---- */} {!isCreate && (
{cards ?

Identity

:
Identity
}
mark("identity", "name", v)} immediate className={inputClass} placeholder="Agent name" /> mark("identity", "title", v || null)} immediate className={inputClass} placeholder="e.g. VP of Engineering" /> mark("identity", "reportsTo", id)} excludeAgentIds={[props.agent.id]} chooseLabel="Choose manager…" /> mark("identity", "capabilities", v || null)} placeholder="Describe what this agent can do..." contentClassName="min-h-(--sz-44px) text-sm font-mono" imageUploadHandler={async (file) => { const asset = await uploadMarkdownImage.mutateAsync({ file, namespace: `agents/${props.agent.id}/capabilities`, }); return asset.contentPath; }} /> {isLocal && !props.hidePromptTemplate && ( <> mark("adapterConfig", "promptTemplate", v ?? "")} placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." contentClassName="min-h-(--sz-88px) text-sm font-mono" imageUploadHandler={async (file) => { const namespace = `agents/${props.agent.id}/prompt-template`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); return asset.contentPath; }} />
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
)}
)} {/* ---- Execution ---- */} {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.
{cards ?

Environment

:
Environment
}
{kubernetesEnvironment ? (
{kubernetesEnvironment.name} · Kubernetes sandbox
) : (
This instance requires the Kubernetes sandbox, but no managed Kubernetes environment is available for this organization yet. Configure one before creating agents; execution will not fall back to local.
)}
) : showEnvironmentOverrideControl ? (
{cards ?

Environment

:
Environment
}
) : null} {/* ---- Adapter ---- */}
{cards ?

Adapter

: Adapter } {showInlineAdapterTestEnvironmentButton && ( )}
{showAdapterTypeField && ( { if (isCreate) { // Reset all adapter-specific fields to defaults when switching adapter type const { adapterType: _at, ...defaults } = defaultCreateValues; const nextValues: CreateConfigValues = { ...defaults, adapterType: t }; if (t === "codex_local") { nextValues.dangerouslyBypassSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } else if (t === "gemini_local") { nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; } else if (t === "kimi_local") { nextValues.model = DEFAULT_KIMI_LOCAL_MODEL; } else if (t === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (t === "opencode_local") { nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL; } set!(nextValues); } else { // Clear all adapter config and explicitly blank out model + effort/mode keys // so the old adapter's values don't bleed through via eff() setOverlay((prev) => ({ ...prev, adapterType: t, modelProfiles: { cheap: { cleared: true } }, adapterConfig: { model: t === "gemini_local" ? DEFAULT_GEMINI_LOCAL_MODEL : t === "kimi_local" ? DEFAULT_KIMI_LOCAL_MODEL : t === "opencode_local" ? DEFAULT_OPENCODE_LOCAL_MODEL : t === "cursor" ? DEFAULT_CURSOR_LOCAL_MODEL : "", effort: "", modelReasoningEffort: "", variant: "", mode: "", ...(t === "codex_local" ? { dangerouslyBypassApprovalsAndSandbox: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, } : {}), }, })); } }} /> )} {showInlineAdapterTestEnvironmentFeedback && (testActionError || testEnvironment.error) && (
{testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : "Environment test failed")}
)} {showInlineAdapterTestEnvironmentFeedback && testEnvironment.data && ( )} {showInlineAdapterTestEnvironmentFeedback && showAdapterLogin && ( )} {/* Working directory */} {showLegacyWorkingDirectoryField && (
isCreate ? set!({ cwd: v }) : mark("adapterConfig", "cwd", v || undefined) } immediate className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" placeholder="/path/to/project" />
)} {!isLocal && } {/* Local adapter-specific fields are rendered inside Permissions & Configuration */}
{/* ---- Permissions & Configuration ---- */} {isLocal && (
{cards ?

Permissions & Configuration

:
Permissions & Configuration
}
{/* The command names a binary on the execution host, so the managed-sandbox-only policy hides it: the platform-managed image owns the binary. Hiding is presentation only. A stored `adapterConfig.command` stays as it is and the server does not reject one, because an import carries adapter configuration written on another instance; rejecting it would break that flow. The value is inert while the policy is on. The field also stays hidden until the policy is known, so a stored command never flashes on a managed instance. */} {!hideHostPaths && ( isCreate ? set!({ command: v }) : mark("adapterConfig", adapterCommandField, v || null) } immediate className={inputClass} placeholder={ ({ claude_local: "claude", codex_local: "codex", gemini_local: "gemini", kimi_local: "kimi", pi_local: "pi", cursor: "agent", opencode_local: "opencode", } as Record)[adapterType] ?? adapterType.replace(/_local$/, "") } /> )} {supportsModelProfiles && (
Primary model
)} isCreate ? set!({ model: v }) : mark("adapterConfig", "model", v || undefined) } open={modelOpen} onOpenChange={setModelOpen} allowDefault={adapterType !== "opencode_local"} required={adapterType === "opencode_local"} groupByProvider={adapterType === "opencode_local"} creatable detectedModel={detectedModel} detectedModelCandidates={[]} onDetectModel={adapterType === "opencode_local" ? undefined : async () => { const result = await refetchDetectedModel(); return result.data?.model ?? null; }} onRefreshModels={ supportsAdapterModelRefresh(adapterType) ? handleRefreshModels : undefined } refreshingModels={refreshingModels} detectModelLabel="Detect model" emptyDetectHint="No model detected. Select or enter one manually." /> {(refreshModelsError || fetchedModelsError) && (

{refreshModelsError ?? (fetchedModelsError instanceof Error ? fetchedModelsError.message : "Failed to load adapter models.")}

)} {adapterType === "opencode_local" && currentDefaultEnvironment && currentDefaultEnvironment.driver !== "local" && (

Live OpenCode model discovery only runs for Local environments. Using the curated list and manual entry for {currentDefaultEnvironment.name}.

)} {supportsModelProfiles && ( )} {showThinkingEffort && ( <> isCreate ? set!({ thinkingEffort: v }) : mark("adapterConfig", thinkingEffortKey, v || undefined) } open={thinkingEffortOpen} onOpenChange={setThinkingEffortOpen} /> {adapterType === "codex_local" && codexSearchEnabled && currentThinkingEffort === "minimal" && (

Codex may reject `minimal` thinking when search is enabled.

)} )} {!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && ( <> mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) } placeholder="Optional initial setup prompt for the first run" contentClassName="min-h-(--sz-44px) text-sm font-mono" imageUploadHandler={async (file) => { const namespace = `agents/${props.agent.id}/bootstrap-prompt`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); return asset.contentPath; }} />
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent's prompt template or instructions file instead.
)} {adapterType === "claude_local" && ( )} isCreate ? set!({ extraArgs: v }) : mark("adapterConfig", "extraArgs", v?.trim() ? parseCommaArgs(v) : null) } className={inputClass} placeholder="e.g. --verbose, --foo=bar" /> ) : (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record)) } secrets={availableSecrets} userSecretDefinitions={userSecretDefinitions} 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 */} {!isCreate && ( <> mark("adapterConfig", "timeoutSec", v)} immediate className={inputClass} /> mark("adapterConfig", "graceSec", v)} immediate className={inputClass} /> )}
)} {/* ---- Run Policy ---- */} {isCreate && showCreateRunPolicySection ? (
{cards ?

Run Policy

:
Run Policy
}
set!({ heartbeatEnabled: v })} number={val!.intervalSec} onNumberChange={(v) => set!({ intervalSec: v })} numberLabel="sec" numberPrefix="Run heartbeat every" numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} />
) : !isCreate ? (
{cards ?

Run Policy

:
Run Policy
}
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)} />
setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)} >
mark("heartbeat", "wakeOnDemand", v)} /> mark("heartbeat", "cooldownSec", v)} immediate className={inputClass} /> mark("heartbeat", "maxConcurrentRuns", v)} immediate className={inputClass} />
updateMaxTurnContinuation({ enabled: v })} /> {maxTurnContinuationEnabled ? (
updateMaxTurnContinuation({ maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP), })} immediate className={inputClass} /> updateMaxTurnContinuation({ delayMs: clampDelayMsFromSeconds(v), })} immediate className={inputClass} />
) : null}
) : null}
); } // The public session states that end a login. The panel stops the status poll // and shows a terminal message when the session reaches one of these. const ADAPTER_LOGIN_TERMINAL_STATUSES = new Set([ "authenticated", "failed", "timed_out", "cancelled", ]); // The status route poll interval while a session is active (Decision A). The // poll stops at a terminal state. const ADAPTER_LOGIN_POLL_INTERVAL_MS = 2000; // A copy-to-clipboard button. It mirrors the workspace service control bar: a // short "copied" flash, then it returns to the copy icon. function AdapterLoginCopyButton({ value, label }: { value: string; label: string }) { const [copied, setCopied] = useState(false); const timeoutRef = useRef | null>(null); useEffect( () => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, [], ); return ( ); } // The terminal message for a finished login. It never shows a secret. It shows // only the fixed, non-secret failure message the server returns. function AdapterLoginTerminalState({ status, message, }: { status: AdapterAuthSessionStatus; message: string | null; }) { if (status === "authenticated") { return (
Authenticated. The environment has credentials now.
); } const label = status === "timed_out" ? "Login timed out" : status === "cancelled" ? "Login cancelled" : "Login failed"; return (
{label} {message ? `: ${message}` : "."}
); } // The login panel for one adapter in one sandbox environment. It starts a login // session, polls the status route, and shows the one-time code and the // authentication URL with copy and open actions. It shows the terminal states. // It never writes the code, the URL, or any credential byte to a log line. // // The panel holds its own session state. The parent gives it a stable `key` from // the adapter type and the environment id, so a change to either remounts the // panel with a fresh session state. // The props that identify one login panel: one adapter in one sandbox // environment for one company. A parent that lifts the test feedback renders // the panel from this descriptor. export type AdapterLoginDescriptor = { companyId: string; adapterType: string; environmentId: string; }; // The panel props. `onStored` reports the non-secret `storedSessionId` claim from // a Claude login that reaches the server `stored` state. The create-mode form // holds that claim for the fixed binding; the callback never carries a token. // `onApplyStored` binds the fixed reference to an existing stored login with no // new login round trip. The panel shows the apply-existing affordance only when // the status route reports a stored value. export type AdapterLoginPanelProps = AdapterLoginDescriptor & { onStored?: (storedSessionId: string) => void; onApplyStored?: () => void; }; // The login panel dispatcher. It picks the panel from the projected panel mode, // not from the adapter name. The `submitted_browser_code` mode shows the // submitted-browser-code panel; every other mode shows the displayed-code panel. export function AdapterLoginPanel(props: AdapterLoginPanelProps) { const getCapabilities = useAdapterCapabilities(); const panelMode = getCapabilities(props.adapterType).login?.panelMode; if (panelMode === "submitted_browser_code") { return ; } return ; } function DisplayedCodeLoginPanel({ companyId, adapterType, environmentId, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); const [startError, setStartError] = useState(null); // The server delivers the one-time prompt on the first owner read only. Latch // it so a later poll that returns a null prompt does not hide the code and the // URL. const [latchedPrompt, setLatchedPrompt] = useState(null); const startLogin = useMutation({ mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId }), onSuccess: (session) => { setStartError(null); setLatchedPrompt(null); setSessionId(session.sessionId); }, onError: (error) => { setStartError(error instanceof Error ? error.message : "Could not start the login."); }, }); const cancelLogin = useMutation({ mutationFn: () => agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId!), onSuccess: () => { // Reset local state, so the panel returns to its idle start state and the // Log in button is available again. setSessionId(null); setLatchedPrompt(null); setStartError(null); }, onError: (error) => { setStartError(error instanceof Error ? error.message : "Could not cancel the login."); }, }); const statusQuery = useQuery({ queryKey: ["adapter-login-status", companyId, adapterType, sessionId], queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!), enabled: Boolean(sessionId), refetchInterval: (query) => { const status = query.state.data?.status; return status && ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) ? false : ADAPTER_LOGIN_POLL_INTERVAL_MS; }, }); // Latch the first non-null prompt for the current session. A later poll // returns a null prompt after the one-time delivery, so keep the latched value. useEffect(() => { const next = statusQuery.data?.prompt ?? null; if (next) setLatchedPrompt(next); }, [statusQuery.data]); const session = statusQuery.data ?? startLogin.data ?? null; const status = session?.status ?? null; const prompt = latchedPrompt; const isTerminal = status ? ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) : false; const isActive = Boolean(sessionId) && !isTerminal; const startDisabled = startLogin.isPending || isActive; return (
Sign in to the environment
{isActive && ( )}
{startError && (
{startError}
)} {/* One live region announces the loading, prompt, and terminal states, so a screen reader reports each transition without a re-navigation. */}
{isActive && !prompt && (
Preparing the login…
)} {isActive && prompt && (
Open the authentication page and enter the code.
Code
{prompt.code}
Authentication URL
{prompt.url}
)} {isTerminal && status && ( )}
); } // The public session states that fail a Claude login. The panel returns to its // start state and shows a fixed message at one of these. const CLAUDE_LOGIN_FAILURE_STATUSES = new Set([ "failed", "timed_out", "cancelled", ]); // The fixed, non-secret message for a failed Claude login. The panel shows this // text and returns to its start state. It never shows a provider message that // could carry a secret. const CLAUDE_LOGIN_FAILED_MESSAGE = "The login did not finish. Start the login again."; // The client wall-clock cap for one active login. The panel polls the status // route and the prompt route every two seconds. The server can leave a session // short of a terminal status, and the prompt route returns 404 until the URL is // ready. Without a cap, the panel polls forever. When this cap passes, the panel // stops both polls and shows the timed-out state, so the user can start again. // A generous client failsafe for a live login whose server deadline has not // arrived yet. The server `expiresAt` is the real cutoff; the panel drives the // timer from it. This failsafe only bounds a stuck poll while the status route // has not yet returned `expiresAt`, so a login can never poll forever. The // value is far longer than the server budget, so it never cuts a normal login // short (the earlier fixed 60-second cutoff did, which drove this fix). const CLAUDE_LOGIN_FAILSAFE_TIMEOUT_MS = 15 * 60_000; // The fixed, non-secret message for a timed-out Claude login. The panel shows // this text, stops both polls, and returns to its start state. const CLAUDE_LOGIN_TIMED_OUT_MESSAGE = "The login timed out. Start the login again."; // The submitted-browser-code login panel for the Claude adapter. It starts a // setup-token login, polls the status route, and reads the authorization URL // from the guarded prompt route. The user opens the URL, reads the browser code, // and submits it. The panel clears the code right after submit. The panel treats // only the server `stored` state as success, and it never shows the OAuth token. function SubmittedBrowserCodeLoginPanel({ companyId, environmentId, onStored, onApplyStored, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); const [startError, setStartError] = useState(null); // The server delivers the authorization URL on the guarded prompt read only. // Latch it so a later poll does not hide the URL. const [authorizationUrl, setAuthorizationUrl] = useState(null); // True when the guarded prompt read reports a non-confidential transport. The // server does not block a plain-HTTP login. The panel shows a non-blocking // disclaimer and the login still proceeds. Latch it beside the URL. const [transportInsecure, setTransportInsecure] = useState(false); // The browser code the user submits. The panel clears it right after submit, so // the secret never lingers in the input. const [browserCode, setBrowserCode] = useState(""); // The non-secret stored-session claim from a completed login. It marks the // server `stored` state, the only success state. const [storedSessionId, setStoredSessionId] = useState(null); // True after a completion read fails. The panel returns to its start state. const [completionFailed, setCompletionFailed] = useState(false); // True after the client wall-clock cap passes for the active login. The panel // stops both polls and shows the timed-out state. const [timedOut, setTimedOut] = useState(false); // True after the status poll returns 404. The server removes the row and the // in-memory session at once on any non-stored terminal state, so a status 404 // means the login failed and the server cleaned up. The panel stops both // polls and shows a terminal failure state. It shows no credential material. const [statusGone, setStatusGone] = useState(false); // Guards the one completion read per session. const completionStartedRef = useRef(false); // True after the owner applies an existing stored login in this panel. The // apply-existing path binds the fixed reference with no new login round trip, // so the panel shows the applied confirmation and hides the apply affordance. const [appliedStored, setAppliedStored] = useState(false); const resetLocalState = () => { setStartError(null); setAuthorizationUrl(null); setTransportInsecure(false); setBrowserCode(""); setStoredSessionId(null); setCompletionFailed(false); setTimedOut(false); setStatusGone(false); completionStartedRef.current = false; }; // Read the stored Claude OAuth token status when the panel mounts. A 200 body // carries the secret id and the version of the owner value; a 404 means the // owner has no stored value. The panel applies the stored token first: when a // value exists, a replacement login rotates it under the captured version // instead of a blind first write. The server rejects a stale capture, so a // change by another process after the capture fails the overwrite. const storedTokenQuery = useQuery({ queryKey: ["claude-oauth-token-status", companyId], queryFn: async () => { try { return await agentsApi.getClaudeOAuthTokenStatus(companyId); } catch (error) { // A 404 means the owner has no stored value (indistinguishable from a // foreign value). The panel treats it as "no stored token". if (error instanceof ApiError && error.status === 404) return null; throw error; } }, retry: (failureCount, error) => { if (error instanceof ApiError && error.status === 404) return false; return failureCount < 2; }, }); const storedToken = storedTokenQuery.data ?? null; const startLogin = useMutation({ mutationFn: () => agentsApi.startClaudeSetupTokenLogin(companyId, { environmentId, // When the owner already has a stored token, the login rotates it under // the captured version, so a replacement login never conflicts with an // existing value. Without a stored token the login is a first write. ...(storedToken ? { overwrite: { expectedSecretId: storedToken.secretId, expectedLatestVersion: storedToken.latestVersion, }, } : {}), }), onSuccess: (session) => { resetLocalState(); setSessionId(session.sessionId); }, onError: (error) => { setStartError(error instanceof Error ? error.message : "Could not start the login."); }, }); const clearActiveSession = () => { // Return the panel to its idle start state. The Log in button is available // again, and both polls stop because the session id is null. setSessionId(null); resetLocalState(); }; const cancelLogin = useMutation({ mutationFn: () => agentsApi.cancelClaudeSetupTokenLogin(companyId, sessionId!), onSuccess: () => { clearActiveSession(); }, onError: (error) => { // The cancel is resilient. The server removes a terminal session, so a // cancel of an already-terminal or unknown session can return a 404. Treat // that 404 the same as a successful cancel: clear the session and stop the // polls. The panel never keeps polling a session the server no longer // holds. Any other error surfaces and keeps the active login. if (error instanceof ApiError && error.status === 404) { clearActiveSession(); return; } setStartError(error instanceof Error ? error.message : "Could not cancel the login."); }, }); // Release the server session at once, without a change to the panel state. The // client-cutoff timer and the unmount path both use this. The server holds a // per-owner reservation until the session reaches a terminal state, so an // abandoned session locks the owner out until the server deadline. A best- // effort cancel frees that reservation now, so the same owner can start a new // login and does not hit the "too many active sessions" cap. The server // removes a terminal session, so a 404 means the session is already gone. Treat // that 404 the same as a successful cancel. This is a fire-and-forget cleanup, // so it drops every error. The manual Cancel button uses the `cancelLogin` // mutation instead, because that path also returns the panel to its idle start // state. const releaseServerSession = useCallback( (id: string) => { void agentsApi.cancelClaudeSetupTokenLogin(companyId, id).catch(() => { // Drop the error. A 404 means the server already removed the session. A // cleanup path cannot surface any other error, so it stays silent. }); }, [companyId], ); // Both polls run only while a session is active and the client cap has not // passed. The timeout stops the polls, so the panel never polls forever. A // status 404 also stops the polls: the server cleaned up the session, so the // panel enters a terminal failure state instead. const pollingEnabled = Boolean(sessionId) && !timedOut && !statusGone; const statusQuery = useQuery({ queryKey: ["claude-setup-token-status", companyId, sessionId], queryFn: () => agentsApi.getClaudeSetupTokenLoginStatus(companyId, sessionId!), enabled: pollingEnabled, // A status 404 is terminal. The server removes a cleaned-up session at once, // so a retry cannot recover it. Stop at once and fail loudly. retry: (failureCount, error) => { if (error instanceof ApiError && error.status === 404) return false; return failureCount < 3; }, refetchInterval: (query) => { if (timedOut || statusGone) return false; const status = query.state.data?.status; return status && ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) ? false : ADAPTER_LOGIN_POLL_INTERVAL_MS; }, }); // Fail loudly on a status 404. The server cleans up the reservation, the row, // and the in-memory session at once on any non-stored terminal state, so the // status route returns 404 when a login fails and the server cleanup wins the // race against the next poll. React Query keeps the last successful data on // error, so without this branch the panel would hold stale data and show // nothing. Enter the terminal failure state, which stops both polls. useEffect(() => { const error = statusQuery.error; if (error instanceof ApiError && error.status === 404) { setStatusGone(true); } }, [statusQuery.error]); // Poll the guarded prompt route until it returns the authorization URL. The // route returns 404 until the URL is ready, so the panel treats a 404 as // not-ready and keeps polling. const promptQuery = useQuery({ queryKey: ["claude-setup-token-prompt", companyId, sessionId], enabled: pollingEnabled && !authorizationUrl, queryFn: async () => { try { return await agentsApi.getClaudeSetupTokenLoginPrompt(companyId, sessionId!); } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } }, refetchInterval: (query) => timedOut || query.state.data?.authorizationUrl ? false : ADAPTER_LOGIN_POLL_INTERVAL_MS, }); useEffect(() => { const url = promptQuery.data?.authorizationUrl ?? null; if (url) setAuthorizationUrl(url); // The advisory rides the same guarded prompt read as the URL. Latch it, so a // later poll does not clear the disclaimer. if (promptQuery.data?.transportAdvisory) setTransportInsecure(true); }, [promptQuery.data]); const submitCode = useMutation({ mutationFn: (code: string) => agentsApi.submitClaudeSetupTokenBrowserCode(companyId, sessionId!, code), onError: (error) => { setStartError(error instanceof Error ? error.message : "Could not submit the browser code."); }, }); const completeLogin = useMutation({ mutationFn: () => agentsApi.completeClaudeSetupTokenLogin(companyId, sessionId!), onSuccess: (result) => { // The server reached the `stored` state. Hold the non-secret claim and // report it to the parent. The response carries no token. setStoredSessionId(result.storedSessionId); onStored?.(result.storedSessionId); }, onError: () => { setCompletionFailed(true); }, }); const status = statusQuery.data?.status ?? startLogin.data?.status ?? null; // The server deadline for the active session (ISO string). The status route // and the start response both carry it. The panel drives the client cutoff // from this value, so the client and the server share one deadline. const expiresAt = statusQuery.data?.expiresAt ?? startLogin.data?.expiresAt ?? null; // Complete the login once the server authenticates the session. The completion // read returns the non-secret stored-session claim. Fire it once per session. const completeLoginRef = useRef(completeLogin.mutate); completeLoginRef.current = completeLogin.mutate; useEffect(() => { if (status === "authenticated" && !completionStartedRef.current) { completionStartedRef.current = true; completeLoginRef.current(); } }, [status]); const isStored = storedSessionId !== null; const isFailure = completionFailed || statusGone || Boolean(status && CLAUDE_LOGIN_FAILURE_STATUSES.has(status)); const isCompleting = status === "authenticated" && !isStored && !completionFailed; const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut; const startDisabled = startLogin.isPending || isActive; // Hold the active session id for the unmount cleanup. The panel updates it on // every render. When the panel unmounts, or the parent removes it as the login // closes, with an active, non-terminal session, the cleanup releases that // session on the server. The ref is null once the session leaves the active // state, so the cleanup never cancels a session the server already removed. const activeSessionRef = useRef(null); activeSessionRef.current = isActive ? sessionId : null; useEffect(() => { return () => { const id = activeSessionRef.current; if (id) releaseServerSession(id); }; }, [releaseServerSession]); // Cap the active login at the server deadline. The timer arms when the login // becomes active and clears when the login leaves the active state (a terminal // status, a stored success, or a new login). It re-arms when `expiresAt` // arrives or changes. When it fires, the panel enters the timed-out state. The // `isActive` guard already excludes `timedOut`, so the timer does not re-arm // after it fires. // // The delay comes from the server `expiresAt`, so the client cutoff matches // the server session budget instead of a fixed short window. While `expiresAt` // has not arrived for the live session, the panel uses a generous failsafe, so // a stuck poll still stops. useEffect(() => { if (!isActive) return; const deadlineMs = expiresAt !== null ? new Date(expiresAt).getTime() : Number.NaN; const remainingMs = Number.isFinite(deadlineMs) ? Math.max(0, deadlineMs - Date.now()) : CLAUDE_LOGIN_FAILSAFE_TIMEOUT_MS; const timer = setTimeout(() => { // Release the server session first, then show the timed-out state. The // cancel frees the server reservation now, so an immediate retry by the // same owner starts a new session instead of a lockout on the per-owner // cap. The panel keeps the timed-out state, so the user sees why the login // stopped. if (sessionId) releaseServerSession(sessionId); setTimedOut(true); }, remainingMs); return () => clearTimeout(timer); }, [isActive, expiresAt, sessionId, releaseServerSession]); const trimmedCode = browserCode.trim(); const canSubmit = isActive && Boolean(authorizationUrl) && !isCompleting && isValidBrowserCode(trimmedCode) && !submitCode.isPending; const handleSubmit = () => { if (!canSubmit) return; submitCode.mutate(trimmedCode); // Clear the browser code right after submit, so the secret never lingers in // the input. setBrowserCode(""); }; return (
Sign in to the environment
{isActive && ( )} {/* Apply the existing stored login with no new login round trip. The affordance shows only when the status route reports a stored value and no active or completed login runs in this panel. */} {onApplyStored && storedToken && !isActive && !isStored && !appliedStored && ( )}
{/* When the owner already has a stored Claude login, the panel applies it first and does not force a fresh login. Use saved login binds the stored token; a replacement login rotates it under the captured version. */} {storedToken && !isActive && !isStored && !appliedStored && (
You have a saved Claude login. Use it to bind this agent, or log in again to replace the stored token.
)} {appliedStored && (
The saved Claude login is bound to this agent now.
)} {startError && (
{startError}
)} {/* One live region announces the loading, prompt, completing, and terminal states, so a screen reader reports each transition. */}
{isActive && !authorizationUrl && !isCompleting && (
Preparing the login…
)} {isActive && authorizationUrl && !isCompleting && (
{transportInsecure && (
This connection is not encrypted. The login code travels in clear text on this network. Continue only on a network you trust.
)}
Open the authorization page, then enter the browser code it shows.
Authorization URL
{authorizationUrl}
Browser code
setBrowserCode(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); handleSubmit(); } }} className="flex-1 min-w-0 rounded-md border border-border bg-background px-2 py-1 font-mono text-xs text-foreground" />
)} {isCompleting && (
Completing the login…
)} {isStored && (
Authenticated. The environment has credentials now.
)} {isFailure && (
{CLAUDE_LOGIN_FAILED_MESSAGE}
)} {timedOut && !isFailure && !isStored && (
{CLAUDE_LOGIN_TIMED_OUT_MESSAGE}
)}
); } export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) { const statusLabel = result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed"; const statusClass = result.status === "pass" ? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10" : result.status === "warn" ? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10" : "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10"; return (
{statusLabel} {new Date(result.testedAt).toLocaleTimeString()}
{result.checks.map((check, idx) => (
{check.level} · {check.message} {check.detail && ({check.detail})} {check.hint && Hint: {check.hint}}
))}
); } /* ---- Internal sub-components ---- */ export function AdapterTypeDropdown({ value, onChange, disabledTypes, }: { value: string; onChange: (type: string) => void; disabledTypes: Set; }) { const [open, setOpen] = useState(false); const selectedDisplay = getAdapterDisplay(value); const adapterList = useMemo( () => listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter( (item) => !disabledTypes.has(item.value), ), [disabledTypes], ); return ( {adapterList.map((item) => ( ))} ); } function ExperimentalBadge() { return ( Experimental ); } export function ModelDropdown({ models, value, onChange, open, onOpenChange, allowDefault, required, groupByProvider, creatable, detectedModel, detectedModelCandidates, onDetectModel, onRefreshModels, refreshingModels, detectModelLabel, emptyDetectHint, defaultLabel, }: { models: AdapterModel[]; value: string; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; allowDefault: boolean; required: boolean; groupByProvider: boolean; creatable?: boolean; detectedModel?: string | null; detectedModelCandidates?: string[]; onDetectModel?: () => Promise; onRefreshModels?: () => Promise; refreshingModels?: boolean; detectModelLabel?: string; emptyDetectHint?: string; defaultLabel?: string; }) { const [modelSearch, setModelSearch] = useState(""); const [detectingModel, setDetectingModel] = useState(false); const selected = models.find((m) => m.id === value); const manualModel = modelSearch.trim(); const canCreateManualModel = Boolean( creatable && manualModel && !models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()), ); // Model IDs already shown as detected/candidate badges — exclude from regular list const promotedModelIds = useMemo(() => { const set = new Set(); if (detectedModel) set.add(detectedModel); for (const c of detectedModelCandidates ?? []) { if (c) set.add(c); } return set; }, [detectedModel, detectedModelCandidates]); const filteredModels = useMemo(() => { return models.filter((m) => { if (promotedModelIds.has(m.id)) return false; if (!modelSearch.trim()) return true; const q = modelSearch.toLowerCase(); const provider = extractProviderId(m.id) ?? ""; return ( m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q) || provider.toLowerCase().includes(q) ); }); }, [models, modelSearch, promotedModelIds]); const groupedModels = useMemo(() => { if (!groupByProvider) { return [ { provider: "models", entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), }, ]; } const map = new Map(); for (const model of filteredModels) { const provider = extractProviderId(model.id) ?? "other"; const group = map.get(provider) ?? []; group.push(model); map.set(provider, group); } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([provider, entries]) => ({ provider, entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), })); }, [filteredModels, groupByProvider]); async function handleDetectModel() { if (!onDetectModel) return; setDetectingModel(true); try { const nextModel = await onDetectModel(); if (nextModel) { onChange(nextModel); onOpenChange(false); setModelSearch(""); } } finally { setDetectingModel(false); } } return ( { onOpenChange(nextOpen); if (!nextOpen) setModelSearch(""); }} >
setModelSearch(e.target.value)} autoFocus /> {modelSearch && ( )}
{onDetectModel && !modelSearch.trim() && ( )} {onRefreshModels && !modelSearch.trim() && ( )} {value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && ( )} {detectedModel && detectedModel !== value && ( )} {detectedModelCandidates ?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value) .map((candidate) => { const entry = models.find((m) => m.id === candidate); return ( ); })}
{allowDefault && ( )} {canCreateManualModel && ( )} {groupedModels.map((group) => (
{groupByProvider && (
{group.provider} ({group.entries.length})
)} {group.entries.map((m) => ( ))}
))} {filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (

{onDetectModel ? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.") : "No models found."}

)}
); } function CheapModelSection({ enabled, model, models, adapterType, adapterDefaultModel, onEnabledChange, onModelChange, open, onOpenChange, }: { enabled: boolean; model: string; models: AdapterModel[]; adapterType: string; adapterDefaultModel: string; onEnabledChange: (next: boolean) => void; onModelChange: (next: string) => void; open: boolean; onOpenChange: (open: boolean) => void; }) { const placeholderHint = adapterDefaultModel ? `Adapter default · ${adapterDefaultModel}` : "No adapter default — choose a cheaper model"; return (
Cheap model

Used when a run requests the cheap profile (e.g. routine summaries). The primary model stays unchanged.

{enabled ? ( ) : null} {enabled && !model && adapterDefaultModel ? (

No explicit cheap model selected — runtime falls back to {adapterDefaultModel}.

) : null} {enabled && !model && !adapterDefaultModel ? (

No cheap model selected and the adapter has no default. Cheap-lane runs will continue on the primary model with a fallback note.

) : null}
); } function ThinkingEffortDropdown({ value, options, onChange, open, onOpenChange, }: { value: string; options: ReadonlyArray<{ id: string; label: string }>; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; }) { const selected = options.find((option) => option.id === value) ?? options[0]; return ( {options.map((option) => ( ))} ); }