File size: 9,942 Bytes
3201ca6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | 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";
}
|