Download src/components/features/onboarding/steps/setup-acp-secrets-step.tsx from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 9.94 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
- Command line
-
hf download hf://SaylorTwift/openhands/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
-
curl -L -o setup-acp-secrets-step.tsx https://huggingface.co/SaylorTwift/openhands/resolve/main/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
9.94 kB
| 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 ( | |
| <div | |
| data-testid="onboarding-step-setup-acp-secrets" | |
| className="flex flex-col gap-6" | |
| > | |
| <header className="flex flex-col gap-2"> | |
| <h2 className="text-2xl font-medium text-white"> | |
| {t(I18nKey.ONBOARDING$ACP_SECRETS_TITLE)} | |
| </h2> | |
| <p className="text-sm text-[var(--oh-muted)]"> | |
| {t(I18nKey.ONBOARDING$ACP_SECRETS_SUBTITLE, { | |
| provider: providerName, | |
| })} | |
| </p> | |
| {required && !isAuthenticated ? ( | |
| <p | |
| data-testid="onboarding-acp-secrets-required-note" | |
| className="text-sm text-[var(--oh-muted)]" | |
| > | |
| {t(I18nKey.ONBOARDING$ACP_SECRETS_REQUIRED_NOTE, { | |
| provider: providerName, | |
| })} | |
| </p> | |
| ) : ( | |
| authStatus !== "authenticated" && ( | |
| // When already signed in, the success banner below already says to | |
| // leave the fields blank, so this general reminder would be redundant. | |
| <p className="text-sm text-[var(--oh-muted)]"> | |
| {t(I18nKey.ONBOARDING$ACP_SECRETS_SUBSCRIPTION_NOTE)} | |
| </p> | |
| ) | |
| )} | |
| </header> | |
| <AcpAuthStatusBanner | |
| status={authStatus} | |
| isChecking={isCheckingAuth} | |
| credentialsConfigured={credentialsConfigured} | |
| providerName={providerName} | |
| testIdPrefix="onboarding-acp-auth" | |
| /> | |
| <div className="flex flex-col gap-5"> | |
| {fields.map((field) => ( | |
| <AcpSecretField | |
| key={field.name} | |
| field={field} | |
| value={values[field.name] ?? ""} | |
| onChange={(value) => setValue(field.name, value)} | |
| alreadySet={secretExists(field.name)} | |
| testId={`onboarding-acp-secret-${field.name}`} | |
| showOptionalTag | |
| /> | |
| ))} | |
| </div> | |
| <AcpConflictWarnings conflicts={conflicts} /> | |
| {blockNext ? ( | |
| <p | |
| data-testid="onboarding-acp-secrets-blocked" | |
| className="text-sm text-amber-300" | |
| > | |
| {t(I18nKey.ONBOARDING$ACP_SECRETS_REQUIRED_BLOCKED)} | |
| </p> | |
| ) : null} | |
| <div className="sticky bottom-0 flex items-center justify-between gap-2 bg-base-secondary pt-4 pb-7"> | |
| <BrandButton | |
| testId="onboarding-acp-secrets-back" | |
| type="button" | |
| variant="secondary" | |
| onClick={onBack} | |
| isDisabled={isSaving} | |
| > | |
| {t(I18nKey.ONBOARDING$BACK)} | |
| </BrandButton> | |
| <BrandButton | |
| testId="onboarding-acp-secrets-next" | |
| type="button" | |
| variant="primary" | |
| isDisabled={nextDisabled} | |
| onClick={handleNext} | |
| > | |
| {isSaving ? t(I18nKey.SETTINGS$SAVING) : t(I18nKey.ONBOARDING$NEXT)} | |
| </BrandButton> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| /** | |
| * Whether the credential step must be satisfied before advancing, given the | |
| * active backend kind and the ACP login-probe result. | |
| * | |
| * - **cloud** β always required: a remote backend has no host CLI login to fall | |
| * back on. | |
| * - **local + ``"unauthenticated"``** β required: the probe ran and found no | |
| * login, i.e. a fresh containerized agent-server. | |
| * - **local + ``"authenticated"`` / ``"unknown"``** β optional: either a login | |
| * exists, or the probe couldn't classify it (CLI missing, odd output) β in | |
| * which case we stay permissive rather than block a working native dev. | |
| * | |
| * Exported for unit testing the matrix without rendering the modal. | |
| */ | |
| export function backendRequiresAcpCredentials( | |
| backendKind: "local" | "cloud", | |
| authStatus: "authenticated" | "unauthenticated" | "unknown", | |
| ): boolean { | |
| if (authStatus === "authenticated") return false; | |
| if (backendKind === "cloud") return true; | |
| return authStatus === "unauthenticated"; | |
| } | |