diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 56b9edddb9..e10d52e8a7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -837,6 +837,8 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentCheck, AdapterEnvironmentTestResult, + AdapterAuthSignal, + AdapterAuthSignalResponse, AdapterAuthSessionStatus, AdapterAuthSessionInternalStatus, AdapterAuthSessionFailure, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 9f7453843d..d1cadf7cb5 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -310,3 +310,15 @@ export interface AdapterEnvironmentTestResult { checks: AdapterEnvironmentCheck[]; testedAt: string; } + +// The cheap tri-state authentication signal for one adapter type. "present" +// means the host already has a usable credential. "absent" means the host has +// no usable credential yet, but the caller can add one. "unknown" means the +// route could not check, or the adapter type has no cheap signal. The route +// that returns this value reads host-local state only; it never leases a +// sandbox and never runs a shell command or a model request. +export type AdapterAuthSignal = "present" | "absent" | "unknown"; + +export interface AdapterAuthSignalResponse { + status: AdapterAuthSignal; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 2d0a08691c..6514fdacd0 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -287,6 +287,8 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentCheck, AdapterEnvironmentTestResult, + AdapterAuthSignal, + AdapterAuthSignalResponse, AdapterAuthSessionStatus, AdapterAuthSessionInternalStatus, AdapterAuthSessionFailure, diff --git a/server/src/__tests__/adapter-auth-signal-routes.test.ts b/server/src/__tests__/adapter-auth-signal-routes.test.ts new file mode 100644 index 0000000000..3a8fc01b97 --- /dev/null +++ b/server/src/__tests__/adapter-auth-signal-routes.test.ts @@ -0,0 +1,415 @@ +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The cheap host-local authentication-signal route. It reads no sandbox and +// runs no shell command or model process, so every test drives it through a +// plain in-process Express app with fake services -- no database, no +// sandbox provider, and no adapter execution. + +const COMPANY_1 = "company-1"; +const OTHER_COMPANY = "company-2"; +const OWNER_A = "user-a"; +const ENVIRONMENT_1 = "11111111-1111-4111-8111-111111111111"; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + getChainOfCommand: vi.fn(async () => []), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + decide: vi.fn(), + hasPermission: vi.fn(), + getMembership: vi.fn(async () => null), + listPrincipalGrants: vi.fn(async () => []), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), + resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config })), + collectMissingRuntimeBindings: vi.fn(async () => [] as Array>), + resolveEnvBindings: vi.fn(async () => ({ + env: {} as Record, + secretKeys: new Set(), + manifest: [], + })), + readClaudeOAuthUserSecretStatus: vi.fn(async () => null as { secretId: string; latestVersion: number } | null), +})); + +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), + releaseLease: vi.fn(), + listBoundCompanyIds: vi.fn(async () => [] as string[]), +})); + +const mockEnvironmentRuntime = vi.hoisted(() => ({ + acquireRunLease: vi.fn(), + realizeWorkspace: vi.fn(), + getDriver: vi.fn(() => ({ releaseRunLease: vi.fn(async () => undefined) })), +})); + +const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn()); +const mockInstanceSettingsService = vi.hoisted(() => ({ + getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), + getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })), +})); + +// The one host-local Codex readiness predictor the route calls. The test +// controls its resolved value and its failure, so it stays independent of a +// real Codex home on disk. +const mockEvaluateCodexCredentialReadiness = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => ({}), + accessService: () => mockAccessService, + approvalService: () => ({}), + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), + companySkillService: () => ({ + listRuntimeSkillEntries: vi.fn(async () => []), + resolveRequestedSkillKeys: vi.fn(async () => []), + }), + budgetService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(), + cancelActiveForAgent: vi.fn(), + }), + ISSUE_LIST_DEFAULT_LIMIT: 50, + issueApprovalService: () => ({}), + issueRecoveryActionService: () => ({}), + issueService: () => ({}), + logActivity: vi.fn(), + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => ({}), +})); + +vi.mock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, +})); + +vi.mock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, +})); + +vi.mock("../services/environment-runtime.js", () => ({ + environmentRuntimeService: () => mockEnvironmentRuntime, +})); + +vi.mock("../services/environment-execution-target.js", () => ({ + resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget, +})); + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, +})); + +vi.mock("@paperclipai/adapter-codex-local/server", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + evaluateCodexCredentialReadiness: mockEvaluateCodexCredentialReadiness, + }; +}); + +let currentActor: Record; + +function boardActor(userId: string, companyIds: string[] = [COMPANY_1, OTHER_COMPANY]): Record { + return { + type: "board", + userId, + companyIds, + source: "local_implicit", + isInstanceAdmin: false, + }; +} + +async function createApp() { + const [{ agentRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/agents.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as unknown as { actor: unknown }).actor = currentActor; + next(); + }); + app.use("/api", agentRoutes({} as never)); + app.use(errorHandler); + return app; +} + +const authSignalPath = (companyId: string, type: string, environmentId?: string) => + `/api/companies/${companyId}/adapters/${type}/auth-signal${environmentId ? `?environmentId=${environmentId}` : ""}`; + +describe("adapter auth-signal route", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + currentActor = boardActor(OWNER_A); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); + mockEnvironmentService.getById.mockResolvedValue({ + id: ENVIRONMENT_1, + companyId: COMPANY_1, + name: "Sandbox QA", + driver: "sandbox", + status: "active", + config: { provider: "fake-plugin" }, + envVars: {}, + }); + mockEnvironmentService.listBoundCompanyIds.mockResolvedValue([]); + mockSecretService.resolveEnvBindings.mockResolvedValue({ + env: {}, + secretKeys: new Set(), + manifest: [], + }); + mockSecretService.readClaudeOAuthUserSecretStatus.mockResolvedValue(null); + mockEvaluateCodexCredentialReadiness.mockResolvedValue({ + managed: true, + authMode: "subscription", + ready: false, + effectiveHome: "/tmp/codex-home", + sharedSourceHome: "/tmp/codex-shared-home", + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns present for codex_local when the readiness predictor reports ready", async () => { + mockEvaluateCodexCredentialReadiness.mockResolvedValueOnce({ + managed: true, + authMode: "subscription", + ready: true, + effectiveHome: "/tmp/codex-home", + sharedSourceHome: "/tmp/codex-shared-home", + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "codex_local")); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "present" }); + expect(mockEvaluateCodexCredentialReadiness).toHaveBeenCalledWith( + expect.objectContaining({ companyId: COMPANY_1 }), + ); + }); + + it("returns absent for codex_local when the readiness predictor reports not ready", async () => { + mockEvaluateCodexCredentialReadiness.mockResolvedValueOnce({ + managed: true, + authMode: "subscription", + ready: false, + effectiveHome: "/tmp/codex-home", + sharedSourceHome: "/tmp/codex-shared-home", + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "codex_local")); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "absent" }); + }); + + it("returns unknown for codex_local when the readiness predictor throws", async () => { + mockEvaluateCodexCredentialReadiness.mockRejectedValueOnce(new Error("boom")); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "codex_local")); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "unknown" }); + }); + + it("returns unknown for codex_local on a sandbox environment even when the host reports ready", async () => { + // The host readiness predictor is ready, but the selected sandbox holds no + // credential of its own. The route must not let the unrelated host login + // hide the sandbox's own sign-in panel. + mockEvaluateCodexCredentialReadiness.mockResolvedValueOnce({ + managed: true, + authMode: "subscription", + ready: true, + effectiveHome: "/tmp/codex-home", + sharedSourceHome: "/tmp/codex-shared-home", + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "codex_local", ENVIRONMENT_1)); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "unknown" }); + expect(mockEvaluateCodexCredentialReadiness).not.toHaveBeenCalled(); + }); + + it("returns present for codex_local on a sandbox environment that holds its own API key", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: ENVIRONMENT_1, + companyId: COMPANY_1, + name: "Sandbox QA", + driver: "sandbox", + status: "active", + config: { provider: "fake-plugin" }, + envVars: { + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-1" }, + }, + }); + mockSecretService.resolveEnvBindings.mockResolvedValueOnce({ + env: { OPENAI_API_KEY: "resolved-key" }, + secretKeys: new Set(["OPENAI_API_KEY"]), + manifest: [], + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "codex_local", ENVIRONMENT_1)); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "present" }); + expect(mockEvaluateCodexCredentialReadiness).not.toHaveBeenCalled(); + }); + + it("uses the host readiness predictor for codex_local on a local-driver environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: ENVIRONMENT_1, + companyId: COMPANY_1, + name: "Local host", + driver: "local", + status: "active", + config: {}, + envVars: {}, + }); + mockEvaluateCodexCredentialReadiness.mockResolvedValueOnce({ + managed: true, + authMode: "subscription", + ready: true, + effectiveHome: "/tmp/codex-home", + sharedSourceHome: "/tmp/codex-shared-home", + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "codex_local", ENVIRONMENT_1)); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "present" }); + expect(mockEvaluateCodexCredentialReadiness).toHaveBeenCalledWith( + expect.objectContaining({ companyId: COMPANY_1 }), + ); + }); + + it("returns present for claude_local when the environment holds a non-empty token", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: ENVIRONMENT_1, + companyId: COMPANY_1, + name: "Sandbox QA", + driver: "sandbox", + status: "active", + config: { provider: "fake-plugin" }, + envVars: { + CLAUDE_CODE_OAUTH_TOKEN: { type: "secret_ref", secretId: "secret-1" }, + }, + }); + mockSecretService.resolveEnvBindings.mockResolvedValueOnce({ + env: { CLAUDE_CODE_OAUTH_TOKEN: "resolved-token" }, + secretKeys: new Set(["CLAUDE_CODE_OAUTH_TOKEN"]), + manifest: [], + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "claude_local", ENVIRONMENT_1)); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "present" }); + expect(mockSecretService.readClaudeOAuthUserSecretStatus).not.toHaveBeenCalled(); + }); + + it("returns present for claude_local when the owner holds a stored login and the environment holds no key", async () => { + mockSecretService.readClaudeOAuthUserSecretStatus.mockResolvedValueOnce({ + secretId: "secret-1", + latestVersion: 1, + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "claude_local", ENVIRONMENT_1)); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "present" }); + expect(mockSecretService.readClaudeOAuthUserSecretStatus).toHaveBeenCalledWith(COMPANY_1, OWNER_A); + }); + + it("returns absent for claude_local when neither source holds a value", async () => { + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "claude_local")); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "absent" }); + }); + + it("returns unknown for an adapter type that has no cheap signal", async () => { + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "cursor_local")); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual({ status: "unknown" }); + expect(mockEvaluateCodexCredentialReadiness).not.toHaveBeenCalled(); + expect(mockSecretService.readClaudeOAuthUserSecretStatus).not.toHaveBeenCalled(); + }); + + it("rejects an environment that belongs to another company", async () => { + mockEnvironmentService.listBoundCompanyIds.mockResolvedValueOnce([OTHER_COMPANY]); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "claude_local", ENVIRONMENT_1)); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockSecretService.resolveEnvBindings).not.toHaveBeenCalled(); + expect(mockSecretService.readClaudeOAuthUserSecretStatus).not.toHaveBeenCalled(); + }); + + it("rejects a caller who cannot create agents for the company", async () => { + mockAccessService.decide.mockResolvedValue({ + allowed: false, + reason: "deny_no_grant", + explanation: "Not allowed by any grant", + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "claude_local")); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockEnvironmentService.getById).not.toHaveBeenCalled(); + expect(mockSecretService.readClaudeOAuthUserSecretStatus).not.toHaveBeenCalled(); + }); + + it("returns a response body that holds only the status field", async () => { + mockEvaluateCodexCredentialReadiness.mockResolvedValueOnce({ + managed: true, + authMode: "subscription", + ready: true, + effectiveHome: "/tmp/codex-home", + sharedSourceHome: "/tmp/codex-shared-home", + }); + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "codex_local")); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(Object.keys(res.body)).toEqual(["status"]); + }); + + it("takes no sandbox lease and starts no model process", async () => { + const app = await createApp(); + + const res = await request(app).get(authSignalPath(COMPANY_1, "codex_local", ENVIRONMENT_1)); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled(); + expect(mockResolveEnvironmentExecutionTarget).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index cb510ce4d3..cfff6bb3d1 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -80,6 +80,8 @@ import type { AdapterEnvironmentTestResult, AdapterModelProfileDefinition, } from "@paperclipai/adapter-utils"; +import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-local/server"; +import type { AdapterAuthSignal, AdapterAuthSignalResponse } from "@paperclipai/shared"; import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; import { skillVersionSelectionMap } from "../services/runtime-skill-selections.js"; import { secretService } from "../services/secrets.js"; @@ -2696,6 +2698,126 @@ export function agentRoutes( }, ); + // The claude_local branch of the auth-signal read. It checks two host-local + // sources for a usable Claude Code OAuth token: the resolved envVars of the + // caller's selected environment, and the caller's own stored Claude login. It + // returns "present" the moment either source holds a non-empty token, so it + // never resolves more than the one env key it needs. + async function evaluateClaudeAuthSignal( + req: Request, + companyId: string, + environmentId: string | null, + ): Promise { + if (environmentId) { + const environment = await environmentsSvc.getById(environmentId); + const environmentEnv = Object.fromEntries( + Object.entries(parseObject(environment?.envVars)).filter( + ([key]) => !isForbiddenConfigEnvKey(key), + ), + ); + const tokenBinding = environmentEnv.CLAUDE_CODE_OAUTH_TOKEN; + if (tokenBinding !== undefined) { + const resolution = await secretsSvc.resolveEnvBindings( + companyId, + { CLAUDE_CODE_OAUTH_TOKEN: tokenBinding }, + buildActorSecretContext(req, { consumerType: "environment", consumerId: environmentId }), + ); + if (asNonEmptyString(resolution.env.CLAUDE_CODE_OAUTH_TOKEN)) { + return "present"; + } + } + } + const ownerUserId = req.actor.userId; + if (ownerUserId) { + const stored = await secretsSvc.readClaudeOAuthUserSecretStatus(companyId, ownerUserId); + if (stored) return "present"; + } + return "absent"; + } + + // The codex_local branch of the auth-signal read. The host filesystem check + // (`evaluateCodexCredentialReadiness` against `process.env`) describes only + // the Paperclip host, so it is authoritative for the null-environment and + // "local" driver cases, where the host is the execution target. For a + // non-local environment (a sandbox), the host's own credential state says + // nothing about that sandbox, so the route checks the environment's own + // OPENAI_API_KEY binding instead and otherwise reports "unknown" -- never + // "present" from a host login the sandbox does not share. + async function evaluateCodexAuthSignal( + req: Request, + companyId: string, + environmentId: string | null, + ): Promise { + if (environmentId) { + const environment = await environmentsSvc.getById(environmentId); + if (environment && environment.driver !== "local") { + const environmentEnv = Object.fromEntries( + Object.entries(parseObject(environment.envVars)).filter( + ([key]) => !isForbiddenConfigEnvKey(key), + ), + ); + const apiKeyBinding = environmentEnv.OPENAI_API_KEY; + if (apiKeyBinding !== undefined) { + const resolution = await secretsSvc.resolveEnvBindings( + companyId, + { OPENAI_API_KEY: apiKeyBinding }, + buildActorSecretContext(req, { consumerType: "environment", consumerId: environmentId }), + ); + if (asNonEmptyString(resolution.env.OPENAI_API_KEY)) { + return "present"; + } + } + return "unknown"; + } + } + + const readiness = await evaluateCodexCredentialReadiness({ + env: process.env, + companyId, + configuredCodexHome: null, + configuredApiKey: null, + }); + return readiness.ready ? "present" : "absent"; + } + + // The cheap host-local authentication signal for one adapter type. The route + // reads host-local state only: a stored Claude login, a resolved environment + // env var, or the local Codex credential readiness check. It leases no + // sandbox, starts no shell command, and starts no model request. The two + // access gates below run before any read, so a caller who cannot create + // agents for the company and a foreign environment both fail closed before + // the route touches a credential source. + router.get( + "/companies/:companyId/adapters/:type/auth-signal", + async (req, res) => { + const companyId = req.params.companyId as string; + const type = req.params.type as string; + await assertCanCreateAgentsForCompany(req, companyId); + const environmentId = asNonEmptyString(req.query.environmentId); + if (environmentId) { + await assertAdapterTestEnvironmentForCompany(companyId, environmentId); + } + res.setHeader("Cache-Control", "no-store"); + + let status: AdapterAuthSignal = "unknown"; + try { + if (type === "claude_local") { + status = await evaluateClaudeAuthSignal(req, companyId, environmentId); + } else if (type === "codex_local") { + status = await evaluateCodexAuthSignal(req, companyId, environmentId); + } + } catch { + // A failed read is never a claim that the credential is absent. Report + // the neutral "unknown" signal instead, so the wizard falls back to + // showing the login panel. + status = "unknown"; + } + + const body: AdapterAuthSignalResponse = { status }; + res.json(body); + }, + ); + // Start a company-scoped adapter device login. The create form has no agent // identifier, so the route keys on the company and the adapter. The owner // helper requires a board actor with the configuration permission, and it diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index d56bbb8fb3..ba442d6a24 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2187,6 +2187,18 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/adapters/{type}/auth-signal", + tags: ["adapters"], + summary: "Read the cheap host-local authentication signal for an adapter type", + request: { + params: z.object({ companyId: z.string(), type: z.string() }), + query: z.object({ environmentId: z.string().optional() }), + }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + registry.registerPath({ method: "post", path: "/api/companies/{companyId}/adapters/{type}/login-sessions", diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 0755e6aade..724322003c 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -95,4 +95,146 @@ test.describe("Onboarding wizard", () => { // The expanded wizard must not crash the app (Rules-of-Hooks regression). expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); }); + + test("adapter step shows the login panel from the cheap auth signal, and blocks the hire on a failed test", async ({ + page, + }) => { + const pageErrors: string[] = []; + page.on("pageerror", (err) => pageErrors.push(err.message)); + + const flagRes = await page.request.patch("/api/instance/settings/experimental", { + data: { enableConferenceRoomChat: true }, + }); + expect(flagRes.ok()).toBe(true); + + // The login panel's capability gate requires a sandbox environment with a + // login-capable provider, and this throwaway instance has neither: it + // only auto-creates the local environment. Add one fake sandbox + // environment to the real list, make it the instance default, and declare + // its provider's login pseudo-terminal capability — this reproduces the + // gate a real sandbox-backed instance would already pass, without + // changing any other field the rest of the page depends on. + const FAKE_SANDBOX_ENVIRONMENT_ID = "e2e-fake-sandbox-environment"; + const FAKE_SANDBOX_PROVIDER = "e2e-fake-provider"; + + await page.route("**/environments", async (route) => { + const response = await route.fetch(); + const environments = await response.json(); + environments.push({ + id: FAKE_SANDBOX_ENVIRONMENT_ID, + name: "E2E fake sandbox", + description: null, + driver: "sandbox", + status: "active", + config: { provider: FAKE_SANDBOX_PROVIDER }, + envVars: {}, + metadata: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + await route.fulfill({ response, json: environments }); + }); + + await page.route("**/environments/capabilities", async (route) => { + const response = await route.fetch(); + const capabilities = await response.json(); + capabilities.sandboxProviders[FAKE_SANDBOX_PROVIDER] = { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: false, + supportsInteractiveSetup: false, + interactiveSetupConnectionTypes: [], + supportsTemplateCapture: false, + supportsTemplateDelete: false, + supportsLoginPty: true, + source: "plugin", + }; + await route.fulfill({ response, json: capabilities }); + }); + + await page.route("**/instance/settings", async (route) => { + const response = await route.fetch(); + const settings = await response.json(); + settings.defaultEnvironmentId = FAKE_SANDBOX_ENVIRONMENT_ID; + await route.fulfill({ response, json: settings }); + }); + + // Report no ready credential, so the wizard shows the login panel right + // after the adapter is picked, before it ever runs an adapter test. + await page.route("**/adapters/*/auth-signal*", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ status: "absent" }), + }), + ); + + // Fail the adapter test the "Connect" button runs, so the hire gate + // blocks the create and this test can prove no agent is hired. + await page.route("**/test-environment", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + adapterType: "claude_local", + status: "fail", + checks: [ + { + code: "claude_cli_not_found", + level: "fail", + message: "The claude CLI was not found on this host.", + }, + ], + testedAt: new Date().toISOString(), + }), + }), + ); + + let hireCalled = false; + await page.route("**/agent-hires", (route) => { + hireCalled = true; + return route.continue(); + }); + + await page.goto("/onboarding"); + + const startBtn = page.getByRole("button", { + name: /Start Onboarding|New Organization|Add Agent/, + }); + if (await startBtn.count()) { + await startBtn.first().click(); + } + const createCard = page.getByRole("button", { name: /Build a new organization/ }); + if (await createCard.count()) { + await createCard.first().click(); + } + + await expect( + page.getByRole("heading", { name: "What is the name of your organization?" }), + ).toBeVisible({ timeout: 15_000 }); + await page.getByPlaceholder("e.g. Northwind Labs").fill(`${COMPANY_NAME}-auth-signal`); + await page.getByRole("button", { name: /^Continue/ }).click(); + + await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 }); + await page.locator("#onboarding-agent-name").fill("Ada"); + await page.getByRole("button", { name: "Next" }).click(); + + // Step 4 (Connect a model): the default adapter is claude_local, and the + // signal above reports no ready credential, so the login panel must show + // with no button to reuse a saved login. + await expect(page.getByText("Sign in to the environment")).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0); + + await page.getByRole("button", { name: /^Connect/ }).click(); + + // The failed test blocks the hire and shows its own checks. + await expect(page.getByText("The claude CLI was not found on this host.")).toBeVisible({ + timeout: 15_000, + }); + expect(hireCalled).toBe(false); + + expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); + }); }); diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 7cc773a6eb..402d313708 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -8,6 +8,7 @@ import type { AgentInstructionsFileDetail, AgentSkillSnapshot, AdapterEnvironmentTestResult, + AdapterAuthSignalResponse, AdapterAuthSessionResponse, AdapterAuthSessionOwnerResponse, ClaudeSetupTokenSessionResponse, @@ -237,6 +238,12 @@ export const agentsApi = { `/companies/${companyId}/adapters/${type}/test-environment`, data, ), + getAdapterAuthSignal: (companyId: string, type: string, environmentId?: string | null) => { + const query = environmentId ? `?environmentId=${encodeURIComponent(environmentId)}` : ""; + return api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/auth-signal${query}`, + ); + }, invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) => api.post(agentPath(id, companyId, "/heartbeat/invoke"), data), wakeup: ( diff --git a/ui/src/components/OnboardingWizard.adapters.test.tsx b/ui/src/components/OnboardingWizard.adapters.test.tsx index 77e25a9734..6df537f54f 100644 --- a/ui/src/components/OnboardingWizard.adapters.test.tsx +++ b/ui/src/components/OnboardingWizard.adapters.test.tsx @@ -78,6 +78,9 @@ vi.mock("../adapters/adapter-display-registry", () => ({ description: "", icon: () => null, }), + getAdapterLabel: (type: string) => type, + getAdapterLabels: () => ({}) as Record, + isKnownAdapterType: () => true, })); vi.mock("../adapters/use-disabled-adapters", () => ({ useDisabledAdaptersSync: () => mockAdapterRegistry.disabled, diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx index fb4cb0170c..fdccbc6fdd 100644 --- a/ui/src/components/OnboardingWizard.step.test.tsx +++ b/ui/src/components/OnboardingWizard.step.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; import { ONBOARDING_AGENT_STEP, @@ -39,6 +40,7 @@ const mockAgentsApi = vi.hoisted(() => ({ instructionsBundle: vi.fn(), saveInstructionsFile: vi.fn(), testEnvironment: vi.fn(), + getClaudeOAuthTokenStatus: vi.fn(), })); const mockCompaniesApi = vi.hoisted(() => ({ create: vi.fn() })); // The hire path resolves the Test environment before it probes: it reads the @@ -205,6 +207,12 @@ describe("OnboardingWizard — which step it lands on", () => { checks: [], testedAt: new Date("2026-03-02T00:00:00Z").toISOString(), }); + // Onboarding applies a stored Claude login automatically; this suite is + // not testing that path, so default to "no stored value" (the route's + // fixed 404) so the hire path behaves as it did before that feature. + mockAgentsApi.getClaudeOAuthTokenStatus.mockRejectedValue( + new ApiError("Not found", 404, null), + ); mockEnvironmentsApi.list.mockResolvedValue([]); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index d20e21cbfd..27bb045750 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -47,24 +47,45 @@ const mockGoalsApi = vi.hoisted(() => ({ })); const mockAgentsApi = vi.hoisted(() => ({ adapterModels: vi.fn(async () => [] as Array<{ id: string; label: string }>), - testEnvironment: vi.fn(async () => ({ - adapterType: "claude_local", - status: "pass" as const, - checks: [], - testedAt: new Date().toISOString(), - })), + testEnvironment: vi.fn( + async (): Promise => ({ + adapterType: "claude_local", + status: "pass", + checks: [], + testedAt: new Date().toISOString(), + }), + ), hire: vi.fn(async () => ({ agent: { id: "agent-1" }, approval: null })), instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })), saveInstructionsFile: vi.fn(async () => ({})), + // No default implementation: the top-level `beforeEach` sets the "no + // stored value" 404 rejection, using the real `ApiError` class the code + // under test checks with `instanceof`. + getClaudeOAuthTokenStatus: vi.fn(), + getAdapterAuthSignal: vi.fn( + async (): Promise => ({ + status: "present", + }), + ), +})); +// The adapter registry mock below always returns this function, so a test +// can shape the built adapter config (e.g. a configured ANTHROPIC_API_KEY) +// without a real adapter package. +const mockAdapterBuild = vi.hoisted(() => ({ + buildAdapterConfig: vi.fn(() => ({}) as Record), })); // The Connect path loads environment settings before probing; without these // the probe dies on "Could not load environment settings" and the hire never // runs — which reads as a mysterious 0-call assertion, not an error. const mockEnvironmentsApi = vi.hoisted(() => ({ - list: vi.fn(async () => []), + list: vi.fn(async () => [] as Array>), + capabilities: vi.fn( + async (): Promise => + (await import("@paperclipai/shared")).getEnvironmentCapabilities([]), + ), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ - get: vi.fn(async () => ({ defaultEnvironmentId: null })), + get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })), getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })), })); const mockApprovalsApi = vi.hoisted(() => ({ @@ -107,7 +128,7 @@ vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi })) vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi })); vi.mock("../adapters", () => ({ listUIAdapters: () => mockAdapterRegistry.list, - getUIAdapter: () => ({ buildAdapterConfig: () => ({}) }), + getUIAdapter: () => ({ buildAdapterConfig: mockAdapterBuild.buildAdapterConfig }), })); vi.mock("../adapters/metadata", () => ({ isVisualAdapterChoice: () => true })); vi.mock("../adapters/adapter-display-registry", () => ({ @@ -118,6 +139,9 @@ vi.mock("../adapters/adapter-display-registry", () => ({ description: "", icon: () => null, }), + getAdapterLabel: (type: string) => type, + getAdapterLabels: () => ({}) as Record, + isKnownAdapterType: () => true, })); vi.mock("../adapters/use-disabled-adapters", () => ({ useDisabledAdaptersSync: () => mockAdapterRegistry.disabled, @@ -126,13 +150,21 @@ vi.mock("../adapters/use-disabled-adapters", () => ({ // makes it undefined and the call throws. useAdapterRegistryLoaded: () => true, })); +// Adapters with a declared login capability, mirroring the real registry +// closely enough for the login-panel gate: `claude_local` and `codex_local` +// both support a sandbox login. Every other type has none, matching the +// real `useAdapterCapabilities` fallback for an unlisted type. +const ADAPTERS_WITH_LOGIN = new Set(["claude_local", "codex_local"]); vi.mock("../adapters/use-adapter-capabilities", () => ({ - useAdapterCapabilities: () => () => ({ + useAdapterCapabilities: () => (type: string) => ({ supportsInstructionsBundle: false, supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false, + login: ADAPTERS_WITH_LOGIN.has(type) + ? { panelMode: "displayed_code" as const, timeoutPolicy: "fixed" as const } + : undefined, }), })); // Animation / canvas-ish children that add nothing to the logic under test. @@ -142,6 +174,8 @@ vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null })); import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; +import { ADAPTER_AUTH_MISSING_CHECK_CODE, getEnvironmentCapabilities } from "@paperclipai/shared"; +import { CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model"; import { ONBOARDING_STORAGE_KEY, OnboardingWizard } from "./OnboardingWizard"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -198,6 +232,29 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockResolvedValue([]); mockAdapterRegistry.list = []; mockAdapterRegistry.disabled = new Set(); + mockAdapterBuild.buildAdapterConfig.mockReset(); + mockAdapterBuild.buildAdapterConfig.mockReturnValue({}); + // Default: no stored Claude login for the owner. The route returns a + // fixed 404 for a missing value, so the client treats a real `ApiError` + // with that status as "no stored value" rather than a hard failure. + mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); + mockAgentsApi.getClaudeOAuthTokenStatus.mockRejectedValue( + new ApiError("Not found", 404, null), + ); + // Reset to each mock's original default. `mockResolvedValue` / + // `mockReturnValue` overrides a mock's implementation permanently — it + // is not undone by `afterEach`'s `vi.clearAllMocks()`, which only clears + // call history — so a test that customizes one of these must not leak + // its override into the next test. + mockAgentsApi.testEnvironment.mockReset(); + mockAgentsApi.testEnvironment.mockResolvedValue({ + adapterType: "claude_local", + status: "pass" as const, + checks: [], + testedAt: new Date().toISOString(), + }); + mockAgentsApi.hire.mockReset(); + mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null }); mockCompaniesApi.create.mockResolvedValue({ id: "created", name: "Created Co", @@ -498,6 +555,363 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); }); + describe("hire gate: adapter authentication (claude_local, the default onboarding adapter)", () => { + /** Drives the wizard to the Connect step, agent name already filled in. */ + async function openConnectStep() { + mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); + window.localStorage.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ step: 1, onboardingPath: "create", companyName: "Initech" }), + ); + mockDialog.onboardingOptions = {}; + mockCompany.companies = []; + mockCompany.loading = false; + mockCompaniesApi.list.mockResolvedValue([]); + + const { root, queryClient } = render(); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const clickByText = async (match: (text: string) => boolean) => { + const el = [...document.body.querySelectorAll("button")].find((b) => + match(b.textContent?.trim() ?? ""), + )!; + await act(async () => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + }; + + await clickByText((t) => t.startsWith("Continue")); + const agentField = document.body.querySelector( + "#onboarding-agent-name", + ) as HTMLInputElement; + await act(async () => { + setControlledValue(agentField, "Ada"); + }); + await flushReact(); + await clickByText((t) => t.startsWith("Next")); + expect(document.body.textContent).toContain("Connect a model"); + + return { root, clickByText }; + } + + it("blocks the hire on a warn result that holds adapter_auth_missing, and shows the returned checks", async () => { + mockAgentsApi.testEnvironment.mockResolvedValue({ + adapterType: "claude_local", + status: "warn" as const, + checks: [ + { + code: ADAPTER_AUTH_MISSING_CHECK_CODE, + level: "warn" as const, + message: "No stored Claude login was found for this agent.", + }, + ], + testedAt: new Date().toISOString(), + }); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + "No stored Claude login was found for this agent.", + ); + + await act(async () => root.unmount()); + }); + + it("still hires on a warn result that carries no adapter_auth_missing check", async () => { + mockAgentsApi.testEnvironment.mockResolvedValue({ + adapterType: "claude_local", + status: "warn" as const, + checks: [ + { + code: "claude_anthropic_api_key_overrides_subscription", + level: "warn" as const, + message: "ANTHROPIC_API_KEY overrides the subscription login.", + }, + ], + testedAt: new Date().toISOString(), + }); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.hire).toHaveBeenCalled(); + + await act(async () => root.unmount()); + }); + + it("does not open the create path on a cached warn result that holds adapter_auth_missing", async () => { + mockAgentsApi.testEnvironment.mockResolvedValue({ + adapterType: "claude_local", + status: "warn" as const, + checks: [ + { + code: ADAPTER_AUTH_MISSING_CHECK_CODE, + level: "warn" as const, + message: "No stored Claude login was found for this agent.", + }, + ], + testedAt: new Date().toISOString(), + }); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + + // A second Connect must not treat the first (cached) blocking result as + // reusable — it re-probes, and the create path stays closed. + await clickByText((t) => t.startsWith("Connect")); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + + await act(async () => root.unmount()); + }); + + it("sends the fixed Claude binding and applyStoredClaudeLogin when a stored login exists", async () => { + mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); + mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ + secretId: "11111111-1111-1111-1111-111111111111", + latestVersion: 1, + }); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.hire).toHaveBeenCalled(); + const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[]; + const hireBody = hireArgs[1] as { + adapterConfig: { env?: Record }; + applyStoredClaudeLogin?: boolean; + }; + expect(hireBody.applyStoredClaudeLogin).toBe(true); + expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toEqual({ + type: "user_secret_ref", + key: CLAUDE_OAUTH_TOKEN_ENV_KEY, + version: "latest", + required: true, + }); + + await act(async () => root.unmount()); + }); + + it("sends no binding and no flag when the Claude status route returns 404", async () => { + // The default `beforeEach` mock already rejects with a 404 `ApiError`, + // matching "no stored value" — asserted explicitly here to pin the + // scenario this test is named for. + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.hire).toHaveBeenCalled(); + const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[]; + const hireBody = hireArgs[1] as { + adapterConfig: { env?: Record }; + applyStoredClaudeLogin?: boolean; + }; + expect(hireBody.applyStoredClaudeLogin).toBeUndefined(); + expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined(); + + await act(async () => root.unmount()); + }); + + it("sends no binding when the adapter configuration holds a non-empty ANTHROPIC_API_KEY", async () => { + mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); + mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ + secretId: "11111111-1111-1111-1111-111111111111", + latestVersion: 1, + }); + mockAdapterBuild.buildAdapterConfig.mockReturnValue({ + env: { ANTHROPIC_API_KEY: { type: "plain", value: "sk-ant-configured" } }, + }); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.hire).toHaveBeenCalled(); + // The status route must not even be asked — the conflict is decided + // from the adapter configuration alone, before any network round trip. + expect(mockAgentsApi.getClaudeOAuthTokenStatus).not.toHaveBeenCalled(); + const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[]; + const hireBody = hireArgs[1] as { + adapterConfig: { env?: Record }; + applyStoredClaudeLogin?: boolean; + }; + expect(hireBody.applyStoredClaudeLogin).toBeUndefined(); + expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined(); + + await act(async () => root.unmount()); + }); + + it("carries no token value in the hire payload", async () => { + mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); + mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ + secretId: "11111111-1111-1111-1111-111111111111", + latestVersion: 1, + }); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.hire).toHaveBeenCalled(); + const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[]; + const hireBody = hireArgs[1] as { adapterConfig: { env?: Record } }; + const binding = hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY] as + | { type: string } + | undefined; + // A reference, never a value: no `value` field, no `secretId` field + // either — the fixed binding names the env var, not the status + // response's secret id. + expect(binding?.type).toBe("user_secret_ref"); + expect(JSON.stringify(hireBody)).not.toContain("secretId"); + + await act(async () => root.unmount()); + }); + + it("sends the fixed binding in the environment test request when the status route returns 200", async () => { + mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); + mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ + secretId: "11111111-1111-1111-1111-111111111111", + latestVersion: 1, + }); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.testEnvironment).toHaveBeenCalled(); + const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[]; + const testBody = testArgs[2] as { adapterConfig: { env?: Record } }; + expect(testBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toEqual({ + type: "user_secret_ref", + key: CLAUDE_OAUTH_TOKEN_ENV_KEY, + version: "latest", + required: true, + }); + + await act(async () => root.unmount()); + }); + + it("sends no binding in the environment test request when the status route returns 404", async () => { + // The default `beforeEach` mock already rejects with a 404 `ApiError`. + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.testEnvironment).toHaveBeenCalled(); + const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[]; + const testBody = testArgs[2] as { adapterConfig: { env?: Record } }; + expect(testBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined(); + + await act(async () => root.unmount()); + }); + + it("hires when a stored login exists, even though a probe without the binding would warn adapter_auth_missing", async () => { + mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); + mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ + secretId: "11111111-1111-1111-1111-111111111111", + latestVersion: 1, + }); + // Answers like the real sandbox probe: `warn` with `adapter_auth_missing` + // for a configuration with no binding, `pass` once the binding is + // present. This proves the wizard sends the probe the SAME configuration + // it hires with — a probe still sent without the binding would warn and + // block the hire below. + mockAgentsApi.testEnvironment.mockImplementation( + (async (...args: unknown[]) => { + const request = args[2] as { + adapterConfig: { env?: Record }; + }; + const hasBinding = Boolean( + request.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY], + ); + return hasBinding + ? { + adapterType: "claude_local" as const, + status: "pass" as const, + checks: [], + testedAt: new Date().toISOString(), + } + : { + adapterType: "claude_local" as const, + status: "warn" as const, + checks: [ + { + code: ADAPTER_AUTH_MISSING_CHECK_CODE, + level: "warn" as const, + message: "No stored Claude login was found for this agent.", + }, + ], + testedAt: new Date().toISOString(), + }; + }) as unknown as () => Promise< + import("@paperclipai/shared").AdapterEnvironmentTestResult + >, + ); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.hire).toHaveBeenCalled(); + + await act(async () => root.unmount()); + }); + + it("blocks the hire when the probe reports warn with adapter_auth_missing and no stored login exists", async () => { + // The default `beforeEach` mock already rejects with a 404 `ApiError`. + mockAgentsApi.testEnvironment.mockResolvedValue({ + adapterType: "claude_local", + status: "warn" as const, + checks: [ + { + code: ADAPTER_AUTH_MISSING_CHECK_CODE, + level: "warn" as const, + message: "No stored Claude login was found for this agent.", + }, + ], + testedAt: new Date().toISOString(), + }); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + "No working authentication was found", + ); + + await act(async () => root.unmount()); + }); + + it("reads the stored-login status once for each create attempt", async () => { + mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); + mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ + secretId: "11111111-1111-1111-1111-111111111111", + latestVersion: 1, + }); + // Fails after the gate opens, so the button stays clickable for a + // second attempt instead of advancing past step 4. + mockAgentsApi.hire.mockRejectedValue(new Error("hire failed")); + const { root, clickByText } = await openConnectStep(); + + await clickByText((t) => t.startsWith("Connect")); + expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(1); + + await clickByText((t) => t.startsWith("Connect")); + expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(2); + + await act(async () => root.unmount()); + }); + }); + it("re-syncs a restored draft once companies resolve asynchronously (companies start empty/loading)", async () => { // Regression for the initializer-only restore bug: the inner wizard's // ~20 useState(saved?.x ?? default) initializers only read `saved` on @@ -1064,4 +1478,151 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( root.unmount(); }); }); + + describe("the adapter step login panel (cheap auth signal, no adapter test)", () => { + const SANDBOX_ENVIRONMENT = { + id: "env-sandbox-1", + driver: "sandbox" as const, + status: "active" as const, + config: { provider: "daytona" }, + metadata: {}, + }; + const LOCAL_ENVIRONMENT = { + id: "env-local-1", + driver: "local" as const, + status: "active" as const, + config: { provider: "daytona" }, + metadata: { defaultForInstance: true }, + }; + + beforeEach(() => { + mockEnvironmentsApi.list.mockReset(); + mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT]); + mockEnvironmentsApi.capabilities.mockReset(); + mockEnvironmentsApi.capabilities.mockResolvedValue( + getEnvironmentCapabilities([], { + sandboxProviders: { daytona: { supportsLoginPty: true } }, + }), + ); + mockInstanceSettingsApi.get.mockReset(); + mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: "env-sandbox-1" }); + mockInstanceSettingsApi.getExperimental.mockReset(); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false }); + mockAgentsApi.getAdapterAuthSignal.mockReset(); + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); + }); + + /** Drives the wizard to the Connect step with a company already created. */ + async function openStep4(overrides: Record = {}) { + mockCompany.companies = [{ id: "company-new", name: "Initech", issuePrefix: "INI" }]; + mockCompany.loading = false; + mockCompaniesApi.list.mockResolvedValue(mockCompany.companies); + window.localStorage.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ + step: 4, + onboardingPath: "create", + companyName: "Initech", + agentName: "Ada", + createdCompanyId: "company-new", + adapterType: "claude_local", + ...overrides, + }), + ); + mockDialog.onboardingOptions = {}; + + const { root, queryClient } = render(); + await act(async () => { + root.render( + + + , + ); + }); + // The login-panel gate chains several dependent queries (environments, + // instance settings, environment capabilities, then the auth signal + // itself), each settling on its own render. One flush is not always + // enough to reach the end of that chain. + for (let i = 0; i < 5; i++) await flushReact(); + expect(document.body.textContent).toContain("Connect a model"); + return { root, queryClient }; + } + + it("starts no call to the test-environment route on adapter selection", async () => { + const { root } = await openStep4(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + await act(async () => root.unmount()); + }); + + it("shows the login panel for claude_local when the signal reports no ready credential", async () => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + expect(document.body.textContent).toContain("Sign in to the environment"); + await act(async () => root.unmount()); + }); + + it("shows the login panel for codex_local when the signal cannot decide", async () => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" }); + const { root } = await openStep4({ adapterType: "codex_local" }); + expect(document.body.textContent).toContain("Sign in to the environment"); + await act(async () => root.unmount()); + }); + + it("hides the login panel when the signal reports a ready credential", async () => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + expect(document.body.textContent).not.toContain("Sign in to the environment"); + await act(async () => root.unmount()); + }); + + it("renders no 'Use saved login' control", async () => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + expect(document.body.textContent).not.toContain("Use saved login"); + await act(async () => root.unmount()); + }); + + it("reads the signal again after an adapter change", async () => { + mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + + expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith( + "company-new", + "claude_local", + "env-sandbox-1", + ); + + const clickByText = async (match: (text: string) => boolean) => { + const el = [...document.body.querySelectorAll("button")].find((b) => + match(b.textContent?.trim() ?? ""), + )!; + await act(async () => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + }; + + await clickByText((t) => t.startsWith("Advanced settings")); + await clickByText((t) => t === "codex_local"); + + expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith( + "company-new", + "codex_local", + "env-sandbox-1", + ); + + await act(async () => root.unmount()); + }); + + it("hides the panel when the resolved login environment driver is not sandbox", async () => { + mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); + mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + expect(document.body.textContent).not.toContain("Sign in to the environment"); + expect(mockAgentsApi.getAdapterAuthSignal).not.toHaveBeenCalled(); + await act(async () => root.unmount()); + }); + }); }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 138f100c23..5b5dd23dd7 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -5,16 +5,19 @@ import { MotionConfig, motion } from "motion/react"; import type { AdapterEnvironmentTestResult, AgentRole, + ClaudeOAuthTokenStatusResponse, Environment, InstanceSettings, } from "@paperclipai/shared"; -import { AGENT_ROLES, AGENT_ROLE_LABELS } from "@paperclipai/shared"; +import { AGENT_ROLES, AGENT_ROLE_LABELS, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; +import { AdapterLoginPanel } from "./AgentConfigForm"; import { Label } from "./ui/label"; import { Input } from "./ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { useLocation, useNavigate, useParams } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { ApiError } from "../api/client"; import { companiesApi } from "../api/companies"; import { useCompanyListQuery } from "../api/companies-query"; import { goalsApi } from "../api/goals"; @@ -48,6 +51,7 @@ import { isVisualAdapterChoice } from "../adapters/metadata"; import { useDisabledAdaptersSync, useAdapterRegistryLoaded } from "../adapters/use-disabled-adapters"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; +import { buildFixedClaudeOAuthBinding } from "./environment-variables-editor/model"; import { defaultCreateValues } from "./agent-config-defaults"; import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; import { restoreOnboardingState } from "../lib/onboarding-state"; @@ -119,6 +123,44 @@ function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: s return parts.join(" "); } +/** + * True when an adapter-test result blocks a hire. A `fail` status always + * blocks. A `warn` or a `pass` status blocks too when a check reports + * `ADAPTER_AUTH_MISSING_CHECK_CODE`. That check means the agent has no + * working authentication, so it cannot run. A `warn` with no such check + * still lets the hire proceed. The wizard widened the gate for missing + * authentication only, not for every other warning. + */ +function blocksAgentCreate(result: AdapterEnvironmentTestResult): boolean { + if (result.status === "fail") return true; + return result.checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE); +} + +/** True when `value` is a plain object, so callers can spread it as env config. */ +function isEnvRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const ANTHROPIC_API_KEY_ENV_KEY = "ANTHROPIC_API_KEY"; + +/** + * True when the adapter configuration carries a non-empty ANTHROPIC_API_KEY. + * The server rejects that key together with the fixed Claude login binding + * (see `assertClaudeOAuthBindingInvariant` in `server/src/services/secrets.ts`). + * This checks the built configuration first, so onboarding never sends a + * hire the server would reject. + */ +function adapterConfigHasAnthropicApiKey(config: Record): boolean { + if (!isEnvRecord(config.env)) return false; + const binding = config.env[ANTHROPIC_API_KEY_ENV_KEY]; + if (typeof binding === "string") return binding.trim().length > 0; + if (!isEnvRecord(binding)) return false; + if (binding.type === "plain") { + return typeof binding.value === "string" && binding.value.trim().length > 0; + } + return binding.type === "secret_ref" || binding.type === "user_secret_ref"; +} + // Exported so tests write/read the exact key the component uses, instead of // duplicating the literal and silently drifting from it if it's ever renamed. export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state"; @@ -445,6 +487,11 @@ function OnboardingWizardInner({ useState(false); const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false); const [showMoreAdapters, setShowMoreAdapters] = useState(false); + // The owner's stored Claude subscription login, read right before the hire + // (see handleGiveHeartbeat). Onboarding applies it with no extra control, + // so nothing else reads this state yet. + const [claudeOAuthStatus, setClaudeOAuthStatus] = + useState(null); // Created entity IDs — pre-populate from existing company when skipping step 1 const [createdCompanyId, setCreatedCompanyId] = useState( @@ -483,6 +530,12 @@ function OnboardingWizardInner({ // submissions could then both pass the fresh probe and both hire. `loading` // cannot stop the second caller for the same reason as above. const hiringAgentRef = useRef(false); + // True when the last `adapterEnvResult` came from a config that carried + // the fixed Claude login binding (see `hireAdapterConfig` in + // `handleGiveHeartbeat`). A cached result from a config that did not carry + // the binding cannot answer for a config that now does — see the reuse + // check in `handleGiveHeartbeat`. + const adapterEnvResultAppliedStoredLoginRef = useRef(false); createdCompanyIdRef.current = createdCompanyId; // The mission of the company actually in hand, which is not always the one @@ -705,6 +758,112 @@ function OnboardingWizardInner({ }); const getCapabilities = useAdapterCapabilities(); const adapterCaps = getCapabilities(adapterType); + + // Resolve the login environment at render time, so the wizard can decide + // whether to show the login panel before any adapter test runs. This + // mirrors the agent configuration form's own resolution, including the + // managed-sandbox-only redirect (see AgentConfigForm.tsx:618-640). A render + // must not throw, so a resolver error yields no login environment rather + // than an error boundary. + const { data: loginEnvironmentList = [] } = useQuery({ + queryKey: createdCompanyId + ? queryKeys.environments.list(createdCompanyId) + : ["environments", "none"], + queryFn: () => environmentsApi.list(createdCompanyId!), + enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4, + }); + const { data: instanceSettingsForLogin } = useQuery({ + queryKey: queryKeys.instance.settings, + queryFn: () => instanceSettingsApi.get(), + enabled: effectiveOnboardingOpen && step === 4, + }); + const { data: experimentalSettingsForLogin } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + enabled: effectiveOnboardingOpen && step === 4, + }); + const resolvedLoginEnvironmentId = useMemo(() => { + try { + return resolveAdapterTestEnvironmentId({ + agentDefaultEnvironmentId: null, + instanceDefaultEnvironmentId: instanceSettingsForLogin?.defaultEnvironmentId ?? null, + localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(loginEnvironmentList), + managedSandboxOnly: experimentalSettingsForLogin?.enableManagedSandboxOnly === true, + managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(loginEnvironmentList), + visibleEnvironmentIds: loginEnvironmentList.map((environment) => environment.id), + }); + } catch { + return null; + } + }, [ + instanceSettingsForLogin?.defaultEnvironmentId, + loginEnvironmentList, + experimentalSettingsForLogin?.enableManagedSandboxOnly, + ]); + const resolvedLoginEnvironment = useMemo( + () => + loginEnvironmentList.find((environment) => environment.id === resolvedLoginEnvironmentId) ?? + null, + [loginEnvironmentList, resolvedLoginEnvironmentId], + ); + // Sandbox provider capabilities for the login pseudo-terminal gate, loaded + // only when the adapter declares a login capability — the same query the + // agent configuration form runs (AgentConfigForm.tsx:652-658). + const { data: loginEnvironmentCapabilities } = useQuery({ + queryKey: createdCompanyId + ? queryKeys.environments.capabilities(createdCompanyId) + : ["environment-capabilities", "none"], + queryFn: () => environmentsApi.capabilities(createdCompanyId!), + enabled: + Boolean(createdCompanyId) && + adapterCaps.login != null && + effectiveOnboardingOpen && + step === 4, + }); + const loginEnvironmentProvider = + typeof resolvedLoginEnvironment?.config?.provider === "string" + ? resolvedLoginEnvironment.config.provider + : null; + const loginProviderSupportsPty = + loginEnvironmentProvider != null && + loginEnvironmentCapabilities?.sandboxProviders?.[loginEnvironmentProvider]?.supportsLoginPty === + true; + // The same capability gate the agent configuration form uses to show its + // login panel (AgentConfigForm.tsx:1064), minus the form's fourth input — a + // full adapter test result. The cheap auth signal below stands in for that + // input here, so this gate alone only decides whether the login mechanism + // could ever apply to the current adapter and environment. + const canShowAdapterLogin = Boolean( + adapterCaps.login != null && + resolvedLoginEnvironment?.driver === "sandbox" && + resolvedLoginEnvironmentId && + createdCompanyId && + loginProviderSupportsPty, + ); + // The cheap signal, re-read whenever the adapter type or the resolved login + // environment changes (both are part of the query key). It reports whether + // the host already holds a usable credential, with no adapter environment + // test. The route reads only host-local state, so a login baked into a + // sandbox image rather than held on the host reads as `absent` even though + // the owner could already sign in — the panel then shows for one extra step + // it did not strictly need, never the reverse. + const authSignalQuery = useQuery({ + queryKey: createdCompanyId + ? queryKeys.agents.authSignal(createdCompanyId, adapterType, resolvedLoginEnvironmentId) + : ["agents", "none", "auth-signal", adapterType, resolvedLoginEnvironmentId], + queryFn: () => + agentsApi.getAdapterAuthSignal( + createdCompanyId!, + adapterType, + resolvedLoginEnvironmentId ?? undefined, + ), + enabled: + Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && canShowAdapterLogin, + }); + const authSignalStatus = authSignalQuery.data?.status ?? null; + const showAdapterLoginPanel = + canShowAdapterLogin && (authSignalStatus === "absent" || authSignalStatus === "unknown"); + const isLocalAdapterCaps = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || @@ -786,6 +945,7 @@ function OnboardingWizardInner({ useEffect(() => { if (step !== 4) return; setAdapterEnvResult(null); + adapterEnvResultAppliedStoredLoginRef.current = false; setAdapterEnvError(null); }, [step, adapterType, model, command, args, url]); @@ -864,10 +1024,12 @@ function OnboardingWizardInner({ setArgs(""); setUrl(""); setAdapterEnvResult(null); + adapterEnvResultAppliedStoredLoginRef.current = false; setAdapterEnvError(null); setAdapterEnvLoading(false); setForceUnsetAnthropicApiKey(false); setUnsetAnthropicLoading(false); + setClaudeOAuthStatus(null); setCreatedCompanyId(null); setCreatedCompanyPrefix(null); setCreatedAgentId(null); @@ -1021,7 +1183,8 @@ function OnboardingWizardInner({ } async function runAdapterEnvironmentTest( - adapterConfigOverride?: Record + adapterConfigOverride?: Record, + appliedStoredClaudeLoginBinding = false ): Promise { if (!createdCompanyId) { setAdapterEnvError( @@ -1090,6 +1253,7 @@ function OnboardingWizardInner({ } ); setAdapterEnvResult(result); + adapterEnvResultAppliedStoredLoginRef.current = appliedStoredClaudeLoginBinding; return result; } catch (err) { setAdapterEnvError( @@ -1315,20 +1479,73 @@ function OnboardingWizardInner({ } } + // Onboarding applies a stored Claude subscription login automatically, + // with no extra control. A new user who signs in, leaves, and returns + // should not sign in a second time — that is the board's direction. + // The binding is a reference to the owner's stored value, never the + // value itself (see buildFixedClaudeOAuthBinding). The server rejects + // that binding together with a configured ANTHROPIC_API_KEY, so this + // checks the built configuration first and asks the status route only + // when there is no such conflict. + // + // Read the stored-login status before the environment test below, and + // fold it into one adapter configuration. The test must probe the same + // configuration the hire sends — a config without the binding can + // report missing authentication for a user the binding would have + // covered. + const baseAdapterConfig = buildAdapterConfig(); + let storedClaudeLogin: ClaudeOAuthTokenStatusResponse | null = null; + if ( + adapterType === "claude_local" && + !adapterConfigHasAnthropicApiKey(baseAdapterConfig) + ) { + try { + storedClaudeLogin = await agentsApi.getClaudeOAuthTokenStatus(createdCompanyId); + } catch (err) { + // A fixed 404 means the owner has no stored value. It is not a + // failure. + if (!(err instanceof ApiError) || err.status !== 404) throw err; + storedClaudeLogin = null; + } + if (stillTheSameCompany(createdCompanyId)) setClaudeOAuthStatus(storedClaudeLogin); + } + const shouldApplyStoredClaudeLogin = storedClaudeLogin !== null; + const hireAdapterConfig = shouldApplyStoredClaudeLogin + ? { + ...baseAdapterConfig, + env: { + ...(isEnvRecord(baseAdapterConfig.env) ? baseAdapterConfig.env : {}), + ...buildFixedClaudeOAuthBinding(), + }, + } + : baseAdapterConfig; + if (isLocalAdapter) { - // A cached pass or warn is still good; a cached fail is retried. With - // the "Test now" card gone, this button is the only way to re-probe, - // and reusing a stale fail would lock a customer out of a machine - // they have since fixed. + // A cached result is reusable only when it tested the same + // configuration the hire below sends, and only when it does not + // block the hire — see blocksAgentCreate. With the "Test now" card + // gone, this button is the only way to re-probe. Reusing a stale + // blocking result, or a result from a config that did not carry the + // stored login, would lock out a customer who has since fixed the + // problem or signed in. const cachedUsable = - adapterEnvResult && adapterEnvResult.status !== "fail" ? adapterEnvResult : null; - const result = cachedUsable ?? (await runAdapterEnvironmentTest()); + adapterEnvResult && + adapterEnvResultAppliedStoredLoginRef.current === shouldApplyStoredClaudeLogin && + !blocksAgentCreate(adapterEnvResult) + ? adapterEnvResult + : null; + const result = + cachedUsable ?? + (await runAdapterEnvironmentTest(hireAdapterConfig, shouldApplyStoredClaudeLogin)); if (!result) return; - // Block the hire on a failed environment test. A pass or a warn may - // proceed; a fail means the agent cannot run as configured. - if (result.status === "fail") { + // Block the hire on a failed environment test. Also block it on a + // pass or a warn result that reports missing authentication — the + // agent cannot run without one of those. + if (blocksAgentCreate(result)) { setError( - "The environment test failed. Fix the reported checks before you hire this agent.", + result.status === "fail" + ? "The environment test failed. Fix the reported checks before you hire this agent." + : "No working authentication was found. Fix the reported checks before you hire this agent.", ); return; } @@ -1338,13 +1555,15 @@ function OnboardingWizardInner({ // type narrowing rather than a gate — but it stays, because a future // path that clears the role must not reach a hire that silently no-ops. if (!agentRole) return; + const hire = await agentsApi.hire(createdCompanyId, { // The name is optional; an agent that reaches here without one is // named for the job it was hired to do rather than left blank. name: agentName.trim() || AGENT_ROLE_LABELS[agentRole], role: agentRole, adapterType, - adapterConfig: buildAdapterConfig(), + adapterConfig: hireAdapterConfig, + ...(shouldApplyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}), runtimeConfig: buildNewAgentRuntimeConfig() }); if (hire.approval) { @@ -2122,6 +2341,30 @@ function OnboardingWizardInner({ )} + {/* Shows as soon as the cheap auth signal reports no ready + credential, well before any adapter environment test + runs. Reuses the same panel the agent configuration form + shows after a test — see AdapterLoginPanel in + AgentConfigForm.tsx. No "Use saved login" control: the + hire step already applies a stored login on its own. */} + {showAdapterLoginPanel && createdCompanyId && resolvedLoginEnvironmentId && ( + { + queryClient.invalidateQueries({ + queryKey: queryKeys.agents.authSignal( + createdCompanyId, + adapterType, + resolvedLoginEnvironmentId, + ), + }); + }} + /> + )} + {/* Conditional adapter fields */} {/* No model picker. Every adapter this step offers resolves its own default (see buildAdapterConfig), so the picker diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 4087c795d9..ee91c9fcec 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -153,6 +153,8 @@ export const queryKeys = { ["agents", companyId, "adapter-model-profiles", adapterType] as const, detectModel: (companyId: string, adapterType: string) => ["agents", companyId, "detect-model", adapterType] as const, + authSignal: (companyId: string, adapterType: string, environmentId?: string | null) => + ["agents", companyId, "auth-signal", adapterType, environmentId ?? null] as const, }, builtInAgents: { list: (companyId: string) => ["built-in-agents", companyId] as const,