import { testAgentSetup } from "@/lib/test-agent-setup"; import { RuntimeTestCard } from "./RuntimeTestCard"; import { useState, useEffect, useRef, useMemo, useCallback, Children, isValidElement, type ReactNode } from "react"; import type { AdapterConfigSection } from "../adapters/types"; import { useConfigSchema } from "../adapters/schema-config-fields"; import { schemaFieldSection } from "../adapters/config-sections"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Agent, AdapterAuthSessionPrompt, AdapterAuthSessionStatus, CodexAccountBindingClaim, 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, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CLAUDE_LOCAL_MODEL } from "@paperclipai/adapter-claude-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, Bug } from "lucide-react"; import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils"; import { copyTextToClipboard } from "../lib/clipboard"; import { connectSourceName, OnboardingLoginCard, OnboardingCardField, OnboardingLoginCodeRow, type AdapterLoginChrome, } from "./AdapterLoginChrome"; 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 { 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"; import { codexReasoningEffortOptions } from "../lib/codex-reasoning-effort"; /* ---- 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 { PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, paperclipRunnerTransitionConfig, 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; compactTestFeedback?: boolean; showCreateRunPolicySection?: boolean; hideInstructionsFile?: boolean; /** Allow instance administrators to configure short-lived raw provider capture. */ canConfigureProviderTrace?: 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"; /** Keep variable bindings beside secret access in a unified edit surface. */ environmentVariablesPlacement?: "configuration" | "secrets"; /** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */ sectionLayout?: "inline" | "cards"; /** Optional settings composition; sorting changes DOM order as well as visual order. */ sectionOrder?: readonly string[]; sectionTitles?: Record; } & ( | { 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: {}, debug: {}, 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" || adapterType === "paperclip_runner" || adapterType === "opencode_local"; } export function resolvePaperclipRunnerTransitionModel( previousAdapterType: string, previousModel: unknown, ): string { return paperclipRunnerTransitionConfig(previousAdapterType, previousModel).model as string; } 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.debug).length > 0 || Object.keys(o.runtime).length > 0 ); } /** * Structural equality for overlay entry values. Overlay values are * JSON-shaped (scalars, env maps, argument arrays), so a reference compare * alone would keep an edit-then-restore of a structured value falsely dirty * after a refresh subtracts the persisted snapshot. */ export function overlayValuesEqual(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true; if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((item, index) => overlayValuesEqual(item, b[index])); } if ( typeof a === "object" && a !== null && !Array.isArray(a) && typeof b === "object" && b !== null && !Array.isArray(b) ) { const aEntries = Object.entries(a as Record); const bRecord = b as Record; return ( aEntries.length === Object.keys(bRecord).length && aEntries.every(([key, value]) => key in bRecord && overlayValuesEqual(value, bRecord[key])) ); } return false; } /** * Remove from `current` every entry `persisted` carried with a structurally * equal value, keeping entries the user added or changed after `persisted` * was snapshotted. The refresh that follows a background save consumes this * so edits made while that save was in flight survive as pending dirty state * instead of being wiped with the rest of the overlay. */ export function subtractPersistedOverlay( current: AgentConfigOverlay, persisted: AgentConfigOverlay, ): AgentConfigOverlay { const subtractGroup = ( currentGroup: Record, persistedGroup: Record, ): Record => Object.fromEntries( Object.entries(currentGroup).filter( ([field, value]) => !(field in persistedGroup) || !overlayValuesEqual(value, persistedGroup[field]), ), ); return { identity: subtractGroup(current.identity, persisted.identity), ...(current.adapterType !== undefined && current.adapterType !== persisted.adapterType ? { adapterType: current.adapterType } : {}), adapterConfig: subtractGroup(current.adapterConfig, persisted.adapterConfig), heartbeat: subtractGroup(current.heartbeat, persisted.heartbeat), debug: subtractGroup(current.debug, persisted.debug), runtime: subtractGroup(current.runtime, persisted.runtime), }; } /* ---- 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 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; } function ConfigSections({ order, className, children }: { order?: readonly string[]; className: string; children: ReactNode; }) { if (!order) return
{children}
; const rank = (child: ReactNode) => { const key = isValidElement<{ "data-config-section"?: string }>(child) ? child.props["data-config-section"] : undefined; const index = key ? order.indexOf(key) : -1; return index < 0 ? order.length : index; }; const sections = Children.toArray(children).sort((a, b) => rank(a) - rank(b)); return
{sections}
; } /* ---- 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 && !props.compactTestFeedback; const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange; const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; const hideInstructionsFile = props.hideInstructionsFile ?? false; const canConfigureProviderTrace = props.canConfigureProviderTrace === true; 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 adapterPickerDisabledTypes = useMemo(() => { const next = new Set(disabledTypes); // Fail closed while settings load. Existing native agents still render // their current value in edit mode, but the picker does not offer a fresh // native selection until the explicit experimental opt-in is known true. if (experimentalSettings?.enableNativeRunner !== true) { next.add("paperclip_runner"); } return next; }, [disabledTypes, experimentalSettings?.enableNativeRunner]); 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 [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false); const [environmentEditorKey, setEnvironmentEditorKey] = useState(0); const agentRef = useRef(null); // The overlay snapshot a background account-binding save persisted. The form // stays editable while that save is in flight, so the agent refresh that // follows it must not wipe edits made during the save. The refresh subtracts // only what the save persisted; a user-initiated Save leaves the snapshot // null and keeps the full wipe. An UNRELATED refresh can land while the save // is still in flight — that refresh does not carry the persisted binding // yet, so it must neither consume the snapshot nor subtract it: subtracting // would drop the binding entry from the overlay while `props.agent` also // lacks it, and an ordinary Save racing the binding refresh would then // replace the config without the binding and undo the just-persisted bind. // The overlay stays untouched until the save settles; the refresh after // settlement consumes the snapshot and subtracts it. const backgroundSaveOverlayRef = useRef(null); const backgroundSaveInFlightRef = useRef(false); // Clear overlay when agent data refreshes (after save) useEffect(() => { if (!isCreate) { if ( agentRef.current !== null && props.agent !== agentRef.current && !backgroundSaveInFlightRef.current ) { const persisted = backgroundSaveOverlayRef.current; backgroundSaveOverlayRef.current = null; setOverlay((prev) => persisted ? subtractPersistedOverlay(prev, persisted) : { ...emptyOverlay }, ); } agentRef.current = props.agent; } }, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps const isDirty = !isCreate && (isOverlayDirty(overlay) || environmentDraftDirty); type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "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 }); setEnvironmentDraftDirty(false); setEnvironmentEditorKey(key => key + 1); }, []); 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 debug = !isCreate ? ((runtimeConfig.debug ?? {}) 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(); }; // Edit mode: a Codex login that signed in to a DIFFERENT account than the // company default cannot take effect through the shared company home — the // promotion never displaces another account's claim there. Bind this // agent's CODEX_HOME to the login's account-home secret and persist at // once, the same one-step shape as the Claude stored-login bind above. // Same-account logins skip the bind on purpose: the company-home refresh // already carried them, and an unbound agent keeps following the company // default across later credential rotations. No claim flag is needed — // the secret already exists company-scoped, so this is an ordinary // secret-reference binding through the normal agent-update patch. const handleCodexAccountBindingEdit = async (claim: CodexAccountBindingClaim) => { if (isCreate || !claim.companyIdentityDiffers) return; const flushedEnv = flushEnvironmentDraft(); const baseEnv = flushedEnv ?? (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record)); const nextEnv: Record = { ...baseEnv, CODEX_HOME: { type: "secret_ref", secretId: claim.secretId, version: "latest" }, }; const nextOverlay: AgentConfigOverlay = { ...overlay, adapterConfig: { ...overlay.adapterConfig, env: nextEnv }, }; setOverlay(nextOverlay); // This save runs in the background while the form stays editable. Record // exactly what it persists so the agent refresh it triggers keeps edits // made during the save (see the refresh effect) instead of wiping them // with the persisted entries. The in-flight flag protects the snapshot // from an unrelated refresh landing mid-save. A failed save never // refreshes the agent with the binding, so clear the snapshot there — a // later unrelated refresh then wipes normally. backgroundSaveOverlayRef.current = nextOverlay; backgroundSaveInFlightRef.current = true; try { await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay)); } catch (err) { backgroundSaveOverlayRef.current = null; throw err; } finally { backgroundSaveInFlightRef.current = false; } 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"; const runnerProvider = adapterType === "paperclip_runner" ? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex" : eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex")) : undefined; // Fetch adapter models for the effective provider, including unsaved changes. const modelQueryKey = selectedCompanyId ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, runnerProvider) : ["agents", "none", "adapter-models", adapterType]; const { data: fetchedModels, error: fetchedModelsError, } = useQuery({ queryKey: modelQueryKey, queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, { environmentId: currentDefaultEnvironmentId || null, provider: runnerProvider, }), 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" && adapterType !== "paperclip_runner"), }); 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); const [configurationAdvancedOpen, setConfigurationAdvancedOpen] = useState(false); const configSchema = useConfigSchema(adapterType); const renderAdapterFields = (section: AdapterConfigSection) => ( <> {adapterType === "claude_local" && } ); // Popover states const [modelOpen, setModelOpen] = useState(false); const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false); 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); } 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; // 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 adapterConfig = buildAdapterConfigForTest(adapterConfigPatch); const agentId = isCreate ? undefined : props.agent.id; if (props.compactTestFeedback) { const providerAdapter = adapterType === "paperclip_runner" ? adapterConfig.provider === "codex" ? "codex_local" : adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local" : adapterType : adapterType; return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, environmentId }); } return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, environmentId }); }, }); 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, environmentId: currentDefaultEnvironmentId || null, provider: runnerProvider }); 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" : adapterType === "pi_local" ? "thinking" : "effort"; const thinkingEffortOptions = adapterType === "codex_local" ? codexReasoningEffortOptions(currentModelId, "Auto").map((option) => ({ id: option.value, label: option.label, })) : adapterType === "cursor" ? cursorModeOptions : adapterType === "opencode_local" ? openCodeThinkingEffortOptions : adapterType === "kimi_local" ? kimiThinkingEffortOptions : adapterType === "pi_local" ? [{ id: "", label: "Auto" }, ...["off", "minimal", "low", "medium", "high", "xhigh"].map(id => ({ id, label: id }))] : 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", thinkingEffortKey, String(config[thinkingEffortKey] ?? "")); 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; 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, }); } const environmentVariablesEditor = ( ) : (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) } /> ); if (!isCreate && props.content === "secrets") { return (
{isDirty && !props.hideInlineSave && (
Unsaved changes
)} {props.environmentVariablesPlacement === "secrets" && (
{cards ?

Environment variables

:
Environment variables
}
{environmentVariablesEditor}
)}
{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 ?

{props.sectionTitles?.["identity"] ?? "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…" /> {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 ?

{props.sectionTitles?.["adapter"] ?? "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; } else if (t === "paperclip_runner") { nextValues.model = DEFAULT_CODEX_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, 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 : t === "paperclip_runner" ? resolvePaperclipRunnerTransitionModel(adapterType, config.model) : "", effort: "", modelReasoningEffort: "", variant: "", mode: "", ...(t === "codex_local" ? { dangerouslyBypassApprovalsAndSandbox: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, } : t === "paperclip_runner" ? { ...paperclipRunnerTransitionConfig(adapterType, eff("adapterConfig", "model", config.model)), } : {}), }, })); } }} /> )} {showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && (testActionError || testEnvironment.error) && (
{testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : "Environment test failed")}
)} {showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && 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" />
)} {renderAdapterFields("adapter")} {isLocal && (<> { const supportedEfforts = codexReasoningEffortOptions(v, "Auto"); const clearUnsupportedEffort = adapterType === "codex_local" && Boolean(currentThinkingEffort) && !supportedEfforts.some((option) => option.value === currentThinkingEffort); if (isCreate) { set!({ model: v, ...(clearUnsupportedEffort ? { thinkingEffort: "" } : {}), }); return; } mark("adapterConfig", "model", v || undefined); if (clearUnsupportedEffort) { mark("adapterConfig", thinkingEffortKey, undefined); mark("adapterConfig", "reasoningEffort", undefined); } }} open={modelOpen} onOpenChange={setModelOpen} defaultLabel={adapterType === "claude_local" ? `Default (${DEFAULT_CLAUDE_LOCAL_MODEL})` : undefined} allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local" && adapterType !== "paperclip_runner"} required={adapterType === "opencode_local" || adapterType === "pi_local"} groupByProvider={adapterType === "opencode_local" || adapterType === "pi_local"} creatable detectedModel={detectedModel} detectedModelCandidates={[]} onDetectModel={adapterType === "opencode_local" || adapterType === "paperclip_runner" ? 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}.

)} {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.

)} )} )}
{/* ---- Configuration ---- */} {(
{cards ?

Configuration

:
Configuration
}
{!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.
)} {renderAdapterFields("configuration")} {(isLocal || adapterType === "process" || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "advanced")) && ( setConfigurationAdvancedOpen(!configurationAdvancedOpen)} >
{isLocal && (<> {/* 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$/, "") } /> )} isCreate ? set!({ extraArgs: v }) : mark("adapterConfig", "extraArgs", v?.trim() ? parseCommaArgs(v) : null) } className={inputClass} placeholder="e.g. --verbose, --foo=bar" /> )} {renderAdapterFields("advanced")}
)}
)} {props.environmentVariablesPlacement !== "secrets" && (isLocal || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "environment")) && (
{cards ?

Environment variables

:
Environment variables
}
{isLocal ? environmentVariablesEditor : renderAdapterFields("environment")}
)} {/* ---- 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} /> setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}>
{renderAdapterFields("runPolicy")}
) : !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)} >
{renderAdapterFields("runPolicy")} {isLocal && (<> {/* Edit-only: timeout + grace period */} {!isCreate && ( <> {!configSchema?.fields.some((field) => field.key === "timeoutSec") && ( mark("adapterConfig", "timeoutSec", v)} immediate className={inputClass} /> )} {!configSchema?.fields.some((field) => field.key === "graceSec") && ( mark("adapterConfig", "graceSec", v)} immediate className={inputClass} /> )} )} )} 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} {/* ---- Debugging ---- */} {!isCreate && canConfigureProviderTrace ? (
{cards ? (

Debugging

) : (
Debugging
)}
("debug", "providerTrace", debug.providerTrace) === "raw"} onChange={(enabled) => mark("debug", "providerTrace", enabled ? "raw" : undefined) } /> {eff("debug", "providerTrace", debug.providerTrace) === "raw" ? (
Raw tracing is on for future runs. Paperclip keeps at most 64 MiB per run and automatically deletes it after 24 hours.
) : null}
) : null} {props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && ( )}
); } // 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. // `autoStart`, `onCancel`, `onConnected` and `chrome` are what the onboarding // connect step needs, and each is off or absent by default so the two settings // surfaces that render this panel keep the behaviour they have. // // They are props on the existing panels rather than a second implementation // because the part onboarding needs unchanged is the whole of it: the session // start, the two polls, the server deadline, the one-shot completion read. A // copy drawn to the new design would have had to reproduce all of that // correctly, and the first thing to rot would have been the timeout and // cleanup paths, which are the ones nobody exercises by hand. export type AdapterLoginPanelProps = AdapterLoginDescriptor & { onStored?: (storedSessionId: string) => void; onApplyStored?: () => void; // Applies the non-secret Codex account-binding claim from an authenticated // owner read: the company secret that names the signed-in account's own // home. The panel calls this only when the company default home stayed on a // DIFFERENT account — the one case where the login cannot take effect // through the shared company home — and it AWAITS the handler, rendering // saving/bound/failed states with an explicit Retry on failure, so a // rejected save is never silently swallowed. The claim never carries a // token byte or an account identifier. onAccountBinding?: (claim: CodexAccountBindingClaim) => void | Promise; // Start the login on mount instead of waiting for a press. The connect step's // footer button is the press — by the time the panel is rendered there, the // customer has already asked for this. autoStart?: boolean; // The login reached its success state. Onboarding advances on this, which is // why the `onboarding` chrome draws no success state of its own — the screen // it would appear on is already gone. onConnected?: () => void; // The pasted code went to the server. Fires as the submit starts rather than // when the login finishes, so a caller can show the work the moment the // customer has done their part: the round trip to `onConnected` is a poll // and a completion read, long enough to read as nothing having happened. onCodeSubmitted?: () => void; // A submitted code did not become a stored login — the submit was refused, // the completion failed, or the session failed or ran out of time. The pair // of `onCodeSubmitted`, so a caller that showed work can stop showing it. onSubmitFailed?: () => void; chrome?: AdapterLoginChrome; /** * The address the customer has to open, once the server has produced one. * * The one fact about a running login that the step needs outside the card: * its own button is what sends the customer there, and a prompt arriving is * what moves the step from waiting to ready. Everything else it needs the * panel already does — the paste submits itself, and the submit and how it * ended are reported through `onCodeSubmitted`, `onSubmitFailed` and * `onConnected` — so this stays a single value rather than a whole session * handed upward. */ onPromptReady?: (authorizationUrl: string | null) => 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. /** * The account a source signs in to, named where one is known. * * "Sign in to the environment" describes the plumbing — a login performed inside * a sandbox — and is the honest label when the provider is unknown. But for the * two sources onboarding offers, the customer is signing in to Anthropic or to * OpenAI, and naming that is what tells them which password manager entry to * reach for. The generic wording stays for anything not listed, where a guess * would be worse than a description. */ const ADAPTER_LOGIN_PROVIDER: Record = { claude_local: "Anthropic", codex_local: "OpenAI", }; function adapterLoginTitle(adapterType: string): string { const provider = ADAPTER_LOGIN_PROVIDER[adapterType]; return provider ? `Sign in to ${provider}` : "Sign in to the environment"; } 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, autoStart, onConnected, onAccountBinding, chrome = "panel", onPromptReady, }: 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); // The cross-account bind's own lifecycle (see the binding block below). // Declared with the panel's state because `startDisabled` reads it: a // saving bind blocks a new Sign in. const [accountBindState, setAccountBindState] = useState<"idle" | "saving" | "bound" | "failed">( "idle", ); // True for the session currently held in `sessionId` when it came from the // owner-scoped resume read rather than a fresh `startLogin`. It marks the // one case that needs the extra release-on-error path below: a session this // browser instance did not just start, so a broken poll cannot fall back to // the ordinary "let the user press Sign in again" recovery — the owner has // no local memory of ever starting it. const resumedRef = useRef(false); const startLogin = useMutation({ mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId }), onSuccess: (session) => { resumedRef.current = false; setStartError(null); setLatchedPrompt(null); // A fresh login is a fresh bind decision: clear the previous session's // bind narration so its outcome cannot masquerade as this session's. setAccountBindState("idle"); setSessionId(session.sessionId); }, onError: (error) => { setStartError(error instanceof Error ? error.message : "Could not start the login."); }, }); // Reset local state, so the panel returns to its idle start state and the // Sign in button is available again. const clearActiveSession = useCallback(() => { resumedRef.current = false; setSessionId(null); setLatchedPrompt(null); setStartError(null); }, []); const cancelLogin = useMutation({ mutationFn: () => agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId!), onSuccess: clearActiveSession, onError: (error) => { setStartError(error instanceof Error ? error.message : "Could not cancel the login."); }, }); // Read the caller's active session on mount, with no session id, so the // browser rediscovers its own session after a reload with no local state. A // 404 means no active session for the caller. const activeSessionQuery = useQuery({ queryKey: ["adapter-login-active-session", companyId, adapterType], queryFn: async () => { try { return await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType); } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } }, retry: false, // Never answered from cache. This read decides whether to adopt a running // session or start a new one, and a cached "none" from an earlier mount is // exactly wrong after Back: the panel would read `isFetched` immediately, // see the stale null, and start a second login while the refetch was still // in flight — which the per-owner cap then rejects. gcTime: 0, staleTime: 0, }); // While the panel releases a resumed session it cannot recover (see below), // it keeps showing the login as active rather than dropping back to idle, so // it does not clear local state before the release finishes. const [releasingResumedSession, setReleasingResumedSession] = useState(false); const statusQuery = useQuery({ queryKey: ["adapter-login-status", companyId, adapterType, sessionId], queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!), enabled: Boolean(sessionId) && !releasingResumedSession, // A status 404 is unrecoverable: the server removed the row, so a retry // cannot bring it back. Stop at once and fail loudly. retry: (failureCount, error) => { if (error instanceof ApiError && error.status === 404) return false; return failureCount < 3; }, 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; // A saving bind also blocks a new Sign in: the bind is an agent-update save, // and a second login started while it is in flight could finish its own // save first — the older save would then land last and silently revert the // agent to the previous account while the panel reports the newer bind. // Serializing at the only entry point is the whole fix; the panel has no // other way to start a login mid-save. const startDisabled = startLogin.isPending || isActive || accountBindState === "saving"; // Adopt the caller's active session once, on mount. This is what makes a // page reload keep the session: with no local state at all, the panel would // otherwise show its idle start state even though the server still holds an // active login for this owner. const resumeAttemptedRef = useRef(false); useEffect(() => { if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return; resumeAttemptedRef.current = true; const active = activeSessionQuery.data; if (!active) return; resumedRef.current = true; setStartError(null); setLatchedPrompt(active.prompt ?? null); setSessionId(active.sessionId); }, [activeSessionQuery.isFetched, activeSessionQuery.data]); // A resumed session's status poll found the session already gone: the read // that discovered it and the poll that tried to use it raced, and the // session lost. The panel cannot resume it, and there is no unmount cleanup // left to fall back on, so it releases the reservation itself and waits for // that release before it returns to the idle start state. useEffect(() => { const error = statusQuery.error; if (!(error instanceof ApiError && error.status === 404)) return; if (!resumedRef.current || releasingResumedSession) return; setReleasingResumedSession(true); const id = sessionId; void (async () => { if (id) { await agentsApi.cancelAdapterAuthLogin(companyId, adapterType, id).catch(() => { // The session is already gone either way; nothing more to do. }); } setReleasingResumedSession(false); clearActiveSession(); })(); }, [statusQuery.error, releasingResumedSession, sessionId, companyId, adapterType, clearActiveSession]); // Start once, on mount, when the caller has already taken the press, and // only once the resume read has answered: a resumed session takes over // instead of a fresh start. The ref is the guard rather than the mutation's // own pending flag: `startLogin` settles, and without a latch a re-render // after it settles would read "not pending, no session yet" during the gap // before the session id lands and start a second login the server would // count against the per-owner cap. const autoStartedRef = useRef(false); const startLoginRef = useRef(startLogin.mutate); startLoginRef.current = startLogin.mutate; useEffect(() => { if (!autoStart || autoStartedRef.current) return; // A failed lookup is not proof that no session exists: only a successful // lookup is. Show the failure to the user instead of starting a second // login the server would reject against the per-owner cap. if (activeSessionQuery.isError) { autoStartedRef.current = true; setStartError( activeSessionQuery.error instanceof Error ? activeSessionQuery.error.message : "Could not check for an active login.", ); return; } if (!activeSessionQuery.isSuccess) return; autoStartedRef.current = true; if (activeSessionQuery.data) return; startLoginRef.current(); }, [ autoStart, activeSessionQuery.isSuccess, activeSessionQuery.isError, activeSessionQuery.data, activeSessionQuery.error, ]); // Report success upward once. `authenticated` is this panel's terminal // success: unlike the Claude login there is no completion read after it, so // the status is the whole of the news. const connectedRef = useRef(false); const onConnectedRef = useRef(onConnected); onConnectedRef.current = onConnected; useEffect(() => { if (status !== "authenticated" || connectedRef.current) return; connectedRef.current = true; onConnectedRef.current?.(); }, [status]); // Drive the account-binding hand-off as a visible state machine, not a // fire-and-forget latch. The bind saves the agent, and the status poll // stops at the terminal state — so a rejected save behind a silently // latched claim would leave nothing to re-fire it and no way to retry. // A cross-account claim moves saving → bound | failed, and failed renders // an explicit Retry that re-runs the same handler with the same claim. // Latched per SESSION, not per mount: the terminal state re-enables Sign in // inside the same mounted panel, and a second cross-account login must run // its own bind — a mount-scoped boolean would silently skip it and leave // the agent on the previous account. const accountBindSessionRef = useRef(null); const onAccountBindingRef = useRef(onAccountBinding); onAccountBindingRef.current = onAccountBinding; const accountBinding = statusQuery.data?.codexAccountBinding ?? null; const runAccountBinding = useCallback(async (claim: CodexAccountBindingClaim) => { const handler = onAccountBindingRef.current; if (!handler) return; setAccountBindState("saving"); try { await handler(claim); setAccountBindState("bound"); } catch { setAccountBindState("failed"); } }, []); useEffect(() => { if (status !== "authenticated" || !sessionId) return; if (accountBindSessionRef.current === sessionId) return; if (!accountBinding || !accountBinding.companyIdentityDiffers || !onAccountBindingRef.current) { return; } accountBindSessionRef.current = sessionId; void runAccountBinding(accountBinding); }, [status, sessionId, accountBinding, runAccountBinding]); // Report the prompt's URL upward, the way the submitted-browser-code panel // does. The caller's loading beat ends when this arrives, so without it the // onboarding step waits on a card that has already opened: the code is on // screen and the button stays disabled. Fires with null on mount, before the // one-time prompt lands, which is the same null the caller starts from. const onPromptReadyRef = useRef(onPromptReady); onPromptReadyRef.current = onPromptReady; useEffect(() => { onPromptReadyRef.current?.(prompt?.url ?? null); }, [prompt]); if (chrome === "onboarding") { const failed = isTerminal && status && status !== "authenticated"; return ( {/* The same destination as the step's own button. Two ways to one link: the button for the customer following the flow, the anchor for anyone finishing in another browser. */} Sign in to {connectSourceName(adapterType)} {" by providing the authorization code below"} } > {startError ? (

{startError}

) : failed ? (

{status === "timed_out" ? "The login timed out. Start it again." : status === "cancelled" ? "The login was cancelled." : "The login did not finish. Start it again."}

) : ( )}
); } return (
{/* `gap`, not `space-y`: the live region below collapses to `display: none` whenever it has nothing to announce, and `space-y` would still put its 8px on the row above — dead space inside the card that pushes the row off centre. A gap only applies between children that render. */}
{adapterLoginTitle(adapterType)}
{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...
)} {isActive && prompt && (
Copy the code, then open the authentication page.
{/* Code first, then the URL, and the sentence and the numbering both say so. This used to run the other way, on the reasoning that handing over a code before the page it belongs to was getting ahead of the customer. What that missed is where the two rows are used: opening the page is what leaves this screen, and the form waiting on the other side wants the code that was on this one. Reaching back for it is the step worth removing, so the code is read and copied while it is still in front of you. The onboarding card is ordered the same way and for the same reason. The Claude panel below is not, and should not be — its second row is a field to type *into*, so there the page genuinely does come first. */}
1. Code
{prompt.code}
2. Authentication URL
{prompt.url}
)} {isTerminal && status && ( )} {/* The cross-account bind's own state, below the login's success line. The bind is a second, separate save — showing it as part of the login would report success for a write that can still fail. */} {status === "authenticated" && accountBindState === "saving" && (
Binding this agent to the signed-in account...
)} {status === "authenticated" && accountBindState === "bound" && (
Agent bound to the signed-in account.
)} {status === "authenticated" && accountBindState === "failed" && (
Could not bind this agent to the signed-in account.
)}
); } // 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, adapterType, environmentId, onStored, onApplyStored, autoStart, onConnected, onCodeSubmitted, onSubmitFailed, chrome = "panel", onPromptReady, }: 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); // A code has gone to the server and has not yet come back as a stored login // or a failure. The field is locked for that stretch: the step's button is // saying "Connecting" above it, and a second paste would submit again. const [codeSubmitted, setCodeSubmitted] = 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); // True for the session currently held in `sessionId` when it came from the // owner-scoped resume read rather than a fresh `startLogin`. It marks the // one case that needs the extra release-on-error path below: a session this // browser instance did not just start, so a broken poll cannot fall back to // the ordinary "let the user press Sign in again" recovery — the owner has // no local memory of ever starting it. const resumedRef = useRef(false); const resetLocalState = () => { setStartError(null); setAuthorizationUrl(null); setTransportInsecure(false); setBrowserCode(""); setStoredSessionId(null); setCompletionFailed(false); setTimedOut(false); setStatusGone(false); setCodeSubmitted(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) => { resumedRef.current = false; 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. resumedRef.current = false; 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 uses 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], ); // Read the caller's active Claude setup-token session on mount, with no // session id, so the browser rediscovers its own session after a reload // with no local state. A 404 means no active session for the caller. const activeSessionQuery = useQuery({ queryKey: ["claude-setup-token-active-session", companyId], queryFn: async () => { try { return await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId); } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } }, retry: false, // Never answered from cache. This read decides whether to adopt a running // session or start a new one, and a cached "none" from an earlier mount is // exactly wrong after Back: the panel would read `isFetched` immediately, // see the stale null, and start a second login while the refetch was still // in flight — which the per-owner cap then rejects. gcTime: 0, staleTime: 0, }); // While the panel releases a resumed session it cannot recover (see below), // it keeps showing the login as active rather than dropping back to idle, so // it does not clear local state before the release finishes. const [releasingResumedSession, setReleasingResumedSession] = useState(false); // 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 && !releasingResumedSession; 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. // // A resumed session takes a different path: the read that discovered it and // the poll that tried to use it raced, and the session lost. There is no // unmount cleanup left to fall back on, so the panel releases the // reservation itself and waits for that release before it returns to the // idle start state, instead of trusting the 404 alone. useEffect(() => { const error = statusQuery.error; if (!(error instanceof ApiError && error.status === 404)) return; if (resumedRef.current) { if (releasingResumedSession) return; setReleasingResumedSession(true); const id = sessionId; void (async () => { if (id) { await agentsApi.cancelClaudeSetupTokenLogin(companyId, id).catch(() => { // The session is already gone either way; nothing more to do. }); } setReleasingResumedSession(false); clearActiveSession(); })(); return; } setStatusGone(true); }, [statusQuery.error, releasingResumedSession, sessionId, companyId]); // Adopt the caller's active session once, on mount. This is what makes a // page reload keep the session: with no local state at all, the panel would // otherwise show its idle start state even though the server still holds an // active login for this owner. const resumeAttemptedRef = useRef(false); useEffect(() => { if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return; resumeAttemptedRef.current = true; const active = activeSessionQuery.data; if (!active) return; resumedRef.current = true; resetLocalState(); setSessionId(active.sessionId); if (active.prompt) { setAuthorizationUrl(active.prompt.authorizationUrl); if (active.prompt.transportAdvisory) setTransportInsecure(true); } }, [activeSessionQuery.isFetched, activeSessionQuery.data]); // 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; // 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 && !codeSubmitted; const onCodeSubmittedRef = useRef(onCodeSubmitted); onCodeSubmittedRef.current = onCodeSubmitted; const onSubmitFailedRef = useRef(onSubmitFailed); onSubmitFailedRef.current = onSubmitFailed; const handleSubmit = () => { if (!canSubmit) return; // A new attempt supersedes the last attempt's error, and has to: the // failure report below watches for an error after a submit, and one left // over from before it would end this attempt the moment it began. setStartError(null); submitCode.mutate(trimmedCode); // Reported now, not when the login finishes. A stored login is a poll and a // completion read away, long enough that a button still offering "Waiting // for code" after the paste read as the paste not having registered. setCodeSubmitted(true); onCodeSubmittedRef.current?.(); // Onboarding keeps the code on screen; the panel still clears it. // // Clearing emptied the input in the same frame the paste landed, so on the // connect step the only feedback for the seconds that followed was a field // that had just gone blank — reported from staging as the paste looking // dropped, or the step looking stuck. There the field is disabled from here // on and the step's own button carries the status, so the code can stay: // `resetLocalState` clears it whenever a session starts, resumes or is // cleared, the value dies with the panel moments later, and the code is // single-use and already spent. // // The panel is not that. It sits in a form that stays open long after the // login, with its own status area doing the reporting — so there the code // does have somewhere to linger, and clearing it remains right. if (chrome !== "onboarding") setBrowserCode(""); }; // Start once, on mount, when the caller has already taken the press, and // only once the resume read has answered: a resumed session takes over // instead of a fresh start. Latched for the same reason as the // displayed-code panel: a second start would burn an owner reservation, and // here it would also rotate the stored token twice. const autoStartedRef = useRef(false); const startLoginRef = useRef(startLogin.mutate); startLoginRef.current = startLogin.mutate; useEffect(() => { if (!autoStart || autoStartedRef.current) return; // A failed lookup is not proof that no session exists: only a successful // lookup is. Show the failure to the user instead of starting a second // login the server would reject against the per-owner cap. if (activeSessionQuery.isError) { autoStartedRef.current = true; setStartError( activeSessionQuery.error instanceof Error ? activeSessionQuery.error.message : "Could not check for an active login.", ); return; } if (!activeSessionQuery.isSuccess) return; autoStartedRef.current = true; if (activeSessionQuery.data) return; startLoginRef.current(); }, [ autoStart, activeSessionQuery.isSuccess, activeSessionQuery.isError, activeSessionQuery.data, activeSessionQuery.error, ]); /** * Submit the pasted code without a press. * * Only in the onboarding chrome, and only here: this is the login where the * code comes *back* off the clipboard, so the paste is the answer and a * Submit button after it adds a step that can be missed. The displayed-code * login has no field to watch. * * Driven by the paste rather than by the value, which is the part that is * easy to get wrong. `isValidBrowserCode` looks like a completeness check and * is not one: it accepts any run of printable ASCII from a single character * up, deliberately, because the provider's exact format has never been * pinned down. Keying the submit off the value therefore fires on the first * keystroke of anyone who types the code instead of pasting it — submitting * one character, failing, and clearing the field they were typing into. * * So the paste arms it and the shape check still gates it, which leaves * typing to Enter. A paste that is not usable simply sits in the field. * * `submitCode.isPending` is inside `canSubmit` and `handleSubmit` clears the * field, so one paste can only submit once. */ const handleSubmitRef = useRef(handleSubmit); handleSubmitRef.current = handleSubmit; const autoSubmit = chrome === "onboarding"; const pastedRef = useRef(false); useEffect(() => { if (!autoSubmit || !pastedRef.current) return; pastedRef.current = false; if (!canSubmit) return; handleSubmitRef.current(); }, [autoSubmit, canSubmit, browserCode]); // Report success upward once. The `stored` state is the only success state, // which is why this watches `isStored` and not the `authenticated` status the // completion read still has to follow. const connectedRef = useRef(false); const onConnectedRef = useRef(onConnected); onConnectedRef.current = onConnected; useEffect(() => { if (!isStored || connectedRef.current) return; connectedRef.current = true; onConnectedRef.current?.(); }, [isStored]); // The other end of `onCodeSubmitted`. Any of these after a submit means the // code is not going to become a stored login, and a caller still showing // "Connecting" would otherwise spin for good. Once per submit; the field // unlocks with it. Not reset on success: the field stays locked through the // hold that follows, rather than reopening under a button saying Connecting. useEffect(() => { if (!codeSubmitted) return; if (!startError && !isFailure && !timedOut) return; setCodeSubmitted(false); onSubmitFailedRef.current?.(); }, [codeSubmitted, startError, isFailure, timedOut]); const onPromptReadyRef = useRef(onPromptReady); onPromptReadyRef.current = onPromptReady; useEffect(() => { onPromptReadyRef.current?.(authorizationUrl); }, [authorizationUrl]); if (chrome === "onboarding") { const failedNow = isFailure || timedOut; return ( Sign in to {connectSourceName(adapterType)} {" then come back and enter authorization code"} } > {/* The plain-HTTP advisory survives the redesign. It is the one thing on this card not about getting the login done, and dropping it to keep the card tidy would remove a warning about a code travelling in clear text. */} {transportInsecure && (

This connection is not encrypted. The login code travels in clear text on this network. Continue only on a network you trust.

)} {startError ? (

{startError}

) : failedNow ? (

{timedOut && !isFailure ? CLAUDE_LOGIN_TIMED_OUT_MESSAGE : CLAUDE_LOGIN_FAILED_MESSAGE}

) : ( { pastedRef.current = true; }} // Dots, not the code. It stays in the field after the paste so the // customer can see something landed, and that is all they need to // see of it. masked disabled={submitCode.isPending || isCompleting || codeSubmitted} /> )}
); } return (
{/* `gap`, not `space-y`: the live region below collapses to `display: none` whenever it has nothing to announce, and `space-y` would still put its 8px on the row above — dead space inside the card that pushes the row off centre. A gap only applies between children that render. */}
{adapterLoginTitle(adapterType)}
{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...
)} {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.
1. Authorization URL
{authorizationUrl}
2. 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 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) => ( ))} ); }