From 1b4dca570d4589206d10cf6b80d031f08ecadd04 Mon Sep 17 00:00:00 2001
From: Debug Agent
Date: Mon, 1 Jun 2026 23:16:41 +0200
Subject: [PATCH 1/7] feat(onboarding): detect ACP login client-side via the
bash endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Auto-detect whether the chosen ACP provider (Claude Code / Codex / Gemini) is
already signed in and show a "✓ you're already signed in" banner instead of
unconditionally asking for an API key. Refs OpenHands/agent-canvas#964.
This is a 100% canvas implementation — no SDK endpoint, no SDK release, no
version pin. Detection is gated to local backends (where the provider CLIs and
credential files actually live) and runs the provider's own status command
through the existing agent-server bash endpoint, classifying the output:
- Claude Code → `claude auth status --json` (read `loggedIn`; exits non-zero
when logged out, so we read the JSON, not the exit code).
- Codex → `codex login status` ("Logged in …" / "Not logged in" — on stderr,
so both streams are checked).
- Gemini → has no status command (browser OAuth), so check its credentials
file `~/.gemini/oauth_creds.json`.
Anything that can't be classified — CLI not installed ("command not found"),
unexpected output, or the bash call failing — is reported as `unknown`, so
onboarding falls back to the API-key fields rather than a false banner. Verified
live end-to-end through the Vite proxy → bash endpoint: all three authenticated
on a logged-in machine; a CLI stripped from PATH (exit 127) → `unknown`.
- `AcpService.getAuthStatus(server)` (`BashClient`-based) returns the classified
status; `useAcpAuthStatus` is gated to local backends and no longer keys off
whether the provider has API-key fields, so Gemini is detected too.
- The credentials step renders a login-status screen (banner, no inputs) for
key-less providers like Gemini; the onboarding flow no longer skips slide 2.
- New i18n: ACP_AUTH_DETECTED, ACP_AUTH_CHECKING, ACP_LOGIN_TITLE,
ACP_LOGIN_SUBTITLE, ACP_AUTH_DETECTED_NO_KEY.
Tests: `acp-service.api.test.ts` covers each provider's classifier incl. the
missing-CLI → `unknown` path (the "no available ACP process" case); hook/step/
modal tests cover gating, the Gemini login-only screen, and no-skip routing.
Supersedes the protocol-probe approach (software-agent-sdk#3452 /
typescript-client#196 / the #971 wiring), which can be closed.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../onboarding/onboarding-modal.test.tsx | 62 ++++-----
.../setup-acp-secrets-step.test.tsx | 100 ++++++++++++-
.../hooks/query/use-acp-auth-status.test.tsx | 131 ++++++++++++++++++
src/api/acp-service/acp-service.api.test.ts | 130 +++++++++++++++++
src/api/acp-service/acp-service.api.ts | 106 ++++++++++++++
.../features/onboarding/onboarding-modal.tsx | 60 +++-----
.../steps/setup-acp-secrets-step.tsx | 70 +++++++++-
src/hooks/query/use-acp-auth-status.ts | 95 +++++++++++++
src/i18n/translation.json | 85 ++++++++++++
9 files changed, 760 insertions(+), 79 deletions(-)
create mode 100644 __tests__/hooks/query/use-acp-auth-status.test.tsx
create mode 100644 src/api/acp-service/acp-service.api.test.ts
create mode 100644 src/api/acp-service/acp-service.api.ts
create mode 100644 src/hooks/query/use-acp-auth-status.ts
diff --git a/__tests__/components/onboarding/onboarding-modal.test.tsx b/__tests__/components/onboarding/onboarding-modal.test.tsx
index 883dab5c..00ff3350 100644
--- a/__tests__/components/onboarding/onboarding-modal.test.tsx
+++ b/__tests__/components/onboarding/onboarding-modal.test.tsx
@@ -86,6 +86,17 @@ vi.mock("#/hooks/mutation/use-create-conversation", () => ({
}),
}));
+// The ACP credentials slide runs a login-detection probe (calls
+// GET /api/acp/auth-status). Stub it here so the modal routing tests don't hit
+// the network; the probe itself is covered in use-acp-auth-status.test.tsx.
+vi.mock("#/hooks/query/use-acp-auth-status", () => ({
+ useAcpAuthStatus: () => ({
+ status: "unknown",
+ isChecking: false,
+ isSupported: false,
+ }),
+}));
+
function renderModal(onClose = vi.fn()) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -283,26 +294,15 @@ describe("OnboardingModal", () => {
expect(settings.contains(next)).toBe(false);
});
- it("skips the step-2 slide for an ACP agent with no credentials to collect", async () => {
+ it("shows slide 2 as a login screen (no key fields) for a credential-less ACP agent", async () => {
renderModal();
const user = userEvent.setup();
- // Pick Gemini CLI: it authenticates via an interactive OAuth login
- // (no env-var API key), so it has no credentials step and slide 2 is
- // skipped — unlike Claude Code / Codex, which now render one there.
+ // Pick Gemini CLI: it authenticates via an interactive OAuth login (no
+ // env-var API key), but the slide still shows so the "you're already
+ // signed in" banner can render — the flow no longer skips it.
await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli"));
await user.click(screen.getByTestId("onboarding-agent-next"));
- await waitFor(
- () =>
- expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
- "data-current-step",
- "1",
- ),
- { timeout: 3000 },
- );
-
- // Advancing again should jump straight to Say Hello (index 3) and
- // bypass slide 2 — Gemini owns its own auth via the OAuth login.
await waitFor(
() =>
expect(
@@ -312,43 +312,35 @@ describe("OnboardingModal", () => {
);
await user.click(screen.getByTestId("onboarding-backend-next"));
+ // Lands on slide 2 (the ACP step) — not jumped past to Say Hello.
await waitFor(
() =>
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
"data-current-step",
- "3",
+ "2",
),
{ timeout: 3000 },
);
- // All four slides remain mounted (the rail just translates them);
- // the assertion that the LLM step was skipped is that slide 3 (Say
- // Hello) is the active one immediately after the backend step,
- // *not* slide 2 (LLM).
expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute(
- "data-active",
- "false",
- );
- expect(screen.getByTestId("onboarding-slide-3")).toHaveAttribute(
"data-active",
"true",
);
-
- // Progress bar reflects the *visited* step count, not the slide
- // index — 3 segments total (not 4), and segment 2 is current (not
- // segment 3, which would imply LLM was completed). Without this
- // mapping, picking an ACP agent makes the bar show segment 2 as
- // "completed" despite the user never visiting it.
expect(
- screen.queryByTestId("onboarding-progress-step-3"),
+ screen.getByTestId("onboarding-step-setup-acp-secrets"),
+ ).toBeInTheDocument();
+ // No API-key inputs for Gemini — it's a login-status-only screen.
+ expect(
+ screen.queryByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
).not.toBeInTheDocument();
+
+ // The flow keeps all four progress segments (nothing is skipped).
+ expect(
+ screen.getByTestId("onboarding-progress-step-3"),
+ ).toBeInTheDocument();
expect(screen.getByTestId("onboarding-progress-step-2")).toHaveAttribute(
"data-state",
"current",
);
- expect(screen.getByTestId("onboarding-progress-step-1")).toHaveAttribute(
- "data-state",
- "completed",
- );
});
it("shows the ACP credentials step on slide 2 for Claude Code and saves entered keys as secrets", async () => {
diff --git a/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx b/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx
index af5b1aac..fe964b6b 100644
--- a/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx
+++ b/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx
@@ -10,7 +10,18 @@ import { SetupAcpSecretsStep } from "#/components/features/onboarding/steps/setu
import { type OnboardingAgentId } from "#/components/features/onboarding/steps/choose-agent-step";
import { SecretsService } from "#/api/secrets-service";
-function renderStep(providerKey: OnboardingAgentId = "claude-code") {
+// The login-detection probe is exercised in its own hook test; here we stub it
+// so rendering the step doesn't spin a conversation, and so we can drive the
+// banner states directly.
+const acpAuthStatusMock = vi.hoisted(() => vi.fn());
+vi.mock("#/hooks/query/use-acp-auth-status", () => ({
+ useAcpAuthStatus: (...args: unknown[]) => acpAuthStatusMock(...args),
+}));
+
+function renderStep(
+ providerKey: OnboardingAgentId = "claude-code",
+ isActive = true,
+) {
const onBack = vi.fn();
const onNext = vi.fn();
const user = userEvent.setup();
@@ -23,6 +34,7 @@ function renderStep(providerKey: OnboardingAgentId = "claude-code") {
@@ -53,6 +65,11 @@ async function renderWithSavedApiKey() {
beforeEach(() => {
vi.restoreAllMocks();
__resetActiveStoreForTests();
+ acpAuthStatusMock.mockReturnValue({
+ status: "unknown",
+ isChecking: false,
+ isSupported: false,
+ });
vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([]);
vi.spyOn(SecretsService, "createSecret").mockResolvedValue();
});
@@ -166,4 +183,85 @@ describe("SetupAcpSecretsStep", () => {
);
expect(onNext).not.toHaveBeenCalled();
});
+
+ it("runs the login probe scoped to the active step and provider", () => {
+ renderStep("claude-code", true);
+ expect(acpAuthStatusMock).toHaveBeenCalledWith("claude-code", {
+ enabled: true,
+ });
+ });
+
+ it("disables the login probe when the step is not active", () => {
+ renderStep("claude-code", false);
+ expect(acpAuthStatusMock).toHaveBeenCalledWith("claude-code", {
+ enabled: false,
+ });
+ });
+
+ it("shows the 'checking' banner while the login probe is in flight", () => {
+ acpAuthStatusMock.mockReturnValue({
+ status: "unknown",
+ isChecking: true,
+ isSupported: true,
+ });
+ renderStep("claude-code");
+
+ expect(
+ screen.getByTestId("onboarding-acp-auth-checking"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("onboarding-acp-auth-detected"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the 'already signed in' banner when authenticated, keeping the key fields", () => {
+ acpAuthStatusMock.mockReturnValue({
+ status: "authenticated",
+ isChecking: false,
+ isSupported: true,
+ });
+ renderStep("claude-code");
+
+ expect(
+ screen.getByTestId("onboarding-acp-auth-detected"),
+ ).toBeInTheDocument();
+ // The fields stay visible (now optional) even when already logged in.
+ expect(
+ screen.getByTestId("onboarding-acp-secret-ANTHROPIC_API_KEY"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders a login-only screen (banner, no key fields) for a credential-less provider", () => {
+ // Gemini authenticates via browser OAuth — no API-key fields. The step is
+ // purely a login-status screen and still shows the "signed in" banner.
+ acpAuthStatusMock.mockReturnValue({
+ status: "authenticated",
+ isChecking: false,
+ isSupported: true,
+ });
+ renderStep("gemini-cli");
+
+ expect(
+ screen.getByTestId("onboarding-acp-auth-detected"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows no banner when the provider is not authenticated", () => {
+ acpAuthStatusMock.mockReturnValue({
+ status: "unauthenticated",
+ isChecking: false,
+ isSupported: true,
+ });
+ renderStep("claude-code");
+
+ expect(
+ screen.queryByTestId("onboarding-acp-auth-detected"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("onboarding-acp-auth-checking"),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/__tests__/hooks/query/use-acp-auth-status.test.tsx b/__tests__/hooks/query/use-acp-auth-status.test.tsx
new file mode 100644
index 00000000..6ffca8e0
--- /dev/null
+++ b/__tests__/hooks/query/use-acp-auth-status.test.tsx
@@ -0,0 +1,131 @@
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useAcpAuthStatus } from "#/hooks/query/use-acp-auth-status";
+
+// Active backend is swapped per-test (local vs cloud) via this mutable holder.
+const backendMock = vi.hoisted(() => ({
+ current: {
+ backend: { id: "local-1", kind: "local" as "local" | "cloud" },
+ orgId: null as string | null,
+ },
+}));
+vi.mock("#/contexts/active-backend-context", () => ({
+ useActiveBackend: () => backendMock.current,
+}));
+
+// AcpService.getAuthStatus resolves an AcpAuthStatus string (it runs the
+// detection command + classifies internally; that logic is unit-tested in
+// acp-service.api.test.ts).
+const getAuthStatus = vi.hoisted(() => vi.fn());
+vi.mock("#/api/acp-service/acp-service.api", () => ({
+ default: {
+ getAuthStatus: (...args: unknown[]) => getAuthStatus(...args),
+ },
+}));
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return {children};
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ backendMock.current = {
+ backend: { id: "local-1", kind: "local" },
+ orgId: null,
+ };
+});
+
+describe("useAcpAuthStatus", () => {
+ it("reports authenticated when the probe says so", async () => {
+ getAuthStatus.mockResolvedValue("authenticated");
+
+ const { result } = renderHook(() => useAcpAuthStatus("claude-code"), {
+ wrapper,
+ });
+
+ await waitFor(() => expect(result.current.status).toBe("authenticated"));
+ // The provider key parameterizes the probe.
+ expect(getAuthStatus).toHaveBeenCalledTimes(1);
+ expect(getAuthStatus).toHaveBeenCalledWith("claude-code");
+ });
+
+ it("reports unauthenticated when the probe says so", async () => {
+ getAuthStatus.mockResolvedValue("unauthenticated");
+
+ const { result } = renderHook(() => useAcpAuthStatus("codex"), { wrapper });
+
+ await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
+ expect(getAuthStatus).toHaveBeenCalledWith("codex");
+ });
+
+ it("falls back to unknown when the probe call rejects", async () => {
+ getAuthStatus.mockRejectedValue(new Error("network down"));
+
+ const { result } = renderHook(() => useAcpAuthStatus("claude-code"), {
+ wrapper,
+ });
+
+ // A rejected probe must not be read as "not logged in" — it stays unknown
+ // so the caller keeps showing the API-key fields.
+ await waitFor(() => expect(result.current.isChecking).toBe(false));
+ expect(result.current.status).toBe("unknown");
+ });
+
+ it("surfaces an unknown status from the probe verbatim", async () => {
+ getAuthStatus.mockResolvedValue("unknown");
+
+ const { result } = renderHook(() => useAcpAuthStatus("claude-code"), {
+ wrapper,
+ });
+
+ await waitFor(() => expect(result.current.isChecking).toBe(false));
+ expect(result.current.status).toBe("unknown");
+ });
+
+ it("does not probe on a cloud backend", async () => {
+ backendMock.current = {
+ backend: { id: "cloud-1", kind: "cloud" },
+ orgId: null,
+ };
+
+ const { result } = renderHook(() => useAcpAuthStatus("claude-code"), {
+ wrapper,
+ });
+
+ await Promise.resolve();
+ expect(result.current.status).toBe("unknown");
+ expect(result.current.isSupported).toBe(false);
+ expect(getAuthStatus).not.toHaveBeenCalled();
+ });
+
+ it("probes credential-less providers too (e.g. gemini-cli, OAuth login)", async () => {
+ // Eligibility is not tied to having API-key fields — the server can detect
+ // subscription/OAuth providers like Gemini, so the hook must still probe.
+ getAuthStatus.mockResolvedValue("authenticated");
+
+ const { result } = renderHook(() => useAcpAuthStatus("gemini-cli"), {
+ wrapper,
+ });
+
+ await waitFor(() => expect(result.current.status).toBe("authenticated"));
+ expect(result.current.isSupported).toBe(true);
+ expect(getAuthStatus).toHaveBeenCalledWith("gemini-cli");
+ });
+
+ it("does not probe when disabled (e.g. the step is not the active slide)", async () => {
+ const { result } = renderHook(
+ () => useAcpAuthStatus("claude-code", { enabled: false }),
+ { wrapper },
+ );
+
+ await Promise.resolve();
+ expect(result.current.status).toBe("unknown");
+ expect(getAuthStatus).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/api/acp-service/acp-service.api.test.ts b/src/api/acp-service/acp-service.api.test.ts
new file mode 100644
index 00000000..ea13bd41
--- /dev/null
+++ b/src/api/acp-service/acp-service.api.test.ts
@@ -0,0 +1,130 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { BashOutput } from "@openhands/typescript-client";
+import AcpService from "./acp-service.api";
+
+// Capture the command the service runs and control the BashOutput it sees.
+const executeCommand = vi.hoisted(() => vi.fn());
+vi.mock("@openhands/typescript-client/clients", () => ({
+ BashClient: class {
+ executeCommand = executeCommand;
+ },
+}));
+vi.mock("#/api/agent-server-client-options", () => ({
+ getAgentServerClientOptions: () => ({
+ host: "http://localhost",
+ workingDir: "/",
+ }),
+}));
+
+function bashOutput(partial: Partial): BashOutput {
+ return {
+ id: "1",
+ timestamp: "2026-01-01T00:00:00Z",
+ command_id: "c1",
+ order: 0,
+ exit_code: 0,
+ stdout: null,
+ stderr: null,
+ kind: "BashOutput",
+ ...partial,
+ } as BashOutput;
+}
+
+beforeEach(() => vi.clearAllMocks());
+
+describe("AcpService.getAuthStatus", () => {
+ describe("claude-code (claude auth status --json)", () => {
+ it("runs the right command and maps loggedIn:true → authenticated", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({
+ stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }),
+ }),
+ );
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
+ "authenticated",
+ );
+ expect(executeCommand).toHaveBeenCalledWith(
+ "claude auth status --json",
+ undefined,
+ expect.any(Number),
+ );
+ });
+
+ it("maps loggedIn:false (even with a non-zero exit) → unauthenticated", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({
+ stdout: JSON.stringify({ loggedIn: false }),
+ exit_code: 1,
+ }),
+ );
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
+ "unauthenticated",
+ );
+ });
+
+ it("→ unknown when the CLI is missing (exit 127, no JSON on stdout)", async () => {
+ // The "no available ACP process / CLI not installed" path.
+ executeCommand.mockResolvedValue(
+ bashOutput({
+ exit_code: 127,
+ stderr: "env: claude: No such file or directory",
+ }),
+ );
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
+ "unknown",
+ );
+ });
+ });
+
+ describe("codex (codex login status)", () => {
+ it("→ authenticated even though the CLI writes to stderr", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({ stderr: "Logged in using ChatGPT\n" }),
+ );
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
+ "authenticated",
+ );
+ });
+
+ it("→ unauthenticated on 'Not logged in'", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({ stderr: "Not logged in\n" }),
+ );
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
+ "unauthenticated",
+ );
+ });
+
+ it("→ unknown when the CLI is missing", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({ exit_code: 127, stderr: "codex: command not found" }),
+ );
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe("unknown");
+ });
+ });
+
+ describe("gemini-cli (credentials file check)", () => {
+ it("→ authenticated when the creds file is present", async () => {
+ executeCommand.mockResolvedValue(bashOutput({ stdout: "present\n" }));
+ await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
+ "authenticated",
+ );
+ // Probes the OAuth creds file, not a (nonexistent) gemini status command.
+ expect(executeCommand.mock.calls[0][0]).toContain("oauth_creds.json");
+ });
+
+ it("→ unauthenticated when the creds file is absent", async () => {
+ executeCommand.mockResolvedValue(bashOutput({ stdout: "absent\n" }));
+ await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
+ "unauthenticated",
+ );
+ });
+ });
+
+ it("→ unknown for an unprobeable provider, without running any command", async () => {
+ await expect(AcpService.getAuthStatus("openhands")).resolves.toBe(
+ "unknown",
+ );
+ expect(executeCommand).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/api/acp-service/acp-service.api.ts b/src/api/acp-service/acp-service.api.ts
new file mode 100644
index 00000000..6b03047f
--- /dev/null
+++ b/src/api/acp-service/acp-service.api.ts
@@ -0,0 +1,106 @@
+import { BashClient } from "@openhands/typescript-client/clients";
+import type { BashOutput } from "@openhands/typescript-client";
+import { getAgentServerClientOptions } from "../agent-server-client-options";
+
+export type AcpAuthStatus = "authenticated" | "unauthenticated" | "unknown";
+
+// Hard cap (seconds) for a single detection command. Generous so a slow-but-
+// working CLI (cold start) isn't misread as "unknown".
+const PROBE_TIMEOUT_SECONDS = 30;
+
+interface AcpAuthProbe {
+ /** Shell command run on the (local) agent-server host to detect login. */
+ command: string;
+ /** Classify the command's output into an auth status. */
+ classify: (out: BashOutput) => AcpAuthStatus;
+}
+
+/** Combined stdout+stderr — CLIs are inconsistent about which stream they use
+ * (e.g. ``codex login status`` writes to stderr). */
+function streams(out: BashOutput): string {
+ return `${out.stdout ?? ""}\n${out.stderr ?? ""}`;
+}
+
+// Claude Code: ``claude auth status --json`` prints {"loggedIn": bool, …}. The
+// CLI exits non-zero when logged out, so we read the JSON, not the exit code.
+// No parseable ``loggedIn`` (e.g. the CLI isn't installed → "command not
+// found", empty stdout) ⇒ unknown, so onboarding shows the API-key fields
+// rather than guessing.
+function classifyClaude(out: BashOutput): AcpAuthStatus {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse((out.stdout ?? "").trim());
+ } catch {
+ return "unknown";
+ }
+ const loggedIn = (parsed as { loggedIn?: unknown } | null)?.loggedIn;
+ if (typeof loggedIn === "boolean") {
+ return loggedIn ? "authenticated" : "unauthenticated";
+ }
+ return "unknown";
+}
+
+// Codex: ``codex login status`` prints "Logged in using …" / "Not logged in"
+// (to stderr), so check both streams. Match "not logged in" first since it
+// contains the "logged in" substring. Neither phrase (e.g. CLI missing) ⇒
+// unknown.
+function classifyCodex(out: BashOutput): AcpAuthStatus {
+ const text = streams(out).toLowerCase();
+ if (text.includes("not logged in")) return "unauthenticated";
+ if (text.includes("logged in")) return "authenticated";
+ return "unknown";
+}
+
+// Gemini CLI signs in via Google OAuth and has no status command, so we check
+// its credentials file. The command echoes present/absent and exits 0 either
+// way; anything else (a shell failure) ⇒ unknown.
+function classifyGemini(out: BashOutput): AcpAuthStatus {
+ const text = streams(out);
+ if (text.includes("present")) return "authenticated";
+ if (text.includes("absent")) return "unauthenticated";
+ return "unknown";
+}
+
+// Per-provider login detection, keyed by ``acp_server`` / OnboardingAgentId.
+// Providers absent here (OpenHands, custom, unknown) report ``unknown``.
+const ACP_AUTH_PROBES: Record = {
+ "claude-code": {
+ command: "claude auth status --json",
+ classify: classifyClaude,
+ },
+ codex: {
+ command: "codex login status",
+ classify: classifyCodex,
+ },
+ "gemini-cli": {
+ command:
+ 'test -f "$HOME/.gemini/oauth_creds.json" && echo present || echo absent',
+ classify: classifyGemini,
+ },
+};
+
+/**
+ * Detects whether the selected ACP provider is already logged in — entirely
+ * client-side, with **no dedicated agent-server endpoint**. It runs the
+ * provider's own status command (or, for Gemini, a credentials-file check)
+ * through the existing agent-server bash endpoint and classifies the output.
+ *
+ * Gated by the caller to **local backends**: the command runs wherever the
+ * agent-server runs, and on a user's own machine the provider CLIs and
+ * credential files live at their standard paths. No prompt is sent, so no model
+ * tokens are spent. A provider that can't be classified — CLI not installed,
+ * unexpected output, or the bash call failing — comes back as ``unknown`` so
+ * onboarding falls back to the API-key fields rather than a misleading banner.
+ */
+class AcpService {
+ static async getAuthStatus(server: string): Promise {
+ const probe = ACP_AUTH_PROBES[server];
+ if (!probe) return "unknown";
+ const out = await new BashClient(
+ getAgentServerClientOptions(),
+ ).executeCommand(probe.command, undefined, PROBE_TIMEOUT_SECONDS);
+ return probe.classify(out);
+ }
+}
+
+export default AcpService;
diff --git a/src/components/features/onboarding/onboarding-modal.tsx b/src/components/features/onboarding/onboarding-modal.tsx
index f93d9aae..07daa3c4 100644
--- a/src/components/features/onboarding/onboarding-modal.tsx
+++ b/src/components/features/onboarding/onboarding-modal.tsx
@@ -16,10 +16,15 @@ import { CheckBackendStep } from "./steps/check-backend-step";
import { SetupLlmStep } from "./steps/setup-llm-step";
import { SetupAcpSecretsStep } from "./steps/setup-acp-secrets-step";
import { SayHelloStep } from "./steps/say-hello-step";
-import { getAcpProviderSecrets } from "#/constants/acp-providers";
const TOTAL_STEPS = 4;
+// Index of the per-provider setup slide (LLM form for OpenHands, ACP
+// credentials for Claude Code / Codex). Named so the slide and the
+// ``isActive`` gate that drives the ACP login probe move together — inserting
+// a slide before it can't silently fire the probe on the wrong step.
+const SETUP_SLIDE_INDEX = 2;
+
interface SlideProps {
/** Index of this slide in the step sequence. */
index: number;
@@ -93,45 +98,21 @@ export function OnboardingModal({ onClose }: OnboardingModalProps) {
// * OpenHands → the LLM-setup form (its own LLM config).
// * Claude Code / Codex → the ACP secrets form (API key + base URL), since
// these providers authenticate via env-var keys.
- // * Gemini CLI → nothing: it authenticates through an interactive
- // OAuth login, so there's no key to enter and we
- // skip the slide entirely.
- // ``getAcpProviderSecrets`` returns the field list (empty for Gemini), which
- // is what distinguishes the ACP-with-secrets case from the skip case.
+ // * Gemini CLI → a login-status screen (no key fields; it signs in
+ // via browser OAuth) so the "you're already signed
+ // in" banner can still render.
+ // Every agent has slide-2 content, so the flow is a plain 4-step sequence
+ // with no skipping.
const isOpenHands = selectedAgentId === "openhands";
- const acpSecretFields = getAcpProviderSecrets(selectedAgentId);
- const showAcpSecretsStep = !isOpenHands && acpSecretFields.length > 0;
- // Skip slide 2 only when there's nothing to show there (an ACP provider
- // with no credentials to collect). Skipping keeps the rest of the flow
- // intact in both directions (back from SayHello returns to CheckBackend,
- // not a dead-end blank page).
- const skipStep2 = !isOpenHands && !showAcpSecretsStep;
const goNext = React.useCallback(
- () =>
- setCurrentStep((step) => {
- const delta = skipStep2 && step === 1 ? 2 : 1;
- return Math.min(step + delta, TOTAL_STEPS - 1);
- }),
- [skipStep2],
+ () => setCurrentStep((step) => Math.min(step + 1, TOTAL_STEPS - 1)),
+ [],
);
const goBack = React.useCallback(
- () =>
- setCurrentStep((step) => {
- const delta = skipStep2 && step === 3 ? 2 : 1;
- return Math.max(step - delta, 0);
- }),
- [skipStep2],
+ () => setCurrentStep((step) => Math.max(step - 1, 0)),
+ [],
);
- // The progress bar should show the user's actual visited-step count,
- // not the underlying index. When slide 2 is skipped:
- // * the bar renders 3 segments instead of 4, and
- // * the SayHello slide (modal index 3) maps to logical step 2 so
- // segment 2 doesn't pop "completed" on a slide the user never saw.
- const progressTotal = skipStep2 ? TOTAL_STEPS - 1 : TOTAL_STEPS;
- const progressStep =
- skipStep2 && currentStep > 1 ? currentStep - 1 : currentStep;
-
return (
@@ -175,16 +156,17 @@ export function OnboardingModal({ onClose }: OnboardingModalProps) {
-
+
{isOpenHands ? (
- ) : showAcpSecretsStep ? (
+ ) : (
- ) : null}
+ )}
diff --git a/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx b/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
index 2b9aabc5..8c127bf4 100644
--- a/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
+++ b/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
@@ -2,11 +2,13 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
+import { Check, Loader2 } from "lucide-react";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { I18nKey } from "#/i18n/declaration";
import { useCreateSecret } from "#/hooks/mutation/use-create-secret";
import { useSearchSecrets } from "#/hooks/query/use-get-secrets";
+import { useAcpAuthStatus } from "#/hooks/query/use-acp-auth-status";
import {
getAcpProviderDisplayName,
getAcpProviderSecrets,
@@ -25,6 +27,13 @@ interface SetupAcpSecretsStepProps {
* form. Providers without a credentials entry (``"openhands"``,
* ``"gemini-cli"``) 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;
}
@@ -45,6 +54,7 @@ interface SetupAcpSecretsStepProps {
*/
export function SetupAcpSecretsStep({
providerKey,
+ isActive,
onBack,
onNext,
}: SetupAcpSecretsStepProps) {
@@ -52,11 +62,21 @@ export function SetupAcpSecretsStep({
const queryClient = useQueryClient();
const { mutateAsync: createSecret } = useCreateSecret();
const { data: existingSecrets } = useSearchSecrets();
+ // Subscription/login detection via GET /api/acp/auth-status — see issue #964.
+ const { status: authStatus, isChecking: isCheckingAuth } = useAcpAuthStatus(
+ providerKey,
+ { enabled: isActive },
+ );
const fields = React.useMemo(
() => getAcpProviderSecrets(providerKey),
[providerKey],
);
+ // Providers like Gemini authenticate via an interactive browser/OAuth login
+ // and have no API-key fields. For those the step is purely a login-status
+ // screen: it shows the "you're signed in" banner (or how to sign in) with no
+ // inputs to fill.
+ const hasFields = fields.length > 0;
const [values, setValues] = React.useState>({});
const [isSaving, setIsSaving] = React.useState(false);
@@ -108,15 +128,57 @@ export function SetupAcpSecretsStep({
>
- {t(I18nKey.ONBOARDING$ACP_SECRETS_TITLE)}
+ {t(
+ hasFields
+ ? I18nKey.ONBOARDING$ACP_SECRETS_TITLE
+ : I18nKey.ONBOARDING$ACP_LOGIN_TITLE,
+ { provider: providerName },
+ )}
- {t(I18nKey.ONBOARDING$ACP_SECRETS_SUBTITLE, {
- provider: providerName,
- })}
+ {t(
+ hasFields
+ ? I18nKey.ONBOARDING$ACP_SECRETS_SUBTITLE
+ : I18nKey.ONBOARDING$ACP_LOGIN_SUBTITLE,
+ { provider: providerName },
+ )}
+ {authStatus === "authenticated" ? (
+
+
+
+ {t(
+ hasFields
+ ? I18nKey.ONBOARDING$ACP_AUTH_DETECTED
+ : I18nKey.ONBOARDING$ACP_AUTH_DETECTED_NO_KEY,
+ { provider: providerName },
+ )}
+
+
+ ) : isCheckingAuth ? (
+
+
+
+ {t(I18nKey.ONBOARDING$ACP_AUTH_CHECKING, {
+ provider: providerName,
+ })}
+
+
+ ) : null}
+
{fields.map((field) => {
const alreadySet = secretExists(field.name);
diff --git a/src/hooks/query/use-acp-auth-status.ts b/src/hooks/query/use-acp-auth-status.ts
new file mode 100644
index 00000000..7933de03
--- /dev/null
+++ b/src/hooks/query/use-acp-auth-status.ts
@@ -0,0 +1,95 @@
+import { useQuery } from "@tanstack/react-query";
+import AcpService, {
+ type AcpAuthStatus,
+} from "#/api/acp-service/acp-service.api";
+import { useActiveBackend } from "#/contexts/active-backend-context";
+
+export type { AcpAuthStatus };
+
+/**
+ * Probe whether the selected ACP provider is already authenticated on the
+ * (local) agent-server — by a subscription login (Claude Pro/Max, ChatGPT,
+ * Google) or a pre-set API key.
+ *
+ * Detection is entirely client-side: {@link AcpService.getAuthStatus} runs the
+ * provider's own status command (Claude: ``claude auth status``; Codex:
+ * ``codex login status``; Gemini: a credentials-file check) through the
+ * agent-server bash endpoint and classifies the output — no dedicated
+ * endpoint, no prompt, no model tokens. Anything it can't classify (CLI not
+ * installed, unexpected output, the bash call failing) is ``unknown``.
+ */
+async function probeAcpAuth(providerKey: string): Promise
{
+ try {
+ return await AcpService.getAuthStatus(providerKey);
+ } catch {
+ // The bash endpoint is unreachable or errored: fall back to "unknown" so
+ // the caller shows the API-key fields rather than falsely claiming
+ // "not logged in".
+ return "unknown";
+ }
+}
+
+interface UseAcpAuthStatusOptions {
+ /**
+ * Gate the probe to when the consuming surface is actually visible — the
+ * onboarding modal mounts every slide at once, so without this the probe
+ * would fire (and spin a subprocess) before the user reaches the step and
+ * before the backend is confirmed connected. Defaults to ``true``.
+ */
+ enabled?: boolean;
+}
+
+/**
+ * React Query wrapper around {@link probeAcpAuth}.
+ *
+ * Gated to **local backends only**: the detection command runs wherever the
+ * agent-server runs, and a provider CLI / credentials file is only reliably
+ * present on the user's own machine. On a remote/cloud backend they're ~never
+ * there, so we skip the probe, return ``"unknown"``, and let the caller fall
+ * back to the (already optional) API-key fields.
+ *
+ * Eligibility is intentionally *not* tied to whether the provider has API-key
+ * fields: subscription/OAuth providers (e.g. Gemini) are detectable too, and an
+ * unknown ``providerKey`` simply classifies as ``"unknown"``. The caller renders
+ * this hook only for ACP providers, so any local backend is probeable.
+ *
+ * The probe runs a subprocess on the agent-server, so the result is cached for
+ * the session (``staleTime: Infinity``, no refetch on focus/mount) — one probe
+ * per provider per backend.
+ */
+export function useAcpAuthStatus(
+ providerKey: string | null | undefined,
+ options: UseAcpAuthStatusOptions = {},
+) {
+ const { enabled = true } = options;
+ const active = useActiveBackend();
+ const isLocal = active.backend.kind === "local";
+ const isSupported = isLocal;
+ const queryEnabled = enabled && isSupported && !!providerKey;
+
+ const query = useQuery({
+ // ``providerKey`` both discriminates the cache (so switching providers
+ // re-probes) and parameterizes the probe — ``queryEnabled`` guarantees it
+ // is non-empty whenever the query runs.
+ queryKey: ["acp-auth-status", active.backend.id, providerKey],
+ queryFn: () => probeAcpAuth(providerKey as string),
+ enabled: queryEnabled,
+ // ``staleTime: Infinity`` = never re-probe while the result stays cached;
+ // ``gcTime`` then bounds that to ~15 min after the hook unmounts. So a
+ // user who dismisses and reopens onboarding >15 min later re-probes —
+ // intentional: it's a cheap one-off and their login state may have changed.
+ staleTime: Infinity,
+ gcTime: 1000 * 60 * 15,
+ retry: false,
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
+ });
+
+ return {
+ status: query.data ?? "unknown",
+ /** True while the first probe for this provider is in flight. */
+ isChecking: queryEnabled && query.isFetching && query.data === undefined,
+ /** Whether a probe can run at all on this backend (local backends only). */
+ isSupported,
+ };
+}
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index 9d8de6c0..caa27e8d 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -1,4 +1,89 @@
{
+ "ONBOARDING$ACP_AUTH_DETECTED": {
+ "ar": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "ca": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "de": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "en": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "es": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "fr": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "it": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "ja": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "ko-KR": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "no": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "pt": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "tr": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "uk": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "zh-CN": "You're already signed in to {{provider}} — adding an API key below is optional.",
+ "zh-TW": "You're already signed in to {{provider}} — adding an API key below is optional."
+ },
+ "ONBOARDING$ACP_AUTH_CHECKING": {
+ "ar": "Checking for an existing {{provider}} login…",
+ "ca": "Checking for an existing {{provider}} login…",
+ "de": "Checking for an existing {{provider}} login…",
+ "en": "Checking for an existing {{provider}} login…",
+ "es": "Checking for an existing {{provider}} login…",
+ "fr": "Checking for an existing {{provider}} login…",
+ "it": "Checking for an existing {{provider}} login…",
+ "ja": "Checking for an existing {{provider}} login…",
+ "ko-KR": "Checking for an existing {{provider}} login…",
+ "no": "Checking for an existing {{provider}} login…",
+ "pt": "Checking for an existing {{provider}} login…",
+ "tr": "Checking for an existing {{provider}} login…",
+ "uk": "Checking for an existing {{provider}} login…",
+ "zh-CN": "Checking for an existing {{provider}} login…",
+ "zh-TW": "Checking for an existing {{provider}} login…"
+ },
+ "ONBOARDING$ACP_LOGIN_TITLE": {
+ "ar": "Sign in to {{provider}}",
+ "ca": "Sign in to {{provider}}",
+ "de": "Sign in to {{provider}}",
+ "en": "Sign in to {{provider}}",
+ "es": "Sign in to {{provider}}",
+ "fr": "Sign in to {{provider}}",
+ "it": "Sign in to {{provider}}",
+ "ja": "Sign in to {{provider}}",
+ "ko-KR": "Sign in to {{provider}}",
+ "no": "Sign in to {{provider}}",
+ "pt": "Sign in to {{provider}}",
+ "tr": "Sign in to {{provider}}",
+ "uk": "Sign in to {{provider}}",
+ "zh-CN": "Sign in to {{provider}}",
+ "zh-TW": "Sign in to {{provider}}"
+ },
+ "ONBOARDING$ACP_LOGIN_SUBTITLE": {
+ "ar": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "ca": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "de": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "en": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "es": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "fr": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "it": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "ja": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "ko-KR": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "no": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "pt": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "tr": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "uk": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "zh-CN": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
+ "zh-TW": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in."
+ },
+ "ONBOARDING$ACP_AUTH_DETECTED_NO_KEY": {
+ "ar": "You're already signed in to {{provider}} — you're all set.",
+ "ca": "You're already signed in to {{provider}} — you're all set.",
+ "de": "You're already signed in to {{provider}} — you're all set.",
+ "en": "You're already signed in to {{provider}} — you're all set.",
+ "es": "You're already signed in to {{provider}} — you're all set.",
+ "fr": "You're already signed in to {{provider}} — you're all set.",
+ "it": "You're already signed in to {{provider}} — you're all set.",
+ "ja": "You're already signed in to {{provider}} — you're all set.",
+ "ko-KR": "You're already signed in to {{provider}} — you're all set.",
+ "no": "You're already signed in to {{provider}} — you're all set.",
+ "pt": "You're already signed in to {{provider}} — you're all set.",
+ "tr": "You're already signed in to {{provider}} — you're all set.",
+ "uk": "You're already signed in to {{provider}} — you're all set.",
+ "zh-CN": "You're already signed in to {{provider}} — you're all set.",
+ "zh-TW": "You're already signed in to {{provider}} — you're all set."
+ },
"MAINTENANCE$SCHEDULED_MESSAGE": {
"en": "Scheduled maintenance will begin at {{time}}",
"ja": "予定されたメンテナンスは{{time}}に開始されます",
From 7a4544d14d00427ea914f841c6f06055f61c9b12 Mon Sep 17 00:00:00 2001
From: Debug Agent
Date: Mon, 1 Jun 2026 23:40:45 +0200
Subject: [PATCH 2/7] chore: address PR review feedback (#1002)
- Move acp-service.api.test.ts under __tests__/ for consistency with the
project's test layout.
- classifyGemini: exact (trimmed) match on present/absent instead of substring,
so stray output falls through to "unknown".
- Document why `retry: false` co-exists with probeAcpAuth's internal catch.
---
.../api/acp-service/acp-service.api.test.ts | 2 +-
src/api/acp-service/acp-service.api.ts | 9 ++++++---
src/hooks/query/use-acp-auth-status.ts | 3 +++
3 files changed, 10 insertions(+), 4 deletions(-)
rename {src => __tests__}/api/acp-service/acp-service.api.test.ts (98%)
diff --git a/src/api/acp-service/acp-service.api.test.ts b/__tests__/api/acp-service/acp-service.api.test.ts
similarity index 98%
rename from src/api/acp-service/acp-service.api.test.ts
rename to __tests__/api/acp-service/acp-service.api.test.ts
index ea13bd41..d35a2b6c 100644
--- a/src/api/acp-service/acp-service.api.test.ts
+++ b/__tests__/api/acp-service/acp-service.api.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BashOutput } from "@openhands/typescript-client";
-import AcpService from "./acp-service.api";
+import AcpService from "#/api/acp-service/acp-service.api";
// Capture the command the service runs and control the BashOutput it sees.
const executeCommand = vi.hoisted(() => vi.fn());
diff --git a/src/api/acp-service/acp-service.api.ts b/src/api/acp-service/acp-service.api.ts
index 6b03047f..8a44a7d7 100644
--- a/src/api/acp-service/acp-service.api.ts
+++ b/src/api/acp-service/acp-service.api.ts
@@ -55,9 +55,12 @@ function classifyCodex(out: BashOutput): AcpAuthStatus {
// its credentials file. The command echoes present/absent and exits 0 either
// way; anything else (a shell failure) ⇒ unknown.
function classifyGemini(out: BashOutput): AcpAuthStatus {
- const text = streams(out);
- if (text.includes("present")) return "authenticated";
- if (text.includes("absent")) return "unauthenticated";
+ // The command echoes exactly `present` / `absent`, so match the trimmed value
+ // exactly rather than a substring — stray output then falls through to
+ // `unknown` instead of being misread.
+ const text = streams(out).trim();
+ if (text === "present") return "authenticated";
+ if (text === "absent") return "unauthenticated";
return "unknown";
}
diff --git a/src/hooks/query/use-acp-auth-status.ts b/src/hooks/query/use-acp-auth-status.ts
index 7933de03..7e7ed1bc 100644
--- a/src/hooks/query/use-acp-auth-status.ts
+++ b/src/hooks/query/use-acp-auth-status.ts
@@ -80,6 +80,9 @@ export function useAcpAuthStatus(
// intentional: it's a cheap one-off and their login state may have changed.
staleTime: Infinity,
gcTime: 1000 * 60 * 15,
+ // ``probeAcpAuth`` always resolves (it catches internally → "unknown"), so
+ // this is redundant today — kept as a guard so the probe still never retries
+ // if that inner catch is ever removed.
retry: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
From 0e82280c1b322b7d1ed97b9217e5506f5cf18e2c Mon Sep 17 00:00:00 2001
From: Debug Agent
Date: Mon, 1 Jun 2026 23:51:57 +0200
Subject: [PATCH 3/7] chore: fix stale GET /api/acp/auth-status comment (#1002)
The endpoint design was replaced by bash-command detection via AcpService;
update the comment in setup-acp-secrets-step to match.
---
.../features/onboarding/steps/setup-acp-secrets-step.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx b/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
index 8c127bf4..9f2cc1f4 100644
--- a/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
+++ b/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
@@ -62,7 +62,8 @@ export function SetupAcpSecretsStep({
const queryClient = useQueryClient();
const { mutateAsync: createSecret } = useCreateSecret();
const { data: existingSecrets } = useSearchSecrets();
- // Subscription/login detection via GET /api/acp/auth-status — see issue #964.
+ // 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 },
From be1eeb008cd48c0976e6bd1cc678cb9aa4c07465 Mon Sep 17 00:00:00 2001
From: Debug Agent
Date: Tue, 2 Jun 2026 00:03:43 +0200
Subject: [PATCH 4/7] chore: address PR review feedback (#1002)
- classifyGemini reads only stdout (the echo writes there); avoids a stray
stderr line turning a real result into unknown.
- Shorten probe timeout 30s -> 10s; the detection commands are fast local
checks, so a long onboarding spinner isn't warranted.
---
src/api/acp-service/acp-service.api.ts | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/api/acp-service/acp-service.api.ts b/src/api/acp-service/acp-service.api.ts
index 8a44a7d7..a7327754 100644
--- a/src/api/acp-service/acp-service.api.ts
+++ b/src/api/acp-service/acp-service.api.ts
@@ -4,9 +4,10 @@ import { getAgentServerClientOptions } from "../agent-server-client-options";
export type AcpAuthStatus = "authenticated" | "unauthenticated" | "unknown";
-// Hard cap (seconds) for a single detection command. Generous so a slow-but-
-// working CLI (cold start) isn't misread as "unknown".
-const PROBE_TIMEOUT_SECONDS = 30;
+// Hard cap (seconds) for a single detection command. These are fast local
+// status checks (no npx download), so 10s is ample for a healthy CLI while
+// keeping the onboarding spinner short before falling back to "unknown".
+const PROBE_TIMEOUT_SECONDS = 10;
interface AcpAuthProbe {
/** Shell command run on the (local) agent-server host to detect login. */
@@ -55,10 +56,10 @@ function classifyCodex(out: BashOutput): AcpAuthStatus {
// its credentials file. The command echoes present/absent and exits 0 either
// way; anything else (a shell failure) ⇒ unknown.
function classifyGemini(out: BashOutput): AcpAuthStatus {
- // The command echoes exactly `present` / `absent`, so match the trimmed value
- // exactly rather than a substring — stray output then falls through to
- // `unknown` instead of being misread.
- const text = streams(out).trim();
+ // The command echoes exactly `present` / `absent` to stdout, so match trimmed
+ // stdout exactly. Reading only stdout (not stderr) means a stray shell
+ // warning can't turn a real result into `unknown`.
+ const text = (out.stdout ?? "").trim();
if (text === "present") return "authenticated";
if (text === "absent") return "unauthenticated";
return "unknown";
From f445021b92810be69a8e3fce1793f42f5e8e05f8 Mon Sep 17 00:00:00 2001
From: Debug Agent
Date: Tue, 2 Jun 2026 10:54:48 +0200
Subject: [PATCH 5/7] feat(onboarding): collect ACP credentials for all
providers incl. Gemini
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Onboarding's ACP credentials step now offers an API-key (+ optional base-URL)
field for Gemini too, not just Claude Code and Codex. Refs agent-canvas#972.
- getAcpProviderSecrets now derives fields from the SDK registry's
api_key_env_var / base_url_env_var (via @openhands/typescript-client) instead
of a hand-maintained per-provider map — so all three built-ins (Claude Code,
Codex, Gemini CLI) get their fields and the list tracks the SDK with no
parallel copy to drift.
- The step renders the standard key fields for every ACP provider plus a note
that a subscription / OAuth login (Gemini's Google login, a Claude login)
takes precedence, so the keys are optional. The detection banner still shows
"you're already signed in" on top when a login is found.
- Drops the now-unreachable Gemini-only "login screen" branch and its three
unused i18n keys (ACP_LOGIN_TITLE/SUBTITLE, ACP_AUTH_DETECTED_NO_KEY); adds
ACP_SECRETS_SUBSCRIPTION_NOTE.
Tests updated: Gemini now asserts a GEMINI_API_KEY field renders alongside the
banner (step + modal).
---
.../onboarding/onboarding-modal.test.tsx | 15 ++--
.../setup-acp-secrets-step.test.tsx | 11 +--
.../steps/setup-acp-secrets-step.tsx | 53 ++++++--------
src/constants/acp-providers.ts | 71 +++++++++----------
src/i18n/translation.json | 68 +++++-------------
5 files changed, 85 insertions(+), 133 deletions(-)
diff --git a/__tests__/components/onboarding/onboarding-modal.test.tsx b/__tests__/components/onboarding/onboarding-modal.test.tsx
index 00ff3350..184dd4a6 100644
--- a/__tests__/components/onboarding/onboarding-modal.test.tsx
+++ b/__tests__/components/onboarding/onboarding-modal.test.tsx
@@ -294,13 +294,13 @@ describe("OnboardingModal", () => {
expect(settings.contains(next)).toBe(false);
});
- it("shows slide 2 as a login screen (no key fields) for a credential-less ACP agent", async () => {
+ it("shows slide 2 with Gemini's credential fields (its step is no longer skipped)", async () => {
renderModal();
const user = userEvent.setup();
- // Pick Gemini CLI: it authenticates via an interactive OAuth login (no
- // env-var API key), but the slide still shows so the "you're already
- // signed in" banner can render — the flow no longer skips it.
+ // Pick Gemini CLI: its key/base-URL come from the SDK registry like the
+ // other providers, so the slide shows the GEMINI_API_KEY field — the flow
+ // no longer skips it.
await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli"));
await user.click(screen.getByTestId("onboarding-agent-next"));
await waitFor(
@@ -328,10 +328,11 @@ describe("OnboardingModal", () => {
expect(
screen.getByTestId("onboarding-step-setup-acp-secrets"),
).toBeInTheDocument();
- // No API-key inputs for Gemini — it's a login-status-only screen.
+ // Gemini now exposes credential fields (GEMINI_API_KEY), derived from the
+ // SDK registry like Claude Code / Codex.
expect(
- screen.queryByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
- ).not.toBeInTheDocument();
+ screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
+ ).toBeInTheDocument();
// The flow keeps all four progress segments (nothing is skipped).
expect(
diff --git a/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx b/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx
index fe964b6b..eb8ebc57 100644
--- a/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx
+++ b/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx
@@ -231,9 +231,10 @@ describe("SetupAcpSecretsStep", () => {
).toBeInTheDocument();
});
- it("renders a login-only screen (banner, no key fields) for a credential-less provider", () => {
- // Gemini authenticates via browser OAuth — no API-key fields. The step is
- // purely a login-status screen and still shows the "signed in" banner.
+ it("renders Gemini's credential fields and the 'signed in' banner together", () => {
+ // Gemini's key/base-URL come from the SDK registry like the others, so the
+ // step shows the GEMINI_API_KEY field AND the detection banner (its Google
+ // login takes precedence, but a key can still be entered).
acpAuthStatusMock.mockReturnValue({
status: "authenticated",
isChecking: false,
@@ -245,8 +246,8 @@ describe("SetupAcpSecretsStep", () => {
screen.getByTestId("onboarding-acp-auth-detected"),
).toBeInTheDocument();
expect(
- screen.queryByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
- ).not.toBeInTheDocument();
+ screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
+ ).toBeInTheDocument();
});
it("shows no banner when the provider is not authenticated", () => {
diff --git a/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx b/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
index 9f2cc1f4..3eddca33 100644
--- a/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
+++ b/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
@@ -24,8 +24,8 @@ 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"``,
- * ``"gemini-cli"``) simply yield no fields. */
+ * form. Providers without a credentials entry (``"openhands"``) simply yield
+ * no fields. */
providerKey: OnboardingAgentId;
/**
* Whether this is the currently visible onboarding slide. The modal mounts
@@ -39,18 +39,20 @@ interface SetupAcpSecretsStepProps {
}
/**
- * Onboarding credentials step for ACP providers that authenticate via an
- * env-var API key (Claude Code, Codex). The fields are derived from
+ * Onboarding credentials step for ACP providers that expose an env-var API key
+ * (Claude Code, Codex, Gemini CLI). The fields are derived from
* {@link getAcpProviderSecrets}; each one maps 1:1 to a **global secret**
* whose name equals the env var the agent-server exports into the provider
* subprocess. Saving here is therefore the same as adding the secret under
* Settings → Secrets — it shows up there afterwards.
*
- * The step is intentionally skippable: a user may authenticate Claude Code via
- * a subscription login, or already have the env var set on the backend, so we
- * never block "Next" on a value. Empty fields are simply not written; a field
- * whose secret already exists shows an "already saved" placeholder and is left
- * untouched unless the user types a replacement.
+ * The step is intentionally skippable: a user may authenticate via a
+ * subscription / OAuth login (a Claude login, or Gemini's Google login), or
+ * already have the env var set on the backend, so we never block "Next" on a
+ * value — and the login probe shows a "you're already signed in" banner when it
+ * detects one. Empty fields are simply not written; 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,
@@ -73,11 +75,6 @@ export function SetupAcpSecretsStep({
() => getAcpProviderSecrets(providerKey),
[providerKey],
);
- // Providers like Gemini authenticate via an interactive browser/OAuth login
- // and have no API-key fields. For those the step is purely a login-status
- // screen: it shows the "you're signed in" banner (or how to sign in) with no
- // inputs to fill.
- const hasFields = fields.length > 0;
const [values, setValues] = React.useState>({});
const [isSaving, setIsSaving] = React.useState(false);
@@ -129,20 +126,15 @@ export function SetupAcpSecretsStep({
>
- {t(
- hasFields
- ? I18nKey.ONBOARDING$ACP_SECRETS_TITLE
- : I18nKey.ONBOARDING$ACP_LOGIN_TITLE,
- { provider: providerName },
- )}
+ {t(I18nKey.ONBOARDING$ACP_SECRETS_TITLE)}
- {t(
- hasFields
- ? I18nKey.ONBOARDING$ACP_SECRETS_SUBTITLE
- : I18nKey.ONBOARDING$ACP_LOGIN_SUBTITLE,
- { provider: providerName },
- )}
+ {t(I18nKey.ONBOARDING$ACP_SECRETS_SUBTITLE, {
+ provider: providerName,
+ })}
+
+
+ {t(I18nKey.ONBOARDING$ACP_SECRETS_SUBSCRIPTION_NOTE)}
@@ -158,12 +150,9 @@ export function SetupAcpSecretsStep({
aria-hidden
/>
- {t(
- hasFields
- ? I18nKey.ONBOARDING$ACP_AUTH_DETECTED
- : I18nKey.ONBOARDING$ACP_AUTH_DETECTED_NO_KEY,
- { provider: providerName },
- )}
+ {t(I18nKey.ONBOARDING$ACP_AUTH_DETECTED, {
+ provider: providerName,
+ })}
) : isCheckingAuth ? (
diff --git a/src/constants/acp-providers.ts b/src/constants/acp-providers.ts
index cc21df58..5b37947e 100644
--- a/src/constants/acp-providers.ts
+++ b/src/constants/acp-providers.ts
@@ -190,50 +190,45 @@ export interface ACPProviderSecretField {
hint_key: I18nKey;
}
-// Credentials Canvas prompts for during onboarding, keyed by ACP registry key.
-// Only providers that authenticate through an env-var API key appear here:
-// Claude Code (Anthropic) and Codex (OpenAI). Gemini CLI authenticates via an
-// interactive OAuth login rather than a static key, so it has no entry and its
-// onboarding credentials step is skipped. Every field is optional (the step is
-// skippable): the API keys render masked, the base-URL entries are plain-text
-// overrides for proxies/gateways. A provider with no entry simply shows no
-// credentials step.
-const ACP_PROVIDER_SECRETS: Record = {
- "claude-code": [
- {
- name: "ANTHROPIC_API_KEY",
- secret: true,
- hint_key: I18nKey.ONBOARDING$ACP_SECRET_API_KEY_HINT,
- },
- {
- name: "ANTHROPIC_BASE_URL",
- hint_key: I18nKey.ONBOARDING$ACP_SECRET_BASE_URL_HINT,
- },
- ],
- codex: [
- {
- name: "OPENAI_API_KEY",
- secret: true,
- hint_key: I18nKey.ONBOARDING$ACP_SECRET_API_KEY_HINT,
- },
- {
- name: "OPENAI_BASE_URL",
- hint_key: I18nKey.ONBOARDING$ACP_SECRET_BASE_URL_HINT,
- },
- ],
-};
-
/**
* List the credentials Canvas should prompt for when onboarding the given ACP
- * provider. Returns ``[]`` for OpenHands, the ``"custom"`` preset, providers
- * that don't use a static API key (Gemini CLI), and any unknown key — callers
- * treat an empty list as "no credentials step for this provider".
+ * provider, derived from the SDK registry's ``api_key_env_var`` /
+ * ``base_url_env_var`` (mirrored via ``@openhands/typescript-client``) rather
+ * than a hand-maintained per-provider list — so the field names track the SDK
+ * as providers are added or renamed, with no parallel copy to drift. Each field
+ * name equals the env var the agent-server exports into the provider subprocess
+ * (which is what makes a saved secret reach the CLI); the API key renders
+ * masked, the base URL plain-text. All three built-ins (Claude Code, Codex,
+ * Gemini CLI) expose an API key + optional base URL.
+ *
+ * Every field is optional — the step is skippable — and a subscription / OAuth
+ * login takes precedence over a key at runtime (most relevant for Gemini, whose
+ * Google login is the common local path).
+ *
+ * Returns ``[]`` for OpenHands, the ``"custom"`` preset, any unknown key, and a
+ * future OAuth-only provider whose registry entry has no ``api_key_env_var`` —
+ * callers treat an empty list as "no credentials step for this provider".
*/
export function getAcpProviderSecrets(
key: string | null | undefined,
): ACPProviderSecretField[] {
- if (!key) return [];
- return ACP_PROVIDER_SECRETS[key] ?? [];
+ const info = key ? getClientAcpProvider(key) : null;
+ if (!info) return [];
+ const fields: ACPProviderSecretField[] = [];
+ if (info.api_key_env_var) {
+ fields.push({
+ name: info.api_key_env_var,
+ secret: true,
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_API_KEY_HINT,
+ });
+ }
+ if (info.base_url_env_var) {
+ fields.push({
+ name: info.base_url_env_var,
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_BASE_URL_HINT,
+ });
+ }
+ return fields;
}
/**
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index caa27e8d..ee0019be 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -1,4 +1,21 @@
{
+ "ONBOARDING$ACP_SECRETS_SUBSCRIPTION_NOTE": {
+ "ar": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "ca": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "de": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "en": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "es": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "fr": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "it": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "ja": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "ko-KR": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "no": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "pt": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "tr": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "uk": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "zh-CN": "Already signed in with a subscription or login? Leave these blank — no API key needed.",
+ "zh-TW": "Already signed in with a subscription or login? Leave these blank — no API key needed."
+ },
"ONBOARDING$ACP_AUTH_DETECTED": {
"ar": "You're already signed in to {{provider}} — adding an API key below is optional.",
"ca": "You're already signed in to {{provider}} — adding an API key below is optional.",
@@ -33,57 +50,6 @@
"zh-CN": "Checking for an existing {{provider}} login…",
"zh-TW": "Checking for an existing {{provider}} login…"
},
- "ONBOARDING$ACP_LOGIN_TITLE": {
- "ar": "Sign in to {{provider}}",
- "ca": "Sign in to {{provider}}",
- "de": "Sign in to {{provider}}",
- "en": "Sign in to {{provider}}",
- "es": "Sign in to {{provider}}",
- "fr": "Sign in to {{provider}}",
- "it": "Sign in to {{provider}}",
- "ja": "Sign in to {{provider}}",
- "ko-KR": "Sign in to {{provider}}",
- "no": "Sign in to {{provider}}",
- "pt": "Sign in to {{provider}}",
- "tr": "Sign in to {{provider}}",
- "uk": "Sign in to {{provider}}",
- "zh-CN": "Sign in to {{provider}}",
- "zh-TW": "Sign in to {{provider}}"
- },
- "ONBOARDING$ACP_LOGIN_SUBTITLE": {
- "ar": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "ca": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "de": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "en": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "es": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "fr": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "it": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "ja": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "ko-KR": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "no": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "pt": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "tr": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "uk": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "zh-CN": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in.",
- "zh-TW": "{{provider}} signs in through your browser — no API key required. If you're not signed in yet, launch it once in a terminal to log in."
- },
- "ONBOARDING$ACP_AUTH_DETECTED_NO_KEY": {
- "ar": "You're already signed in to {{provider}} — you're all set.",
- "ca": "You're already signed in to {{provider}} — you're all set.",
- "de": "You're already signed in to {{provider}} — you're all set.",
- "en": "You're already signed in to {{provider}} — you're all set.",
- "es": "You're already signed in to {{provider}} — you're all set.",
- "fr": "You're already signed in to {{provider}} — you're all set.",
- "it": "You're already signed in to {{provider}} — you're all set.",
- "ja": "You're already signed in to {{provider}} — you're all set.",
- "ko-KR": "You're already signed in to {{provider}} — you're all set.",
- "no": "You're already signed in to {{provider}} — you're all set.",
- "pt": "You're already signed in to {{provider}} — you're all set.",
- "tr": "You're already signed in to {{provider}} — you're all set.",
- "uk": "You're already signed in to {{provider}} — you're all set.",
- "zh-CN": "You're already signed in to {{provider}} — you're all set.",
- "zh-TW": "You're already signed in to {{provider}} — you're all set."
- },
"MAINTENANCE$SCHEDULED_MESSAGE": {
"en": "Scheduled maintenance will begin at {{time}}",
"ja": "予定されたメンテナンスは{{time}}に開始されます",
From c591abbd407dab4d88f614c7f97fffeead30b74b Mon Sep 17 00:00:00 2001
From: Debug Agent
Date: Tue, 2 Jun 2026 10:58:48 +0200
Subject: [PATCH 6/7] docs: drop transient-branch-state comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove comments that narrated this (unreleased) branch's evolution rather than
describing the code — e.g. 'the flow no longer skips it', 'now exposes',
'login-status screen (no key fields)'. The modal slide-2 comment now just states
current behavior (every ACP provider shows the credentials form).
---
.../onboarding/onboarding-modal.test.tsx | 9 ++++-----
.../features/onboarding/onboarding-modal.tsx | 14 ++++----------
2 files changed, 8 insertions(+), 15 deletions(-)
diff --git a/__tests__/components/onboarding/onboarding-modal.test.tsx b/__tests__/components/onboarding/onboarding-modal.test.tsx
index 184dd4a6..1a635f73 100644
--- a/__tests__/components/onboarding/onboarding-modal.test.tsx
+++ b/__tests__/components/onboarding/onboarding-modal.test.tsx
@@ -294,13 +294,12 @@ describe("OnboardingModal", () => {
expect(settings.contains(next)).toBe(false);
});
- it("shows slide 2 with Gemini's credential fields (its step is no longer skipped)", async () => {
+ it("shows slide 2 with Gemini's credential fields", async () => {
renderModal();
const user = userEvent.setup();
// Pick Gemini CLI: its key/base-URL come from the SDK registry like the
- // other providers, so the slide shows the GEMINI_API_KEY field — the flow
- // no longer skips it.
+ // other providers, so the slide shows the GEMINI_API_KEY field.
await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli"));
await user.click(screen.getByTestId("onboarding-agent-next"));
await waitFor(
@@ -328,8 +327,8 @@ describe("OnboardingModal", () => {
expect(
screen.getByTestId("onboarding-step-setup-acp-secrets"),
).toBeInTheDocument();
- // Gemini now exposes credential fields (GEMINI_API_KEY), derived from the
- // SDK registry like Claude Code / Codex.
+ // Gemini exposes credential fields (GEMINI_API_KEY), derived from the SDK
+ // registry like Claude Code / Codex.
expect(
screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
).toBeInTheDocument();
diff --git a/src/components/features/onboarding/onboarding-modal.tsx b/src/components/features/onboarding/onboarding-modal.tsx
index 07daa3c4..79c94bbc 100644
--- a/src/components/features/onboarding/onboarding-modal.tsx
+++ b/src/components/features/onboarding/onboarding-modal.tsx
@@ -93,16 +93,10 @@ export function OnboardingModal({ onClose }: OnboardingModalProps) {
const [selectedAgentId, setSelectedAgentId] =
React.useState("openhands");
- // Slide index 2 is the "provider credentials" slot. Its content depends on
- // the chosen agent:
- // * OpenHands → the LLM-setup form (its own LLM config).
- // * Claude Code / Codex → the ACP secrets form (API key + base URL), since
- // these providers authenticate via env-var keys.
- // * Gemini CLI → a login-status screen (no key fields; it signs in
- // via browser OAuth) so the "you're already signed
- // in" banner can still render.
- // Every agent has slide-2 content, so the flow is a plain 4-step sequence
- // with no skipping.
+ // Slide index 2 is the "provider credentials" slot:
+ // * OpenHands → the LLM-setup form (its own LLM config).
+ // * Any ACP provider (Claude Code / Codex / Gemini) → the ACP credentials
+ // form: API key + optional base URL, with a login-detection banner.
const isOpenHands = selectedAgentId === "openhands";
const goNext = React.useCallback(
() => setCurrentStep((step) => Math.min(step + 1, TOTAL_STEPS - 1)),
From 69a8feadcda3e18dd918bc887508aa217c582019 Mon Sep 17 00:00:00 2001
From: Debug Agent
Date: Tue, 2 Jun 2026 11:07:28 +0200
Subject: [PATCH 7/7] fix(onboarding): clearer "already signed in" banner copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The banner said "adding an API key below is optional", which read ambiguously.
Reword to "you can leave the fields below blank" — the subscription/login takes
precedence over an env API key for all three providers (verified: Claude reports
authMethod=claude.ai even with ANTHROPIC_API_KEY set; Codex stays on ChatGPT;
Gemini uses its OAuth auth-type), so the key isn't needed while signed in.
Also hide the redundant "leave these blank" subtitle note once authenticated —
the banner already conveys it.
---
.../steps/setup-acp-secrets-step.tsx | 10 +++++--
src/i18n/translation.json | 30 +++++++++----------
2 files changed, 22 insertions(+), 18 deletions(-)
diff --git a/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx b/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
index 3eddca33..60a7d745 100644
--- a/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
+++ b/src/components/features/onboarding/steps/setup-acp-secrets-step.tsx
@@ -133,9 +133,13 @@ export function SetupAcpSecretsStep({
provider: providerName,
})}
-
- {t(I18nKey.ONBOARDING$ACP_SECRETS_SUBSCRIPTION_NOTE)}
-
+ {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)}
+
+ )}
{authStatus === "authenticated" ? (
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index ee0019be..9ce31966 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -17,21 +17,21 @@
"zh-TW": "Already signed in with a subscription or login? Leave these blank — no API key needed."
},
"ONBOARDING$ACP_AUTH_DETECTED": {
- "ar": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "ca": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "de": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "en": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "es": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "fr": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "it": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "ja": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "ko-KR": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "no": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "pt": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "tr": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "uk": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "zh-CN": "You're already signed in to {{provider}} — adding an API key below is optional.",
- "zh-TW": "You're already signed in to {{provider}} — adding an API key below is optional."
+ "ar": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "ca": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "de": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "en": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "es": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "fr": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "it": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "ja": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "ko-KR": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "no": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "pt": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "tr": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "uk": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "zh-CN": "You're already signed in to {{provider}} — you can leave the fields below blank.",
+ "zh-TW": "You're already signed in to {{provider}} — you can leave the fields below blank."
},
"ONBOARDING$ACP_AUTH_CHECKING": {
"ar": "Checking for an existing {{provider}} login…",