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";
}