feat(onboarding): sign in to an agent provider during onboarding (#12440)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - New organizations create their first agent through the onboarding
wizard
> - The wizard does not show provider sign-in when a host credential is
absent or unknown
> - The create step also gives unclear feedback when the provider needs
authentication
> - This pull request adds a safe auth signal and a provider sign-in
step for sandbox drivers
> - The benefit is a clearer onboarding path with no token or account
data in the signal

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting (server API, shared types, and UI)

**Problem or motivation**

The onboarding wizard can fail when the selected provider needs
authentication. It does not tell the person how to complete sign-in.

**Proposed solution**

Add a status-only provider auth signal. Show the sign-in panel for
sandbox drivers when the signal says `absent` or `unknown`. Apply a
stored Claude login to the new agent and block creation when the adapter
test reports missing authentication.

**Alternatives considered**

The wizard could hide the sign-in panel when the signal read fails. This
would hide a needed action, so this pull request shows the panel when
the signal is unknown.

**Roadmap alignment**

The change supports the roadmap goal for scoped and audited credential
bindings.

**Additional context**

The auth signal returns only `present`, `absent`, or `unknown`. It never
returns a token, identifier, or account name.

## What Changed

- Add `GET /api/companies/:companyId/adapters/:type/auth-signal` with
company and permission checks.
- Add shared auth-signal types and the UI query path.
- Apply a stored Claude login by reference without reading its token.
- Show the provider sign-in panel only for sandbox drivers with
interactive terminal support.
- Block agent creation when the provider test reports missing
authentication.
- Add route, wizard, and end-to-end test coverage.

## Verification

- `pnpm --filter @paperclipai/server test adapter-auth-signal-routes`
passes 50 tests.
- `pnpm --filter @paperclipai/ui test OnboardingWizard` passes 69 tests.
- `pnpm --filter @paperclipai/ui exec tsc --noEmit` exits with code 0.
- The `e2e_shards` lane runs `tests/e2e/onboarding.spec.ts`.

## Risks

The route reads a host-local readiness signal. It returns `unknown` on
read errors and never exposes credential data. The UI may add a sign-in
step when the signal is unavailable.

## Model Used

OpenAI Codex, GPT-5, extended reasoning, tool use, and code execution.
The exact context window was not provided.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-28 10:20:37 -07:00 committed by GitHub
parent 66e1c0df8b
commit d9449e636e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1554 additions and 23 deletions

View File

@ -837,6 +837,8 @@ export type {
AdapterEnvironmentTestStatus,
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
AdapterAuthSignal,
AdapterAuthSignalResponse,
AdapterAuthSessionStatus,
AdapterAuthSessionInternalStatus,
AdapterAuthSessionFailure,

View File

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

View File

@ -287,6 +287,8 @@ export type {
AdapterEnvironmentTestStatus,
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
AdapterAuthSignal,
AdapterAuthSignalResponse,
AdapterAuthSessionStatus,
AdapterAuthSessionInternalStatus,
AdapterAuthSessionFailure,

View File

@ -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<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
collectMissingRuntimeBindings: vi.fn(async () => [] as Array<Record<string, unknown>>),
resolveEnvBindings: vi.fn(async () => ({
env: {} as Record<string, string>,
secretKeys: new Set<string>(),
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<typeof import("@paperclipai/adapter-codex-local/server")>();
return {
...actual,
evaluateCodexCredentialReadiness: mockEvaluateCodexCredentialReadiness,
};
});
let currentActor: Record<string, unknown>;
function boardActor(userId: string, companyIds: string[] = [COMPANY_1, OTHER_COMPANY]): Record<string, unknown> {
return {
type: "board",
userId,
companyIds,
source: "local_implicit",
isInstanceAdmin: false,
};
}
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../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<string>(),
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();
});
});

View File

@ -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<AdapterAuthSignal> {
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<AdapterAuthSignal> {
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

View File

@ -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",

View File

@ -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);
});
});

View File

@ -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<AdapterAuthSignalResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/auth-signal${query}`,
);
},
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
wakeup: (

View File

@ -78,6 +78,9 @@ vi.mock("../adapters/adapter-display-registry", () => ({
description: "",
icon: () => null,
}),
getAdapterLabel: (type: string) => type,
getAdapterLabels: () => ({}) as Record<string, string>,
isKnownAdapterType: () => true,
}));
vi.mock("../adapters/use-disabled-adapters", () => ({
useDisabledAdaptersSync: () => mockAdapterRegistry.disabled,

View File

@ -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({

View File

@ -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<import("@paperclipai/shared").AdapterEnvironmentTestResult> => ({
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<import("@paperclipai/shared").AdapterAuthSignalResponse> => ({
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<string, unknown>),
}));
// 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<Record<string, unknown>>),
capabilities: vi.fn(
async (): Promise<import("@paperclipai/shared").EnvironmentCapabilities> =>
(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<string, string>,
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<string>();
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(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
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<string, unknown> };
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<string, unknown> };
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<string, unknown> };
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<string, unknown> } };
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<string, unknown> } };
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<string, unknown> } };
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<string, unknown> };
};
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<string, unknown> = {}) {
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(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
// 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());
});
});
});

View File

@ -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<string, unknown> {
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<string, unknown>): 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<ClaudeOAuthTokenStatusResponse | null>(null);
// Created entity IDs — pre-populate from existing company when skipping step 1
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
@ -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<string, unknown>
adapterConfigOverride?: Record<string, unknown>,
appliedStoredClaudeLoginBinding = false
): Promise<AdapterEnvironmentTestResult | null> {
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({
)}
</div>
{/* 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 && (
<AdapterLoginPanel
key={`${adapterType}:${resolvedLoginEnvironmentId}`}
companyId={createdCompanyId}
adapterType={adapterType}
environmentId={resolvedLoginEnvironmentId}
onStored={() => {
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

View File

@ -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,