import React from "react"; import { useTranslation } from "react-i18next"; import { BrandButton } from "#/components/features/settings/brand-button"; import { AcpConflictWarnings } from "#/components/features/settings/acp-conflict-warnings"; import { AcpAuthStatusBanner } from "#/components/features/settings/acp-auth-status-banner"; import { AcpSecretField } from "#/components/features/settings/acp-secret-field"; import { I18nKey } from "#/i18n/declaration"; import { useAcpAuthStatus } from "#/hooks/query/use-acp-auth-status"; import { useAcpCredentialForm } from "#/hooks/use-acp-credential-form"; import { useActiveBackend } from "#/contexts/active-backend-context"; import { getAcpProviderDisplayName, getAcpPreferredDefaultModel, } from "#/constants/acp-providers"; import { useApplyOnboardingAgentProfile } from "#/hooks/mutation/use-apply-onboarding-agent-profile"; import { type OnboardingAgentId } from "./choose-agent-step"; interface SetupAcpSecretsStepProps { /** ACP provider whose credentials we're collecting (e.g. ``"claude-code"``). * Typed as {@link OnboardingAgentId} — the same type the onboarding modal * tracks — so a mistyped key is a compile error rather than a silently empty * form. Providers without a credentials entry (``"openhands"``) simply yield * no fields. */ providerKey: OnboardingAgentId; /** * Whether this is the currently visible onboarding slide. The modal mounts * every slide at once, so we only run the (subprocess-spinning) login probe * once the user has actually reached this step — by which point the backend * is confirmed connected. */ isActive: boolean; onBack: () => void; onNext: () => void; } /** * Onboarding credentials step for ACP providers (Claude Code, Codex, Gemini * CLI). The fields are derived from {@link getAcpProviderSecrets}: the API key * + optional base URL (from the SDK registry) plus the per-provider * credentials a *containerized* agent-server needs (Codex ``auth.json``, the * Claude OAuth token, the Gemini Vertex service-account JSON + project/location). * Each field maps 1:1 to a **global secret** whose name equals the env var the * agent-server exports into the provider subprocess, so saving here is the same * as adding the secret under Settings → Secrets. * * The step is **optional on a backend that can fall back to a host login** (a * native agent-server where the user has already run ``claude``/``codex``/ * ``gcloud`` login) and **required otherwise** — a fresh Docker container or a * cloud backend has no host login, so the agent can't authenticate without * credentials. Required-ness is capability-driven (see {@link backendRequiresAcpCredentials}): * we never block "Next" when the login probe detects an existing session, and * we never block a native dev whose host login we just can't classify. * * Empty fields are never written (a deliberate skip), and a field whose secret * already exists shows an "already saved" placeholder and is left untouched * unless the user types a replacement. */ export function SetupAcpSecretsStep({ providerKey, isActive, onBack, onNext, }: SetupAcpSecretsStepProps) { const { t } = useTranslation("openhands"); const activeBackend = useActiveBackend(); // Login detection via AcpService (provider status commands run through the // agent-server bash endpoint) — see issue #964. const { status: authStatus, isChecking: isCheckingAuth } = useAcpAuthStatus( providerKey, { enabled: isActive }, ); const { fields, values, setValue, secretExists, hasValueFor, conflicts, credentialsConfigured, consumesFileCredentials, save, isSaving, } = useAcpCredentialForm(providerKey); const applyAgentProfile = useApplyOnboardingAgentProfile(); const providerName = getAcpProviderDisplayName(providerKey) ?? providerKey; const isAuthenticated = authStatus === "authenticated"; // Required when the backend can't fall back to a host login (see component // docstring). Cloud never has one; a local backend that probes as logged-out // is a fresh container — require credentials there, but stay permissive when // the probe resolves "unknown" so a native dev is never blocked. const required = backendRequiresAcpCredentials( activeBackend.backend.kind, authStatus, ); // Satisfied once the user has an actual credential for the provider — a // masked ``secret`` field (blob, OAuth token, or API key), typed now or // previously saved. A base URL or GCP project/location alone can't // authenticate anything, so it doesn't count — and neither does a file blob // on a backend that can't materialise it (cloud, agent-canvas#1016): the // save flow warns it's orphaned, so it can't be what satisfies the gate. // An existing login also satisfies it. const satisfied = isAuthenticated || fields.some( (field) => field.secret && (!field.multiline || consumesFileCredentials) && hasValueFor(field.name), ); const blockNext = required && !satisfied; // While the probe is still classifying a local backend, hold Next: the gate // may be about to come up "unauthenticated", and the checking banner is // already showing. A probe that *completes* as "unknown" stays permissive. const nextDisabled = isSaving || blockNext || (isCheckingAuth && !satisfied); const handleNext = async () => { if (await save()) { // Land the user's ACP choice on the active AGENT profile so the next // conversation launches that provider (no LLM profile/key needed — the // subprocess owns its LLM). Best-effort; never blocks advancing. This // step never renders for "openhands" (the modal shows SetupLlmStep // there), so the guard just narrows the type for `acp_server`. if (providerKey !== "openhands") { await applyAgentProfile({ agent_kind: "acp", acp_server: providerKey, acp_model: getAcpPreferredDefaultModel(providerKey) ?? undefined, }); } onNext(); } }; return (
{t(I18nKey.ONBOARDING$ACP_SECRETS_SUBTITLE, { provider: providerName, })}
{required && !isAuthenticated ? ({t(I18nKey.ONBOARDING$ACP_SECRETS_REQUIRED_NOTE, { provider: providerName, })}
) : ( authStatus !== "authenticated" && ( // When already signed in, the success banner below already says to // leave the fields blank, so this general reminder would be redundant.{t(I18nKey.ONBOARDING$ACP_SECRETS_SUBSCRIPTION_NOTE)}
) )}{t(I18nKey.ONBOARDING$ACP_SECRETS_REQUIRED_BLOCKED)}
) : null}