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:
parent
66e1c0df8b
commit
d9449e636e
|
|
@ -837,6 +837,8 @@ export type {
|
|||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterAuthSignal,
|
||||
AdapterAuthSignalResponse,
|
||||
AdapterAuthSessionStatus,
|
||||
AdapterAuthSessionInternalStatus,
|
||||
AdapterAuthSessionFailure,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,6 +287,8 @@ export type {
|
|||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterAuthSignal,
|
||||
AdapterAuthSignalResponse,
|
||||
AdapterAuthSessionStatus,
|
||||
AdapterAuthSessionInternalStatus,
|
||||
AdapterAuthSessionFailure,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue