mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
## Thinking Path > - Paperclip helps people manage AI agents at work. > - Cursor Cloud hosts execution behind its API. > - Product setup still expected a Paperclip Computer. > - Managed-only tenants therefore rejected valid setup. > - This admits the validated API-owned controller. > - Authority, Stop proof, budgets and cleanup remain enforced. ## Linked Issues or Issue Description Refs #18068 and #18069; both dependencies have merged. **What happened?** Managed-only setup still requested a Computer. Live tests exposed missing Cursor Stop proof and duplicate replay. **Expected behavior** Use Cursor’s API controller. Verify provider/controller termination and replay each log identity once. **Steps to reproduce** Enable native Runner/managed-only. Configure Cursor model/repository/key without a Computer, then Test, finish setup, run and Stop. ## What Changed - Derive API-only placement from validated native Cursor configuration; reject placement/command/workspace/home overrides and keep Kubernetes-only execution closed. - Skip Computers/checkout/workspace realization. Keep scoped tools, completion, private staging, budgets, leases and recovery. Persist placement; reject contradictions. - Align Cloud cards, Test, Save and Finish setup; hold unready drafts. Legacy keeps its Computer, credential and policy checks. - Verify Stop from exact API ownership, audited intent, provider-terminal proof, no Computer/process ownership and controller retirement. Recheck immediate/deferred retirement; wake existing recovery on durable evidence. - Clear warnings when the actual stopped target later has authoritative confirmation. Keep missing/other-run negatives and the existing predicate. - Publish safe status refresh after full-proof ACK. Retain bounded native/physical cancellation history; add no poller or weaker proof path. ## Verification Core merged as `3910352c923a`; Product merged as `27d35314be8e`. Public branch `5f86eb66b081` has tree `67344151d303`. Its exact-head Greptile summary is 5/5; the fresh audit found zero unresolved threads and complete pagination. The controlled workspace retry and required summary passed. All 59 current-head contexts are terminal and clean. Prior green-36 evidence keeps its identity. [Current CI](https://github.com/paperclipai/paperclip/actions/runs/41680511731) first failed workspaces-a 1/2: the CLI company-import/export beforeAll 30-second runtime-info/child-PID/HTTP readiness wait expired. All 431 UI files/4,714 tests and 285 other CLI tests passed (one skip). The cause remains UNKNOWN. Child output records startup recovery complete, but buffered CI prefixes do not establish its relation to the waiter deadline; runtime-file/PID/last-health state was not captured. Both unchanged local probes were RED. The second observed dependency preflight still pending at the deadline; observer overhead was not quantified. Neither proves the CI cause. After all functional jobs finished, only the queued metadata summary was cancelled; the other 48 records stayed unchanged. Attempt 1 retains 46 successes, the original failure, one skip and the cancelled summary. One same-head debug retry was admitted at 18:42 UTC on November 3. Its workspace job passed at 19:23 UTC on the same checkout: UI 431 files/4,714 tests and CLI 33 files/286 tests, including company import/export. Test caching was disabled. The new `ci / verify` summary passed; workflow attempt 2 completed SUCCESS. Required `ci / e2e` also passed. The final check at 19:53 UTC on November 3 found all 59 current-head contexts terminal and clean: zero pending and zero failing. The 47 inherited completed/skipped rows are not new executions. No code, assertion or timeout changed; success does not explain the original failure or erase the local REDs. CI checkout is `1ea9d8608ced`/tree `78cb1ec13d59` (parents `2e37c73c05fe`, 5f86); both workflows use 2e37. Its 20 extra paths add low-memory build/Rust task-memory. Scripts cohere; later matrix changes only labels, with no newer debug-cache steps. Static audit found no blocker. Supported API arguments omit the memory flag: no default monitor/instruction changes. Flagged external launchers can change Cursor instructions/digests and remain unqualified. The merge added 41 master-identical paths, retained the green-36 inbox hunk and found UI fixtures matching upstream. Startup/backup, recovery projection, sleep diagnostics and binary backup change production. Static audit found eight Stop/ACK owner blobs and 37 sleep-work SQL templates unchanged, with sleep fences/single-flight accounting intact. Exact precommit tree: 416/416 across eight server files, binary backup 1/1, server types PASS; repeated scopes are not summed. The deferred-retirement P1 reproduced three real-DB/owned-process failures; missing proof stayed unverified. Recovery passed 47 process cases/33 proof negatives. Warning repair binds actual targets to same-run proof. Validation passed 729 scoped UI cases, a separate 359-case thread run, 111 backend cases (84 CAS, 6 assembled, 21 activity/privacy), types/tokens. Proof validators/predicate are unchanged; no new poller or increased timeout. The d8d3 attempt stays FAILED: 45 passes, two workspaces-a failures, derived verify failure and one skip. Both shards shared three failures: two successful mocks omitted the account guard’s required userId. The two-line fixture repair changed no guard, assertion or timeout. Three failures reproduced; 54 access/auth tests then passed, including account-change/denial negatives, plus UI types/tokens. The 69285 attempt stays FAILED: 49 jobs, 46 passed, serialized shard 9 failed, its verify aggregate failed and one selection job skipped. The shard passed 575/576 cases. Its aborted-snapshot case expected nine computes but saw eight: a final HTTP 200 coalesced while seven ordinary reads had not proved the aborted compute settled. Earlier CI and the unmodified local baseline passed with a miss; no local RED is claimed. The repair awaits the snapshot, allows exactly one extra GET if coalesced, then requires a miss and retains nine computes/all HTTP429 assertions. No production code, hook, loop, sleep or timeout changed. Six inbox and seven cache/admission cases passed locally, plus server types; 30 unrelated cases were filtered. Green 36 passed 576/576 without taking the extra-GET branch; local runs also missed that schedule. Current CI event merge 1ea9 now passed 576/576 and naturally exercised the former failure: coalesced (count 2, 1ms), then exactly one fresh miss (count 1, 16ms), with strict nine-compute/HTTP429 assertions intact. No instrumentation or retry was added. Earlier compatibility passed scoped remote/inbox, routine, API-placement and UI tests, plus types/migrations/snapshots/maps/tokens. The 89-path adoption restored fleet routing without workflow overrides or added skips. Each result retains its source identity. Live qualification remains actual `9af1a5de42a2`: unchanged Artifact grader PASSED 17/17 and active-Stop grader PASSED 21/21. All three provider test resources were deleted canonically with GET404; post-delete download returned the exact 27 bytes. Histories stayed stable (Artifact 121 and Stop 67 canonical identities), with 21 company runs, zero active work and no accounting/cancellation/reservation debt or integrity findings. Saved settings reopened; no-prompt Test/Linux admission passed with `claude-sonnet-4-5`. No new Save is claimed. The first Stop sampling case stays FAILED on a 10.373-second gap. Unchanged retry kept all 24 samples: 35.076 seconds quiet, maximum gap 4.584 seconds, plus a separate CANCELLED/artifact-absent read after 180 seconds. Initial grading rejected locator spelling before functional checks; metadata changed only after same-file/inode/byte proof, with raw evidence/oracles unchanged. Original Question/Plan/reconnect and historical deleted-artifact results keep their identities. Git trees verified 11 history-only conflicts and six earlier mainline paths. Hosted 9af to the public BRANCH differs in 222 paths; 13,500 other mode/blob leaves match. Runner runtime implementations and six observer/driver pins match 9af on that branch only; inherited `drivers/acpx/runtime-host.test.ts` differs. No all-Runner/src, CI/latest-master runtime or image equality is claimed. Later static audits through `8ab89f41e499` found no concrete API-only blocker. That base adds completed-history sleep proofs and an MCP cleanup migration; protected Stop/ACK owners are unchanged. These later changes are outside this CI and have no live qualification here. Host duplicate-card and later recovery/UI repairs retain separate proof, not deployed 9af identity. Direct-key coverage requires absent aiConnection; API-only placement skips inherited remote-workspace disk-full paths, and config-read gates match their parent. Other retained failures: stale fixture 132/133; E8 48/135 with 87 reset-hook failures; Mac 17 SQL failures/skips; title/startup/custody/Stop/deletion unknown causes and bounded recoveries; superseded warning/50 duplicate identities; pre-repair ACK/Core CI failures. Fixture RED/GREEN passed 7/7 browser and 121/121 shutdown tests with a failing broken-unsubscribe control; stable WSP wrapper passed 128 after physical teardown failure. No full local-suite pass is inferred. All paid attempts remain recorded; vendor dollar cost is UNKNOWN. ## Risks Experimental: Cursor independently acknowledges neither the effective model nor instruction digest before the first prompt. Explicit values are submitted and behavior checked live; unpriced provider dollar cost stays UNKNOWN. The placement exception applies only to validated native Cursor API execution. Other local work remains denied under managed-only policy. Missing/stale Stop or cleanup proof stays pending; damaged archives remain failed. Explicit Legacy keeps its existing policy owners. ## Model Used OpenAI Codex/GPT-6 family: tools, execution, browser checks, delegated review. API ID/context window unavailable. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used and the limits of available model metadata - [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 linked public issues or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch describes the change and contains no internal ticket ID - [x] I have run the scoped local tests reported above; retained failures are stated separately - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented the risks - [x] All required CI gates pass on the current head - [x] Fresh Greptile is 5/5 with no unresolved findings on the current head - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1575 lines
81 KiB
TypeScript
1575 lines
81 KiB
TypeScript
import { ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS } from "@/lib/native-model-catalog";
|
||
import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils";
|
||
import { ExternalAgentInviteDialog } from "./ExternalAgentInviteDialog";
|
||
import { useConnectionModels } from "../ai-connections/useConnectionModels";
|
||
import { AgentCharacter } from "../AgentCharacter";
|
||
import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft";
|
||
import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField";
|
||
import type { AiConnectionBinding } from "@paperclipai/shared";
|
||
import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared";
|
||
import { AgentRunnerSelect } from "../AgentRunnerSelect";
|
||
import { SelectPopover } from "../ui/select";
|
||
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
|
||
import {
|
||
SETUP_CREDENTIAL_KEYS,
|
||
SETUP_LOGIN_HINTS,
|
||
setupEfforts,
|
||
setupProviderKeys,
|
||
} from "@/lib/agent-setup-fields";
|
||
import { testAgentSetup } from "@/lib/test-agent-setup";
|
||
import { needsClaudeSetupQualification, qualifyClaudeSetup } from "@/lib/qualify-claude-setup";
|
||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||
import { isNewAgentAdapterAllowed } from "@/lib/new-agent-adapters";
|
||
import { pendingAgentSetupChoice } from "@/lib/agent-setup-choice";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { hashKey, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { AnimatePresence, motion, MotionConfig } from "motion/react";
|
||
import { ArrowLeft, ArrowRight, Check, Settings2 } from "lucide-react";
|
||
import type {
|
||
AdapterEnvironmentTestResult,
|
||
Agent,
|
||
EnvBinding,
|
||
} from "@paperclipai/shared";
|
||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, isAgentStatusInvokable, paperclipRunnerProfileForHarness } from "@paperclipai/shared";
|
||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||
import { agentsApi } from "@/api/agents";
|
||
import { ApiError } from "@/api/client";
|
||
import { adaptersApi } from "@/api/adapters";
|
||
import { environmentsApi } from "@/api/environments";
|
||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||
import { secretsApi } from "@/api/secrets";
|
||
import { useCompany } from "@/context/CompanyContext";
|
||
import { useDialogActions } from "@/context/DialogContext";
|
||
import { queryKeys } from "@/lib/queryKeys";
|
||
import { cn, agentUrl } from "@/lib/utils";
|
||
import { getUIAdapter } from "@/adapters";
|
||
import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
|
||
import {
|
||
resolveAdapterTestEnvironmentId,
|
||
resolveLocalDefaultEnvironmentId,
|
||
resolveManagedSandboxEnvironmentId,
|
||
} from "@/lib/adapter-test-environment";
|
||
import { resolveForcedKubernetesEnvironment } from "@/lib/forced-kubernetes-environment";
|
||
import { environmentDisplayLabel } from "@/lib/managed-sandbox-environment";
|
||
import { buildNewAgentRuntimeConfig } from "@/lib/new-agent-runtime-config";
|
||
import {
|
||
PROVIDER_ENV_KEYS,
|
||
storeProviderApiKey,
|
||
storeOrganizationApiKey,
|
||
} from "@/lib/provider-credential";
|
||
import { defaultCreateValues } from "../agent-config-defaults";
|
||
import { ModelDropdown } from "../AgentConfigForm";
|
||
import { Field, ToggleField } from "../agent-config-primitives";
|
||
import { SecretPicker } from "../environment-variables-editor/SecretPicker";
|
||
import { Button } from "../ui/button";
|
||
import { Input } from "../ui/input";
|
||
import { NativeSelect, nativeSelectClassName } from "../ui/select";
|
||
import {
|
||
OnboardingCard,
|
||
OnboardingHeading,
|
||
} from "../onboarding/OnboardingPrimitives";
|
||
import { stepMotion } from "../onboarding/onboarding-motion";
|
||
import { RuntimeTestCard, type TestState } from "../RuntimeTestCard";
|
||
import { AgentBasicsDialog, AdapterMark } from "./AgentBasicsDialog";
|
||
import {
|
||
AgentProviderConnection,
|
||
type ProviderConnection,
|
||
} from "./AgentProviderConnection";
|
||
import { adapterCuratesModelOrder } from "../../lib/model-utils";
|
||
|
||
const controlClass = nativeSelectClassName;
|
||
const nativeSetupHarnesses: Record<string, string> = {
|
||
codex: "codex_local", claude: "claude_local", opencode: "opencode_local", pi: "pi_local", cursor_cloud: "cursor_cloud",
|
||
};
|
||
const blocking = (result: AdapterEnvironmentTestResult) =>
|
||
result.status === "fail" ||
|
||
result.checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE);
|
||
|
||
export function NewAgentSetup() {
|
||
const { selectedCompanyId } = useCompany();
|
||
const [params] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
if (!selectedCompanyId)
|
||
return (
|
||
<p className="text-sm text-muted-foreground">
|
||
Select an organization to create an agent.
|
||
</p>
|
||
);
|
||
if (params.get("adapterType") === "muse" || params.get("runnerProvider") === "muse") return <ExternalAgentInviteDialog companyId={selectedCompanyId} initialPreset="muse" initialMuseName={params.get("name") ?? ""} onClose={() => navigate("/agents/all")} onBack={() => navigate("/agents/all")} />;
|
||
return (
|
||
<Setup
|
||
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider") ?? "codex"}:${params.get("runner") ?? "auto"}`}
|
||
companyId={selectedCompanyId}
|
||
name={params.get("name") ?? ""}
|
||
adapterType={params.get("adapterType") === "paperclip_runner" && Object.hasOwn(nativeSetupHarnesses, params.get("runnerProvider") ?? "codex") ? nativeSetupHarnesses[params.get("runnerProvider") ?? "codex"] : params.get("adapterType") ?? ""}
|
||
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && Object.hasOwn(nativeSetupHarnesses, params.get("runnerProvider") ?? "codex") ? "paperclip" : params.has("setupAgentId") && ["codex_local", "claude_local", "opencode_local", "pi_local", "cursor_cloud"].includes(params.get("adapterType") ?? "") ? "legacy" : "auto")}
|
||
preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && Object.hasOwn(nativeSetupHarnesses, params.get("runnerProvider") ?? "codex")}
|
||
runnerProvider={params.get("runnerProvider") ?? "codex"}
|
||
createdAgentId={params.get("createdAgentId")}
|
||
setupAgentId={params.get("setupAgentId")}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Setup({
|
||
companyId,
|
||
name,
|
||
adapterType,
|
||
runnerProvider,
|
||
initialRunner,
|
||
preserveNativeHarness,
|
||
createdAgentId,
|
||
setupAgentId,
|
||
}: {
|
||
companyId: string;
|
||
name: string;
|
||
adapterType: string;
|
||
runnerProvider: string;
|
||
initialRunner: string;
|
||
preserveNativeHarness: boolean;
|
||
createdAgentId: string | null;
|
||
setupAgentId: string | null;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
const cache = useQueryClient();
|
||
const { openNewIssue } = useDialogActions();
|
||
const appearanceDraft = useAgentAppearanceDraft(`${companyId}:new-agent`);
|
||
const [runner, setRunner] = useState<AgentRunnerChoice>(initialRunner === "legacy" || initialRunner === "paperclip" ? initialRunner : "auto");
|
||
const isRunner = adapterType === "paperclip_runner";
|
||
const isDot = isRunner && runnerProvider === "openai_dot";
|
||
const isKimi = isRunner && runnerProvider === "kimi";
|
||
const brandType = isDot ? "openai_dot" : isRunner
|
||
? isKimi ? "kimi_runtime" : runnerProvider === "antigravity"
|
||
? "antigravity_runtime"
|
||
: runnerProvider === "copilot"
|
||
? "copilot_runtime"
|
||
: runnerProvider === "grok"
|
||
? "grok_local"
|
||
: runnerProvider === "claude"
|
||
? "claude_local"
|
||
: runnerProvider === "opencode"
|
||
? "opencode_local"
|
||
: runnerProvider === "pi" ? "pi_local" : runnerProvider === "codex" ? "codex_local" : runnerProvider
|
||
: adapterType;
|
||
const connectionAdapter =
|
||
brandType === "claude_local" || brandType === "codex_local" || brandType === "grok_local" || brandType === "antigravity_runtime"
|
||
? brandType
|
||
: null;
|
||
const unreleasedAntigravity = brandType === "antigravity_runtime"
|
||
&& !PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "antigravity" && profile.qualified);
|
||
const multiProvider =
|
||
brandType === "opencode_local" || brandType === "pi_local";
|
||
const providerKeys = setupProviderKeys(brandType);
|
||
const chooseProvider = multiProvider || brandType === "hermes_local";
|
||
const hasCredentialField =
|
||
isKimi || chooseProvider || Boolean(SETUP_CREDENTIAL_KEYS[adapterType]);
|
||
const nativeCursorCloud = adapterType === "cursor_cloud" && runner === "paperclip";
|
||
const showModel = !isDot && adapterType !== "hermes_gateway" && (adapterType !== "cursor_cloud" || nativeCursorCloud);
|
||
const [allowUnmeteredProvider, setAllowUnmeteredProvider] = useState(false);
|
||
const [gatewayUrl, setGatewayUrl] = useState("");
|
||
const [kimiModel, setKimiModel] = useState("");
|
||
const [kimiBaseUrl, setKimiBaseUrl] = useState("");
|
||
const [kimiProtocol, setKimiProtocol] = useState("kimi");
|
||
const [screen, setScreen] = useState<"connect" | "runtime" | "saved">(
|
||
createdAgentId ? "saved" : connectionAdapter ? "connect" : "runtime",
|
||
);
|
||
const [model, setModel] = useState("");
|
||
const [effort, setEffort] = useState("");
|
||
const [modelOpen, setModelOpen] = useState(false);
|
||
const [environmentOverride, setEnvironmentOverride] = useState("");
|
||
const [provider, setProvider] = useState("openrouter");
|
||
const [apiKey, setApiKey] = useState("");
|
||
const [providerBinding, setProviderBinding] = useState<EnvBinding | null>(
|
||
null,
|
||
);
|
||
const [runtimeAiBinding, setRuntimeAiBinding] = useState<AiConnectionBinding | undefined>(() =>
|
||
brandType === "copilot_runtime"
|
||
? { provider: "github", method: "api_key", mode: "responsible_user" }
|
||
: brandType === "opencode_local"
|
||
? { provider: "openrouter", method: "api_key", mode: "responsible_user" }
|
||
: undefined,
|
||
);
|
||
const [connection, setConnection] = useState<ProviderConnection | null>(null);
|
||
const [choosingAdapter, setChoosingAdapter] = useState(false);
|
||
const aiBinding = runtimeAiBinding ?? connection?.aiConnection;
|
||
const connectionModels = useConnectionModels(companyId, aiBinding, brandType);
|
||
const [repository, setRepository] = useState("");
|
||
const [branch, setBranch] = useState("");
|
||
const [createdInSession, setCreated] = useState<Agent | null>(null);
|
||
const setupId = useRef(setupAgentId ?? crypto.randomUUID());
|
||
if (setupAgentId) setupId.current = setupAgentId;
|
||
const creating = useRef(false);
|
||
const restored = useRef<string | null>(null);
|
||
const [creationAttempt, setCreationAttempt] = useState(0);
|
||
const recordId = createdAgentId ?? setupAgentId;
|
||
const currentCreated = !recordId || createdInSession?.id === recordId ? createdInSession : null;
|
||
const savedAgent = useQuery({
|
||
queryKey: queryKeys.agents.detail(recordId ?? "new"),
|
||
queryFn: () => agentsApi.get(recordId!, companyId),
|
||
enabled: Boolean(recordId) && !creating.current,
|
||
refetchInterval: query => {
|
||
const agent = query.state.data;
|
||
if (!agent || agent.lifecycleError) return false;
|
||
return (agent.pauseReason === "setup" && ["preparing", "pausing"].includes(agent.lifecycleState ?? ""))
|
||
|| (screen === "saved" && !isDot && ["preparing", "verifying", "resuming", "pausing"].includes(agent.lifecycleState ?? "")) ? 1000 : false;
|
||
},
|
||
retry: false,
|
||
});
|
||
const created =
|
||
(savedAgent.data?.companyId === companyId ? savedAgent.data : null) ?? currentCreated;
|
||
const savedSetupChoice = pendingAgentSetupChoice(created);
|
||
const resumeSavedChoice = savedSetupChoice && (adapterType !== savedSetupChoice.adapterType || runnerProvider !== savedSetupChoice.runnerProvider);
|
||
useEffect(() => {
|
||
if (!resumeSavedChoice || !created || !savedSetupChoice) return;
|
||
navigate(`/agents/new?${new URLSearchParams({ name: created.name, setupAgentId: created.id, ...savedSetupChoice })}`, { replace: true });
|
||
}, [resumeSavedChoice, created?.id, created?.name, savedSetupChoice?.adapterType, savedSetupChoice?.runnerProvider, navigate]);
|
||
const completed = Boolean(created && created.pauseReason !== "setup");
|
||
const runtimeReady = Boolean(created && isAgentStatusInvokable(created.status)
|
||
&& (!created.lifecycleState || created.lifecycleState === "ready"));
|
||
const finishingSetup = !isDot && ["preparing", "verifying", "resuming", "pausing"].includes(created?.lifecycleState ?? "");
|
||
useEffect(() => {
|
||
if (brandType !== "pi_local" || created?.adapterType !== "paperclip_runner"
|
||
|| created.runtimeConfig.aiConnection) return;
|
||
const env = (created.adapterConfig.env ?? {}) as Record<string, unknown>;
|
||
if (Object.values(PROVIDER_ENV_KEYS).some(key => env[key] !== undefined)) return;
|
||
setRuntimeAiBinding(current => current ?? { provider: "openrouter", method: "api_key", mode: "responsible_user" });
|
||
}, [brandType, created?.id, created?.adapterType]);
|
||
const savedNativeMultiProvider = multiProvider && (Boolean(setupAgentId) || runner === "legacy")
|
||
&& created?.adapterType === "paperclip_runner";
|
||
const requestAdapterType = savedNativeMultiProvider || (preserveNativeHarness && runner === "paperclip")
|
||
? "paperclip_runner" : adapterType;
|
||
const [saving, setSaving] = useState(false);
|
||
const [testState, setTestState] = useState<TestState | "warn">("idle");
|
||
const [result, setResult] = useState<AdapterEnvironmentTestResult | null>(
|
||
null,
|
||
);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [environmentSaving, setEnvironmentSaving] = useState(false);
|
||
const [environmentSaveError, setEnvironmentSaveError] = useState<string | null>(null);
|
||
const generation = useRef(0);
|
||
const qualificationTransition = useRef<{ run: number; companyId: string; environmentId: string | null; runner: AgentRunner } | null>(null);
|
||
const savingRef = useRef(false);
|
||
useEffect(
|
||
() => () => {
|
||
generation.current++;
|
||
},
|
||
[],
|
||
);
|
||
const resetTest = () => {
|
||
qualificationTransition.current = null;
|
||
generation.current++;
|
||
setResult(null);
|
||
setTestState("idle");
|
||
setError(null);
|
||
};
|
||
const editConnection = () => {
|
||
setConnection(null);
|
||
setRuntimeAiBinding(undefined);
|
||
resetTest();
|
||
setScreen("connect");
|
||
};
|
||
const adapterInventory = useQuery({
|
||
queryKey: queryKeys.adapters.all,
|
||
queryFn: () => adaptersApi.list(),
|
||
});
|
||
const agents = useQuery({
|
||
queryKey: queryKeys.agents.list(companyId),
|
||
queryFn: () => agentsApi.list(companyId),
|
||
});
|
||
const envs = useQuery({
|
||
queryKey: queryKeys.environments.list(companyId),
|
||
queryFn: () => environmentsApi.list(companyId),
|
||
});
|
||
const settings = useQuery({
|
||
queryKey: queryKeys.instance.settings,
|
||
queryFn: instanceSettingsApi.get,
|
||
});
|
||
const experimental = useQuery({
|
||
queryKey: queryKeys.instance.experimentalSettings,
|
||
queryFn: instanceSettingsApi.getExperimental,
|
||
});
|
||
const general = useQuery({
|
||
queryKey: queryKeys.instance.generalSettings,
|
||
queryFn: instanceSettingsApi.getGeneral,
|
||
});
|
||
const caps = useQuery({
|
||
queryKey: queryKeys.environments.capabilities(companyId),
|
||
queryFn: () => environmentsApi.capabilities(companyId),
|
||
});
|
||
const companySecrets = useQuery({
|
||
queryKey: queryKeys.secrets.list(companyId),
|
||
queryFn: () => secretsApi.list(companyId),
|
||
enabled: hasCredentialField && (adapterType !== "cursor_cloud" || nativeCursorCloud),
|
||
});
|
||
const userSecrets = useQuery({
|
||
queryKey: queryKeys.secrets.myUserSecrets(companyId),
|
||
queryFn: () => secretsApi.listMyUserSecrets(companyId),
|
||
enabled: hasCredentialField && (adapterType !== "cursor_cloud" || nativeCursorCloud),
|
||
retry: false,
|
||
});
|
||
const forced = resolveForcedKubernetesEnvironment(
|
||
general.data?.executionMode,
|
||
envs.data ?? [],
|
||
);
|
||
const managedOnly = experimental.data?.enableManagedSandboxOnly === true;
|
||
let environmentId: string | null = null;
|
||
let environmentError: string | null = null;
|
||
try {
|
||
environmentId = nativeCursorCloud ? null : forced.forced
|
||
? (forced.kubernetesEnvironment?.id ?? null)
|
||
: resolveAdapterTestEnvironmentId({
|
||
agentDefaultEnvironmentId: environmentOverride || null,
|
||
instanceDefaultEnvironmentId:
|
||
settings.data?.defaultEnvironmentId ?? null,
|
||
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(
|
||
envs.data ?? [],
|
||
),
|
||
managedSandboxOnly: managedOnly,
|
||
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(
|
||
envs.data ?? [],
|
||
),
|
||
visibleEnvironmentIds: (envs.data ?? []).map((env) => env.id),
|
||
});
|
||
} catch (cause) {
|
||
environmentError =
|
||
cause instanceof Error
|
||
? cause.message
|
||
: "Could not resolve the environment.";
|
||
}
|
||
const accountBoundCatalog = brandType === "copilot_runtime" || brandType === "antigravity_runtime";
|
||
const accountCatalogAvailable = brandType === "copilot_runtime" || (brandType === "antigravity_runtime" && aiBinding?.method === "subscription");
|
||
const requestAccountModels = accountCatalogAvailable && showModel && !connectionModels && Boolean(aiBinding)
|
||
&& envs.isSuccess && settings.isSuccess && experimental.isSuccess && general.isSuccess && !environmentError;
|
||
const accountModelScope = JSON.stringify([companyId, environmentId, aiBinding]);
|
||
const models = useQuery({
|
||
queryKey: queryKeys.agents.adapterModels(companyId, brandType, environmentId, accountBoundCatalog ? JSON.stringify(aiBinding ?? null) : aiBinding?.provider),
|
||
queryFn: () => agentsApi.adapterModels(companyId, isKimi || accountBoundCatalog ? "paperclip_runner" : brandType, isKimi
|
||
? { provider: "acpx", acpxAgent: "kimi", environmentId } : accountBoundCatalog
|
||
? { provider: "acpx", acpxAgent: runnerProvider, aiConnection: aiBinding, environmentId }
|
||
: { provider: aiBinding?.provider, environmentId }),
|
||
...(accountBoundCatalog ? ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
|
||
enabled: Boolean(brandType) && showModel && !connectionModels && (!accountBoundCatalog || requestAccountModels),
|
||
retry: false,
|
||
});
|
||
const refreshAccountModels = models.refetch;
|
||
useEffect(() => {
|
||
if (requestAccountModels) void refreshAccountModels();
|
||
}, [requestAccountModels, accountModelScope, refreshAccountModels]);
|
||
const environment = envs.data?.find((env) => env.id === environmentId);
|
||
const setupEnvironmentKey = JSON.stringify([companyId, environmentId, environment?.config]);
|
||
const setupEnvironmentRef = useRef("");
|
||
setupEnvironmentRef.current = setupEnvironmentKey;
|
||
useEffect(() => { resetTest(); }, [setupEnvironmentKey]);
|
||
const adapters = useQuery({
|
||
queryKey: queryKeys.adapters.availability(companyId, environmentId),
|
||
queryFn: () => adaptersApi.list({ companyId, environmentId }),
|
||
});
|
||
const candidateHarnesses = adapters.data?.find(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled)?.candidateHarnesses;
|
||
const antigravityCandidate = brandType === "antigravity_runtime" ? candidateHarnesses?.find(candidate => candidate.agent === "antigravity") : undefined;
|
||
const pendingAntigravity = unreleasedAntigravity && !antigravityCandidate;
|
||
useEffect(() => { if (antigravityCandidate) setModel(current => current || antigravityCandidate.model); }, [antigravityCandidate?.model]);
|
||
const defaultRunner = adapters.data?.find(adapter => adapter.type === adapterType)?.defaultRunner;
|
||
const efforts = brandType === "pi_local" && (runner === "paperclip" || (runner === "auto" && defaultRunner === "paperclip"))
|
||
? ["off", "low", "high", "max"] : isRunner ? [] : setupEfforts(adapterType, model);
|
||
useEffect(() => {
|
||
const transition = qualificationTransition.current;
|
||
if (transition?.run === generation.current && transition.companyId === companyId
|
||
&& transition.environmentId === environmentId && transition.runner === defaultRunner) return;
|
||
resetTest();
|
||
}, [companyId, environmentId, runner === "auto" ? defaultRunner : undefined]);
|
||
const sandboxProvider =
|
||
typeof environment?.config?.provider === "string"
|
||
? environment.config.provider
|
||
: "";
|
||
const canLogin =
|
||
environment?.driver === "sandbox" &&
|
||
caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true;
|
||
const envKey =
|
||
isKimi ? "KIMI_API_KEY" : SETUP_CREDENTIAL_KEYS[adapterType] ?? providerKeys[provider] ?? "API_KEY";
|
||
const savedKey = userSecrets.data?.find(
|
||
(entry) => entry.definition.key === envKey && entry.secret,
|
||
);
|
||
const savedOrganizationKey = companySecrets.data?.find(
|
||
(entry) => entry.key === envKey && entry.status === "active",
|
||
);
|
||
const selectedBinding =
|
||
adapterType === "cursor_cloud"
|
||
? nativeCursorCloud ? providerBinding : null
|
||
: (providerBinding ??
|
||
(savedOrganizationKey
|
||
? {
|
||
type: "secret_ref" as const,
|
||
secretId: savedOrganizationKey.id,
|
||
version: "latest" as const,
|
||
}
|
||
: savedKey
|
||
? {
|
||
type: "user_secret_ref" as const,
|
||
key: envKey,
|
||
version: "latest" as const,
|
||
}
|
||
: null));
|
||
const usingKimiApi =
|
||
adapterType === "kimi_local" && Boolean(apiKey.trim() || selectedBinding);
|
||
const cloud = Boolean(useCloudInstance());
|
||
const available =
|
||
(!nativeCursorCloud || experimental.data?.enableNativeRunner === true) &&
|
||
(!cloud || adapterType !== "cursor_cloud" || nativeCursorCloud) &&
|
||
isNewAgentAdapterAllowed(adapterType, {
|
||
cloud,
|
||
nativeRunnerEnabled: experimental.data?.enableNativeRunner === true,
|
||
openAiDotEnabled: experimental.data?.enableOpenAiDot === true,
|
||
runnerProvider,
|
||
candidateHarnesses,
|
||
}) &&
|
||
adapterInventory.data?.some(
|
||
(adapter) =>
|
||
adapter.type === adapterType &&
|
||
adapter.loaded &&
|
||
!adapter.disabled &&
|
||
!getAdapterDisplay(adapterType).comingSoon,
|
||
) && (requestAdapterType !== "paperclip_runner" || adapterType === "paperclip_runner" || adapterInventory.data?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled));
|
||
const configurationAvailable = available || (pendingAntigravity && adapterInventory.data?.some(
|
||
adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled,
|
||
));
|
||
const connectionSettingsReady = Boolean(
|
||
configurationAvailable &&
|
||
(!["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) &&
|
||
!environmentError &&
|
||
!envs.isPending &&
|
||
!settings.isPending &&
|
||
!experimental.isPending &&
|
||
!general.isPending &&
|
||
!agents.isPending &&
|
||
!caps.isPending &&
|
||
!envs.error &&
|
||
!settings.error &&
|
||
!experimental.error &&
|
||
!general.error &&
|
||
!agents.error &&
|
||
!caps.error &&
|
||
(nativeCursorCloud || !(managedOnly || forced.forced) || environmentId),
|
||
);
|
||
const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType);
|
||
const preparingComputer = created?.pauseReason === "setup" && ["preparing", "pausing"].includes(created.lifecycleState ?? "");
|
||
const environmentSelectionSaved = nativeCursorCloud
|
||
|| (!environmentOverride && created?.defaultEnvironmentId == null)
|
||
|| (created?.defaultEnvironmentId ?? null) === (environmentOverride || (forced.forced || managedOnly ? environmentId : null));
|
||
const ready = (pendingAntigravity || Boolean(created) && environmentSelectionSaved) && !environmentSaving && !preparingComputer && connectionSettingsReady && (!needsCurrentSetup || (
|
||
envs.isSuccess && !envs.isFetching
|
||
&& settings.isSuccess && !settings.isFetching
|
||
&& experimental.isSuccess && !experimental.isFetching
|
||
&& general.isSuccess && !general.isFetching
|
||
&& (runner !== "auto" || (adapters.isSuccess && !adapters.isFetching))
|
||
));
|
||
const busy = testState === "running" || saving || environmentSaving;
|
||
|
||
function buildConfig(
|
||
nextConnection = connection,
|
||
binding = selectedBinding,
|
||
setupRunner?: AgentRunner,
|
||
): Record<string, unknown> {
|
||
const values = {
|
||
...defaultCreateValues,
|
||
adapterType,
|
||
runner,
|
||
resolvedRunner: runner === "auto" ? setupRunner ?? defaultRunner : runner,
|
||
model:
|
||
model || (brandType === "codex_local" ? DEFAULT_CODEX_LOCAL_MODEL : ""),
|
||
thinkingEffort: effort,
|
||
dangerouslyBypassSandbox: adapterType === "codex_local" && runner === "legacy",
|
||
envBindings: nextConnection?.env ?? {},
|
||
...(isRunner
|
||
? {
|
||
adapterSchemaValues: {
|
||
provider: ["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? "acpx" : runnerProvider,
|
||
...(isDot ? { allowUnmeteredProvider } : {}),
|
||
...(["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
|
||
},
|
||
}
|
||
: {}),
|
||
};
|
||
const profile = paperclipRunnerProfileForHarness(brandType);
|
||
const nativeSchema = profile ? {
|
||
...profile,
|
||
...(profile.provider === "acpx" ? { acpxPermissionMode: "approve-all" } : {}),
|
||
...(brandType === "pi_local" && effort ? { piThinkingLevel: effort } : {}),
|
||
} : {};
|
||
const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" && brandType !== "pi_local" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: nativeSchema } : values,
|
||
antigravityCandidate ? { candidateHarnesses: [antigravityCandidate] } : undefined);
|
||
if (preserveNativeHarness && runner === "paperclip" && profile) {
|
||
Object.assign(config, nativeSchema, profile.provider === "opencode" ? { opencodePermissionMode: "allow" } : profile.provider === "codex" ? { codexPermissionMode: "never" } : {}, { lifecycleMode: "per_turn" });
|
||
}
|
||
if (isRunner)
|
||
Object.assign(config, {
|
||
provider: ["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? "acpx" : runnerProvider,
|
||
...(["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
|
||
...(model ? { model } : {}),
|
||
});
|
||
if (multiProvider && created) {
|
||
// The simplified setup page does not edit these saved runtime policies.
|
||
const saved = created.adapterConfig;
|
||
for (const [key, value] of Object.entries(saved)) {
|
||
if (!(key in config)) config[key] = value;
|
||
}
|
||
for (const key of ["dangerouslySkipPermissions", "opencodePermissionMode", "acpxPermissionMode", "acpxSessionMode", "lifecycleMode", "idleTimeoutMs"]) {
|
||
if (saved[key] !== undefined) config[key] = saved[key];
|
||
}
|
||
if (brandType === "pi_local" && created.adapterType === "paperclip_runner" && runner !== "legacy" && effort) {
|
||
config.piThinkingLevel = effort;
|
||
delete config.thinking;
|
||
}
|
||
config.model = model;
|
||
config.env = { ...((saved.env as object) ?? {}), ...((config.env as object) ?? {}) };
|
||
}
|
||
if (!aiBinding && !nextConnection?.aiConnection && hasCredentialField && binding) {
|
||
if (adapterType === "hermes_gateway") config.apiKey = binding;
|
||
else
|
||
config.env = { ...((config.env as object) ?? {}), [envKey]: binding };
|
||
}
|
||
if (adapterType === "cursor_cloud")
|
||
Object.assign(config, {
|
||
repoUrl: repository.trim(),
|
||
...(branch.trim() ? { repoStartingRef: branch.trim() } : {}),
|
||
});
|
||
if (adapterType === "hermes_gateway") config.apiBaseUrl = gatewayUrl.trim();
|
||
if (usingKimiApi) {
|
||
// --model overrides Kimi's environment-defined model. Let KIMI_MODEL_NAME win.
|
||
delete config.model;
|
||
config.kimiApi = { model: kimiModel.trim(), protocol: kimiProtocol, baseUrl: kimiBaseUrl.trim() };
|
||
config.env = {
|
||
...((config.env as object) ?? {}),
|
||
KIMI_MODEL_NAME: { type: "plain", value: kimiModel.trim() },
|
||
KIMI_MODEL_PROVIDER_TYPE: { type: "plain", value: kimiProtocol },
|
||
...(kimiBaseUrl.trim()
|
||
? {
|
||
KIMI_MODEL_BASE_URL: { type: "plain", value: kimiBaseUrl.trim() },
|
||
}
|
||
: {}),
|
||
};
|
||
}
|
||
return config;
|
||
}
|
||
function preparedConfig(nextConnection = connection, setupRunner?: AgentRunner) {
|
||
if (nativeCursorCloud && !model.trim()) throw new Error("Enter the exact Cursor Cloud model ID.");
|
||
if (isKimi && !model.trim()) throw new Error("Enter the exact Kimi model ID.");
|
||
if (isKimi && !apiKey.trim() && !selectedBinding) throw new Error("Enter KIMI_API_KEY or select an organization secret.");
|
||
if (brandType === "antigravity_runtime" && (!model.trim() || model !== model.trim() || /\s/.test(model) || ["auto", "default"].includes(model.toLowerCase())))
|
||
throw new Error("Choose or enter an exact Google Antigravity model ID.");
|
||
if (brandType === "copilot_runtime" && !models.data?.some(candidate => candidate.id === model))
|
||
throw new Error("Refresh Copilot models and select an available model before creating this agent.");
|
||
if (isDot && !allowUnmeteredProvider) throw new Error("Acknowledge external provider billing before creating your Dot agent.");
|
||
if (multiProvider && (!model.trim() || !model.includes("/")))
|
||
throw new Error("Choose or enter a model in provider/model format.");
|
||
if (
|
||
adapterType === "cursor_cloud" &&
|
||
!/^https:\/\/github\.com\/[^/]+\/[^/]+/.test(repository.trim())
|
||
)
|
||
throw new Error("Enter a GitHub repository URL.");
|
||
if (
|
||
["cursor_cloud", "hermes_gateway"].includes(adapterType) &&
|
||
!apiKey.trim() &&
|
||
!selectedBinding
|
||
)
|
||
throw new Error(
|
||
adapterType === "cursor_cloud"
|
||
? "Enter a Cursor API key."
|
||
: `Enter ${envKey} or select an organization secret.`,
|
||
);
|
||
if (adapterType === "hermes_gateway") {
|
||
try {
|
||
const url = new URL(gatewayUrl.trim());
|
||
if (!["https:", "http:"].includes(url.protocol)) throw new Error();
|
||
} catch {
|
||
throw new Error("Enter the Hermes API base URL.");
|
||
}
|
||
}
|
||
if (usingKimiApi && !kimiModel.trim())
|
||
throw new Error("Enter the Kimi API model name.");
|
||
return buildConfig(nextConnection, selectedBinding, setupRunner);
|
||
}
|
||
function pendingCredentials(nextConnection = connection) {
|
||
if (aiBinding || nextConnection?.aiConnection) return {};
|
||
return {
|
||
...nextConnection?.credentials,
|
||
...(hasCredentialField && apiKey.trim()
|
||
? { [envKey]: apiKey.trim() }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (pendingAntigravity || !name || !adapterType || !connectionSettingsReady || createdAgentId || created || creating.current
|
||
|| (setupAgentId && !savedAgent.isError)) return;
|
||
if (savedAgent.error && (!(savedAgent.error instanceof ApiError) || savedAgent.error.status !== 404)) return;
|
||
creating.current = true;
|
||
setError(null);
|
||
navigate(`/agents/new?${new URLSearchParams({ name, adapterType, runnerProvider,
|
||
runner, setupAgentId: setupId.current })}`, { replace: true });
|
||
const existing = agents.data ?? [];
|
||
const leader = existing.find(agent => agent.role === "ceo" && agent.status !== "terminated");
|
||
void agentsApi.create(companyId, {
|
||
setupId: setupId.current, name, appearance: appearanceDraft.appearance,
|
||
role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}),
|
||
adapterType: requestAdapterType, runner,
|
||
adapterConfig: requestAdapterType === "paperclip_runner" ? {
|
||
provider: ["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? "acpx" : runnerProvider,
|
||
...(["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
|
||
...(antigravityCandidate ? { model: antigravityCandidate.model } : {}),
|
||
} : {}, defaultEnvironmentId: nativeCursorCloud ? null : environmentOverride || (forced.forced || managedOnly ? environmentId : null),
|
||
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
|
||
}).then(async agent => {
|
||
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||
setCreated(agent);
|
||
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||
void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not create the agent."))
|
||
.finally(() => { creating.current = false; });
|
||
}, [name, adapterType, companyId, connectionSettingsReady, creationAttempt, createdAgentId, setupAgentId, savedAgent.isError, created, pendingAntigravity]);
|
||
|
||
useEffect(() => {
|
||
if (!savedAgent.data || restored.current === savedAgent.data.id || savedAgent.data.companyId !== companyId) return;
|
||
restored.current = savedAgent.data.id;
|
||
const agent = savedAgent.data;
|
||
if (agent.pauseReason !== "setup") { setScreen("saved"); return; }
|
||
const config = agent.adapterConfig;
|
||
if (multiProvider || adapterType === "cursor_cloud") setRunner(agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy");
|
||
setModel(typeof config.model === "string" && config.model ? config.model : antigravityCandidate?.model ?? "");
|
||
setEffort(String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? config.thinkingEffort ?? config.piThinkingLevel ?? config.thinking ?? ""));
|
||
setEnvironmentOverride(agent.defaultEnvironmentId ?? "");
|
||
setRepository(typeof config.repoUrl === "string" ? config.repoUrl : "");
|
||
setBranch(typeof config.repoRef === "string" ? config.repoRef : typeof config.repoStartingRef === "string" ? config.repoStartingRef : "");
|
||
setGatewayUrl(typeof config.apiBaseUrl === "string" ? config.apiBaseUrl : "");
|
||
setAllowUnmeteredProvider(config.allowUnmeteredProvider === true);
|
||
const kimiApi = config.kimiApi as { model?: string; protocol?: string; baseUrl?: string } | undefined;
|
||
setKimiModel(kimiApi?.model ?? "");
|
||
setKimiProtocol(kimiApi?.protocol ?? "kimi");
|
||
setKimiBaseUrl(kimiApi?.baseUrl ?? "");
|
||
const env = typeof config.env === "object" && config.env !== null ? config.env as Record<string, unknown> : {};
|
||
const providerEntry = Object.entries(providerKeys).find(([, key]) => env[key]);
|
||
if (providerEntry) setProvider(providerEntry[0]);
|
||
const credential = config.apiKey ?? env[providerEntry?.[1] ?? envKey];
|
||
if (credential && typeof credential === "object" && "type" in credential
|
||
&& ["secret_ref", "user_secret_ref"].includes(String(credential.type))) setProviderBinding(credential as EnvBinding);
|
||
const binding = agent.runtimeConfig.aiConnection as AiConnectionBinding | undefined;
|
||
if (binding && connectionAdapter) setConnection({ env: {}, aiConnection: binding });
|
||
else if (binding) setRuntimeAiBinding(binding);
|
||
setScreen(binding || !connectionAdapter ? "runtime" : "connect");
|
||
}, [savedAgent.data, companyId, connectionAdapter, brandType]);
|
||
|
||
async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record<string, unknown>, selectedEnvironment = environmentOverride) {
|
||
if (pendingAntigravity) return;
|
||
if (!created || completed) return;
|
||
const binding = runtimeAiBinding ?? nextConnection?.aiConnection;
|
||
const config = testedConfig ?? buildConfig(nextConnection);
|
||
const staged: Array<{ remove: () => Promise<unknown> }> = [];
|
||
let submitted = false;
|
||
const rememberCredentials = () => {
|
||
if (!staged.length) return;
|
||
setApiKey("");
|
||
setProviderBinding((adapterType === "hermes_gateway" ? config.apiKey : (config.env as Record<string, EnvBinding>)?.[envKey]) as EnvBinding);
|
||
};
|
||
try {
|
||
for (const [key, value] of Object.entries(pendingCredentials(nextConnection))) {
|
||
const secret = await (connectionAdapter ? storeProviderApiKey : storeOrganizationApiKey)(companyId, key, value);
|
||
staged.push(secret);
|
||
if (adapterType === "hermes_gateway" && key === "API_SERVER_KEY") config.apiKey = secret.binding;
|
||
else config.env = { ...((config.env as object) ?? {}), [key]: secret.binding };
|
||
}
|
||
submitted = true;
|
||
const agent = await agentsApi.update(created.id, {
|
||
adapterType: requestAdapterType, runner, adapterConfig: config,
|
||
defaultEnvironmentId: nativeCursorCloud ? null : selectedEnvironment || (forced.forced || managedOnly ? environmentId : null),
|
||
runtimeConfig: { ...created.runtimeConfig, ...(binding ? { aiConnection: binding } : {}) },
|
||
...(antigravityCandidate ? { metadata: { ...created.metadata, setupAdapterPending: false, setupAdapterChoice: undefined } } : {}),
|
||
...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
|
||
}, companyId);
|
||
rememberCredentials();
|
||
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||
setCreated(agent);
|
||
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||
if (exit) navigate("/agents/all");
|
||
return agent;
|
||
} catch (cause) {
|
||
if (submitted && !(cause instanceof ApiError && cause.status >= 400 && cause.status < 500 && cause.status !== 408)) {
|
||
rememberCredentials();
|
||
} else {
|
||
try { await Promise.all(staged.map(secret => secret.remove())); }
|
||
catch { throw new Error(`${cause instanceof Error ? cause.message : "Could not save agent setup."} Could not remove an unused setup credential. Remove it from Secrets before retrying.`); }
|
||
}
|
||
throw cause;
|
||
}
|
||
}
|
||
|
||
async function saveAndExit() {
|
||
if (pendingAntigravity) { navigate("/agents/all"); return; }
|
||
if (savingRef.current) return;
|
||
savingRef.current = true;
|
||
setSaving(true);
|
||
try { await saveDraft(connection, true); }
|
||
catch (cause) { setError(cause instanceof Error ? cause.message : "Could not save agent setup."); }
|
||
finally { savingRef.current = false; setSaving(false); }
|
||
}
|
||
|
||
async function changeEnvironment(selected = environmentOverride) {
|
||
setEnvironmentOverride(selected);
|
||
resetTest();
|
||
setEnvironmentSaving(true);
|
||
setEnvironmentSaveError(null);
|
||
try { await saveDraft(connection, false, undefined, selected); }
|
||
catch (cause) { setEnvironmentSaveError(cause instanceof Error ? cause.message : "Could not save the environment."); }
|
||
finally { setEnvironmentSaving(false); }
|
||
}
|
||
|
||
async function runTest(nextConnection = connection): Promise<Record<string, unknown> | null> {
|
||
if (!ready || pendingAntigravity) return null;
|
||
const run = ++generation.current;
|
||
const setupEnvironment = setupEnvironmentRef.current;
|
||
setTestState("running");
|
||
setResult(null);
|
||
setError(null);
|
||
try {
|
||
preparedConfig(nextConnection);
|
||
const selection = { adapterType, runner, savedAgentId: completed ? createdAgentId : null, environment };
|
||
const qualified = await qualifyClaudeSetup({ ...selection,
|
||
readAdapters: () => adaptersApi.list({ companyId, environmentId }),
|
||
probeEnvironment: () => environmentsApi.probe(environmentId!, companyId),
|
||
isCurrent: () => run === generation.current && setupEnvironmentRef.current === setupEnvironment,
|
||
});
|
||
if (run !== generation.current || setupEnvironmentRef.current !== setupEnvironment
|
||
|| (needsClaudeSetupQualification(selection) && !qualified)) return null;
|
||
if (qualified) {
|
||
qualificationTransition.current = { run, companyId, environmentId, runner: qualified.runner };
|
||
cache.setQueryData(queryKeys.adapters.availability(companyId, environmentId), qualified.adapters);
|
||
}
|
||
const config = preparedConfig(nextConnection, qualified?.runner);
|
||
const tested = await testAgentSetup({
|
||
companyId,
|
||
agentId: created?.id,
|
||
adapterType: requestAdapterType,
|
||
providerAdapter: brandType,
|
||
...(["codex_local", "claude_local", "opencode_local", "pi_local", "cursor_cloud"].includes(adapterType) && (qualified || runner !== "auto") ? { runner: qualified?.runner ?? runner } : {}),
|
||
adapterConfig: config,
|
||
testCredentials: pendingCredentials(nextConnection),
|
||
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
|
||
environmentId,
|
||
});
|
||
if (run !== generation.current) return null;
|
||
setResult(tested);
|
||
setTestState(blocking(tested) ? "fail" : tested.status);
|
||
return (
|
||
!blocking(tested) &&
|
||
(tested.checks.some(check => check.code === "environment_agent_approval_required") || !connectionAdapter ||
|
||
tested.checks.some((check) =>
|
||
check.code.endsWith("hello_probe_passed") || check.code === "ai_connection_api_key_reverified"
|
||
|| (brandType === "antigravity_runtime" && check.code === "antigravity_metadata_verified"),
|
||
))
|
||
) ? config : null;
|
||
} catch (cause) {
|
||
if (run === generation.current) {
|
||
setError(
|
||
cause instanceof Error ? cause.message : "Could not test the agent.",
|
||
);
|
||
setTestState("fail");
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
async function finish() {
|
||
if (
|
||
savingRef.current ||
|
||
pendingAntigravity ||
|
||
completed ||
|
||
!created ||
|
||
!ready ||
|
||
!name.trim() ||
|
||
testState === "running" ||
|
||
testState === "fail" ||
|
||
(connectionAdapter && !connection)
|
||
)
|
||
return;
|
||
savingRef.current = true;
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
const credentials = pendingCredentials();
|
||
const config = (connectionAdapter && testState === "idle") || Object.keys(credentials).length
|
||
? await runTest()
|
||
: preparedConfig();
|
||
if (!config) return;
|
||
if (Object.keys(credentials).length) await saveDraft(connection, false, config);
|
||
const response = await agentsApi.hire(companyId, {
|
||
setupAgentId: created.id,
|
||
name: created.name,
|
||
appearance: created.appearance ?? appearanceDraft.appearance,
|
||
role: created.role,
|
||
reportsTo: created.reportsTo,
|
||
adapterType: requestAdapterType,
|
||
...(["codex_local", "claude_local", "opencode_local", "pi_local", "cursor_cloud"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||
adapterConfig: config,
|
||
defaultEnvironmentId: nativeCursorCloud ? null :
|
||
environmentOverride ||
|
||
(forced.forced || managedOnly ? environmentId : null),
|
||
runtimeConfig: { ...buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), ...(aiBinding ? { aiConnection: aiBinding } : {}) },
|
||
budgetMonthlyCents: 0,
|
||
...(connection?.storedSessionId
|
||
? { storedSessionId: connection.storedSessionId }
|
||
: {}),
|
||
...(connection?.applyStoredClaudeLogin
|
||
? { applyStoredClaudeLogin: true }
|
||
: {}),
|
||
});
|
||
setApiKey("");
|
||
setConnection(null);
|
||
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(response.agent.id) });
|
||
setCreated(response.agent);
|
||
appearanceDraft.clear();
|
||
setScreen("saved");
|
||
navigate(
|
||
`/agents/new?${new URLSearchParams({ name: response.agent.name, adapterType, runnerProvider, ...(runner !== "auto" ? { runner } : {}), createdAgentId: response.agent.id })}`,
|
||
{ replace: true },
|
||
);
|
||
cache.setQueryData(
|
||
queryKeys.agents.detail(response.agent.id),
|
||
response.agent,
|
||
);
|
||
await Promise.all([
|
||
cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }),
|
||
cache.invalidateQueries({
|
||
queryKey: queryKeys.approvals.list(companyId),
|
||
}),
|
||
]);
|
||
} catch (cause) {
|
||
setError(
|
||
cause instanceof Error ? cause.message : "Could not create the agent.",
|
||
);
|
||
} finally {
|
||
void cache.invalidateQueries({
|
||
queryKey: queryKeys.secrets.myUserSecrets(companyId),
|
||
});
|
||
void cache.invalidateQueries({
|
||
queryKey: queryKeys.secrets.list(companyId),
|
||
});
|
||
savingRef.current = false;
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function assignFirstTask() {
|
||
if (!created || !runtimeReady) return;
|
||
const current = cache.getQueryData<Agent>(queryKeys.agents.detail(created.id)) ?? created;
|
||
if (!isAgentStatusInvokable(current.status) || (current.lifecycleState && current.lifecycleState !== "ready")) return;
|
||
await cache.cancelQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||
cache.setQueryData<Agent[]>(queryKeys.agents.list(companyId), agents => agents?.map(agent =>
|
||
agent.id === current.id && (agent.lifecycleVersion ?? 0) <= (current.lifecycleVersion ?? 0) ? { ...agent, ...current } : agent));
|
||
openNewIssue({ assigneeAgentId: current.id, status: "todo" });
|
||
}
|
||
if (recordId && !created && !creating.current && savedAgent.error && !(savedAgent.error instanceof ApiError && savedAgent.error.status === 404))
|
||
return (
|
||
<p
|
||
role={savedAgent.error ? "alert" : "status"}
|
||
className="text-sm text-muted-foreground"
|
||
>
|
||
{savedAgent.error
|
||
? "Could not load the created agent. Return to Agents to view it."
|
||
: "Loading your agent…"}
|
||
</p>
|
||
);
|
||
if (resumeSavedChoice) return <p role="status" className="text-sm text-muted-foreground">Loading your agent setup…</p>;
|
||
if (choosingAdapter || !name || !adapterType || (created?.metadata?.setupAdapterPending === true && !unreleasedAntigravity))
|
||
return (
|
||
<AgentBasicsDialog
|
||
open
|
||
initialAdapter={choosingAdapter && savedSetupChoice ? "antigravity_runtime" : created?.metadata?.setupAdapterPending ? "" : adapterType}
|
||
initialName={created?.name ?? name}
|
||
setupAgentId={created?.metadata?.setupAdapterPending ? created.id : setupAgentId ?? undefined}
|
||
onClose={() => choosingAdapter ? setChoosingAdapter(false) : navigate("/agents/all")}
|
||
onContinue={(basics) => {
|
||
setChoosingAdapter(false);
|
||
navigate(`/agents/new?${new URLSearchParams(basics)}`, {
|
||
replace: true,
|
||
});
|
||
}}
|
||
/>
|
||
);
|
||
if (!created && connectionSettingsReady && !pendingAntigravity) return <div className="space-y-4">
|
||
<p role={error ? "alert" : "status"}>{error ?? "Creating your agent…"}</p>
|
||
{error && <Button onClick={() => setCreationAttempt(value => value + 1)}>Retry</Button>}
|
||
</div>;
|
||
const steps = [
|
||
...(connectionAdapter ? ["connect" as const] : []),
|
||
"runtime" as const,
|
||
"saved" as const,
|
||
];
|
||
const labels = {
|
||
connect: "Connect",
|
||
runtime: "Configure",
|
||
saved: "Confirmation",
|
||
};
|
||
const confirmationEnvironment = created?.defaultEnvironmentId
|
||
? envs.data?.find((env) => env.id === created.defaultEnvironmentId)
|
||
: environment;
|
||
const createdKimiModel = (
|
||
created?.adapterConfig?.env as Record<string, unknown> | undefined
|
||
)?.KIMI_MODEL_NAME;
|
||
const confirmationModel =
|
||
created?.adapterConfig?.model ||
|
||
(created?.adapterConfig?.kimiApi as { model?: string } | undefined)?.model ||
|
||
(typeof createdKimiModel === "string"
|
||
? createdKimiModel
|
||
: (createdKimiModel as { value?: string } | undefined)?.value) ||
|
||
model ||
|
||
"Default";
|
||
const environmentLabel =
|
||
adapterType === "cursor_cloud"
|
||
? "Cursor Cloud"
|
||
: adapterType === "hermes_gateway"
|
||
? "Hermes Gateway"
|
||
: confirmationEnvironment
|
||
? environmentDisplayLabel(confirmationEnvironment)
|
||
: "Local machine";
|
||
const setupError =
|
||
adapters.error ??
|
||
adapterInventory.error ??
|
||
envs.error ??
|
||
settings.error ??
|
||
experimental.error ??
|
||
general.error ??
|
||
agents.error ??
|
||
caps.error;
|
||
return (
|
||
<MotionConfig reducedMotion="user">
|
||
<div className="mx-auto flex max-w-5xl flex-col gap-8 py-6">
|
||
<header className="flex items-center gap-4">
|
||
<AgentCharacter appearance={created?.appearance ?? appearanceDraft.appearance} size={256} className="size-48" trackingScope="page"
|
||
state={completed || testState === "pass" ? "success" : testState === "running" ? "loading" : "sleepy"}
|
||
muted={!completed && testState !== "pass"} />
|
||
<div className="space-y-2">
|
||
<h1 className="text-2xl font-semibold tracking-tight">{name}</h1>
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<AdapterMark type={brandType} />
|
||
<span>{getAdapterDisplay(brandType).label}</span>
|
||
{isRunner && !isDot && (
|
||
<span>
|
||
·{" "}
|
||
{runnerProvider === "codex"
|
||
? "Native app server runner"
|
||
: "Paperclip Runner"}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
{environmentError && !envs.isPending && (
|
||
<p role="alert" className="text-sm text-destructive">
|
||
{environmentError}
|
||
</p>
|
||
)}
|
||
{preparingComputer && <p role="status" className="text-sm text-muted-foreground">Preparing your agent computer…</p>}
|
||
{created?.lifecycleError && <div className="space-y-2">
|
||
<p role="alert" className="text-sm text-destructive">{created.lifecycleError}</p>
|
||
<Button disabled={busy} onClick={() => {
|
||
setEnvironmentSaving(true);
|
||
void agentsApi.retryLifecycle(created.id, companyId).then(async agent => {
|
||
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||
setCreated(agent);
|
||
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not retry computer preparation."))
|
||
.finally(() => setEnvironmentSaving(false));
|
||
}}>Retry preparation</Button>
|
||
</div>}
|
||
{setupError && (
|
||
<p role="alert" className="text-sm text-destructive">
|
||
{setupError.message}
|
||
</p>
|
||
)}
|
||
{pendingAntigravity && configurationAvailable && (
|
||
<div className="space-y-2">
|
||
<p role="status" className="text-sm text-muted-foreground">
|
||
Google Antigravity is not available yet. You can connect your Google account and review settings. This agent cannot finish setup or run tasks yet. Model and connection settings are not saved to this agent yet.
|
||
</p>
|
||
{created && <Button variant="link" className="px-0" onClick={() => setChoosingAdapter(true)}>Change adapter</Button>}
|
||
</div>
|
||
)}
|
||
{antigravityCandidate && <p role="status" className="text-sm text-muted-foreground">Google Antigravity candidate is enabled for testing on this instance.</p>}
|
||
{adapterInventory.data && !configurationAvailable && (
|
||
<p role="alert" className="text-sm text-destructive">
|
||
This adapter is unavailable. Choose an enabled adapter.
|
||
</p>
|
||
)}
|
||
{!nativeCursorCloud && (managedOnly || forced.forced) &&
|
||
!envs.isPending &&
|
||
!environmentId && (
|
||
<p role="alert" className="text-sm text-destructive">
|
||
No managed environment is available. Configure an environment
|
||
before continuing.
|
||
</p>
|
||
)}
|
||
<div className="flex flex-col gap-8 md:flex-row">
|
||
<nav aria-label="Agent setup steps" className="shrink-0 md:w-44">
|
||
<ol className="flex flex-wrap gap-2 md:flex-col">
|
||
{steps.map((step, index) => (
|
||
<li key={step}>
|
||
<button
|
||
type="button"
|
||
aria-current={step === screen ? "step" : undefined}
|
||
disabled={
|
||
busy || completed || step === "saved"
|
||
}
|
||
onClick={() => step === "connect" && screen !== "connect" ? editConnection() : setScreen(step)}
|
||
className={cn(
|
||
"flex w-full items-center gap-3 rounded-md px-3 py-3 text-left text-sm",
|
||
step === screen
|
||
? "bg-accent font-medium"
|
||
: "text-muted-foreground",
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"flex size-6 shrink-0 items-center justify-center rounded-full border text-xs",
|
||
step === screen
|
||
? "border-foreground bg-foreground text-background"
|
||
: "border-border",
|
||
)}
|
||
>
|
||
{index + 1}
|
||
</span>
|
||
{labels[step]}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</nav>
|
||
<div className="min-w-0 flex-1">
|
||
<AnimatePresence mode="wait" initial={false}>
|
||
<motion.div key={screen} {...stepMotion}>
|
||
{screen === "connect" && connectionAdapter ? (
|
||
<OnboardingCard className="mx-auto">
|
||
<div className="mb-8">
|
||
<OnboardingHeading
|
||
title="Connect a model"
|
||
center
|
||
/>
|
||
</div>
|
||
{connectionSettingsReady ? <fieldset disabled={!ready} className="contents"><AgentProviderConnection
|
||
key={environmentId ?? "local"}
|
||
companyId={companyId}
|
||
adapterType={connectionAdapter}
|
||
environmentId={environmentId}
|
||
canLogin={canLogin}
|
||
localEnvironment={environment?.driver === "local"}
|
||
advancedConnection={{
|
||
value: aiBinding,
|
||
content: (
|
||
<AiConnectionField
|
||
companyId={companyId}
|
||
agentName={name}
|
||
adapterType={brandType}
|
||
model={model}
|
||
environmentId={environmentId ?? undefined}
|
||
value={aiBinding}
|
||
preferAdvanced
|
||
onChange={binding => {
|
||
if (binding.mode === "router") return;
|
||
setConnection(null);
|
||
setRuntimeAiBinding(binding);
|
||
resetTest();
|
||
}}
|
||
/>
|
||
),
|
||
}}
|
||
onBack={() => void saveAndExit()}
|
||
onDraftChanged={() => {
|
||
setConnection(null);
|
||
setRuntimeAiBinding(undefined);
|
||
resetTest();
|
||
}}
|
||
testConnection={async next => {
|
||
if (brandType !== "antigravity_runtime") return (await runTest(next)) !== null;
|
||
if (next.aiConnection?.method === "subscription") await agentsApi.adapterModels(companyId, "paperclip_runner", {
|
||
provider: "acpx", acpxAgent: "antigravity", aiConnection: next.aiConnection, environmentId,
|
||
agentId: !created || created.pauseReason === "setup" ? undefined : created.id,
|
||
});
|
||
return true;
|
||
}}
|
||
testError={
|
||
error ??
|
||
(
|
||
result?.checks.find(
|
||
(check) => check.level === "error",
|
||
) ??
|
||
result?.checks.find(
|
||
(check) =>
|
||
(check.code.includes("hello_probe") || check.code.startsWith("environment_agent_")) &&
|
||
check.level === "warn",
|
||
)
|
||
)?.message
|
||
}
|
||
onConnected={(next) => {
|
||
void saveDraft(next).then(() => {
|
||
setConnection(next);
|
||
setRuntimeAiBinding(undefined);
|
||
setScreen("runtime");
|
||
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not save agent setup."));
|
||
}}
|
||
/></fieldset> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
|
||
{["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && <details className="mt-5 space-y-3">
|
||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||
<AgentRunnerSelect value={runner} disabled={busy}
|
||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||
defaultRunner={adapters.data?.find(item => item.type === adapterType)?.defaultRunner}
|
||
supportedRunners={adapters.data?.find(item => item.type === adapterType)?.supportedRunners}
|
||
onChange={value => { setRunner(value); resetTest(); }} />
|
||
</details>}
|
||
</OnboardingCard>
|
||
) : screen === "saved" && created ? (
|
||
<div className="space-y-6">
|
||
<div className="space-y-6 rounded-lg border border-border p-6">
|
||
<h2 className="flex items-center gap-3 text-lg font-semibold">
|
||
<Check className="size-5" />
|
||
{created.status === "pending_approval"
|
||
? "Agent submitted for approval"
|
||
: isDot ? "Your Dot agent has been created"
|
||
: finishingSetup ? "Finishing agent setup" : runtimeReady ? "Your agent is ready" : "Your agent is paused"}
|
||
</h2>
|
||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||
<dt className="text-muted-foreground">Adapter</dt>
|
||
<dd>{getAdapterDisplay(isDot ? "openai_dot" : adapterType).label}</dd>
|
||
{showModel && (
|
||
<>
|
||
<dt className="text-muted-foreground">Model</dt>
|
||
<dd className="break-all">
|
||
{String(confirmationModel)}
|
||
</dd>
|
||
</>
|
||
)}
|
||
<dt className="text-muted-foreground">Environment</dt>
|
||
<dd>{environmentLabel}</dd>
|
||
</dl>
|
||
<p className="text-sm text-muted-foreground">
|
||
{created.status === "pending_approval"
|
||
? "An organization administrator must approve this agent before it can work."
|
||
: isDot ? "Pair your Dot and test event delivery in configuration before assigning work."
|
||
: finishingSetup ? "Paperclip is preparing and verifying your agent. You can assign a task when it is ready."
|
||
: runtimeReady ? "Assign a task when you’re ready for this agent to work." : "Review the agent’s status in configuration before assigning work."}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap justify-between gap-3">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => navigate(isDot ? "/agents/all" : `${agentUrl(created)}/runtime`)}
|
||
>
|
||
<Settings2 className="size-4" />
|
||
{isDot ? "Back to Agents" : "Edit configuration"}
|
||
</Button>
|
||
<Button
|
||
disabled={created.status === "pending_approval" || (!isDot && !runtimeReady)}
|
||
onClick={() => isDot
|
||
? navigate(`${agentUrl(created)}/runtime`)
|
||
: void assignFirstTask()
|
||
}
|
||
>
|
||
{isDot ? "Pair Dot" : `Assign ${created.name} a Task`}
|
||
<ArrowRight className="size-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<form
|
||
className="space-y-8"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void finish();
|
||
}}
|
||
>
|
||
<h2 className="text-xl font-semibold">
|
||
Configure your agent
|
||
</h2>
|
||
<fieldset disabled={busy} className="space-y-8">
|
||
<section className="space-y-5">
|
||
{isDot && <>
|
||
<p className="text-sm text-muted-foreground">Create this agent, then copy its pairing prompt to your Dot.</p>
|
||
{experimental.data?.enablePublicMcp !== true && <p className="text-sm text-muted-foreground">Enable Assistant connections (MCP) in Experimental settings before pairing.</p>}
|
||
<ToggleField label="Allow externally billed provider" hint="Dot does not report token usage or cost. Paperclip cannot enforce a provider spend ceiling; known company and agent budget limits still apply."
|
||
checked={allowUnmeteredProvider} onChange={value => { setAllowUnmeteredProvider(value); resetTest(); }} />
|
||
</>}
|
||
{!connectionAdapter && aiProviderForAdapter(brandType) && (
|
||
<AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
|
||
onChange={binding => { binding.mode !== "router" && setRuntimeAiBinding(binding); resetTest();
|
||
if (accountCatalogAvailable && hashKey([aiBinding ?? null]) === hashKey([binding ?? null])) void refreshAccountModels();
|
||
}} />
|
||
)}
|
||
{(connectionModels ? connectionModels.error : models.error) && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
|
||
{((showModel && !usingKimiApi) ||
|
||
efforts.length > 0) && (
|
||
<div className="grid items-start gap-5 sm:grid-cols-2">
|
||
{showModel && !usingKimiApi && (
|
||
<ModelDropdown
|
||
presentation={brandType === "codex_local" ? "searchable" : "native"}
|
||
models={connectionModels?.models ?? models.data ?? []}
|
||
loadingModels={connectionModels?.isLoading ?? models.isLoading}
|
||
onRefreshModels={connectionModels?.refreshModels ?? (accountCatalogAvailable ? async () => { await models.refetch(); } : undefined)}
|
||
refreshingModels={connectionModels?.refreshing ?? (accountCatalogAvailable ? models.isFetching : undefined)}
|
||
value={model}
|
||
onChange={(value) => {
|
||
setModel(connectionModels?.resolveModel(value) ?? value);
|
||
if (
|
||
effort &&
|
||
!setupEfforts(adapterType, value).includes(
|
||
effort,
|
||
)
|
||
)
|
||
setEffort("");
|
||
const nextProvider = value.split("/")[0];
|
||
if (
|
||
multiProvider &&
|
||
PROVIDER_ENV_KEYS[nextProvider] &&
|
||
nextProvider !== provider
|
||
) {
|
||
setProvider(nextProvider);
|
||
setApiKey("");
|
||
setProviderBinding(null);
|
||
}
|
||
resetTest();
|
||
}}
|
||
open={modelOpen}
|
||
onOpenChange={setModelOpen}
|
||
allowDefault={!isKimi && !multiProvider && !accountBoundCatalog && !nativeCursorCloud}
|
||
required={isKimi || multiProvider || accountBoundCatalog || nativeCursorCloud}
|
||
creatable={brandType !== "copilot_runtime"}
|
||
groupByProvider={multiProvider && !connectionModels}
|
||
preserveOrder={Boolean(connectionModels) || adapterCuratesModelOrder(brandType)}
|
||
/>
|
||
)}
|
||
{efforts.length > 0 && (
|
||
<Field label="Thinking effort">
|
||
{brandType === "codex_local" ? <SelectPopover aria-label="Thinking effort" value={effort}
|
||
onValueChange={value => { setEffort(value); resetTest(); }}
|
||
options={[{ value: "", label: "Auto" }, ...efforts.map(value => ({ value, label: value }))]} /> : <NativeSelect
|
||
aria-label="Thinking effort"
|
||
value={effort}
|
||
onChange={(event) => {
|
||
setEffort(event.target.value);
|
||
resetTest();
|
||
}}
|
||
>
|
||
<option value="">Auto</option>
|
||
{efforts.map((value) => (
|
||
<option key={value} value={value}>
|
||
{value}
|
||
</option>
|
||
))}
|
||
</NativeSelect>}
|
||
</Field>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!aiBinding && SETUP_LOGIN_HINTS[adapterType] && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{SETUP_LOGIN_HINTS[adapterType]}
|
||
</p>
|
||
)}
|
||
{hasCredentialField && !aiBinding && (
|
||
<div className="grid gap-5 sm:grid-cols-2">
|
||
{chooseProvider && (
|
||
<Field label="API key provider">
|
||
<select
|
||
aria-label="API key provider"
|
||
className={controlClass}
|
||
value={provider}
|
||
onChange={(event) => {
|
||
setProvider(event.target.value);
|
||
setModel("");
|
||
setApiKey("");
|
||
setProviderBinding(null);
|
||
resetTest();
|
||
}}
|
||
>
|
||
{Object.keys(providerKeys).map((key) => (
|
||
<option key={key} value={key}>
|
||
{key === "openrouter"
|
||
? "OpenRouter"
|
||
: key === "openai"
|
||
? "OpenAI"
|
||
: key === "anthropic"
|
||
? "Anthropic"
|
||
: ({
|
||
google: "Google",
|
||
xai: "xAI",
|
||
groq: "Groq",
|
||
opencode: "OpenCode",
|
||
}[key] ?? key)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
)}
|
||
<div
|
||
className={
|
||
adapterType === "cursor_cloud"
|
||
? "sm:col-span-2"
|
||
: undefined
|
||
}
|
||
>
|
||
<Field label={envKey}>
|
||
<div className="flex items-center gap-3">
|
||
<Input
|
||
aria-label={envKey}
|
||
type="password"
|
||
autoComplete="off"
|
||
value={apiKey}
|
||
onChange={(event) => {
|
||
setApiKey(event.target.value);
|
||
setProviderBinding(null);
|
||
resetTest();
|
||
}}
|
||
placeholder={
|
||
selectedBinding
|
||
? "Using saved key"
|
||
: (isKimi || [
|
||
"cursor_cloud",
|
||
"hermes_gateway",
|
||
].includes(adapterType))
|
||
? "Required"
|
||
: "Optional if already configured"
|
||
}
|
||
/>
|
||
{adapterType === "cursor_cloud" && (
|
||
<a
|
||
href="https://cursor.com/dashboard/api?section=user-keys#user-api-keys"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="shrink-0 text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||
>
|
||
get api key
|
||
</a>
|
||
)}
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
{(adapterType !== "cursor_cloud" || nativeCursorCloud) && (
|
||
<div
|
||
className={
|
||
chooseProvider ? "sm:col-span-2" : undefined
|
||
}
|
||
>
|
||
<Field label="Or use an organization secret">
|
||
<SecretPicker
|
||
secretId={
|
||
selectedBinding &&
|
||
typeof selectedBinding === "object" &&
|
||
selectedBinding.type === "secret_ref"
|
||
? selectedBinding.secretId
|
||
: ""
|
||
}
|
||
secrets={companySecrets.data ?? []}
|
||
disabled={busy}
|
||
onSelect={(secretId) => {
|
||
setProviderBinding({
|
||
type: "secret_ref",
|
||
secretId,
|
||
version: "latest",
|
||
});
|
||
setApiKey("");
|
||
resetTest();
|
||
}}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-muted-foreground sm:col-span-2">
|
||
New keys are saved as organization secrets when
|
||
you finish setup.
|
||
{isKimi && " Use a Kimi Code API key for this runner."}
|
||
{multiProvider && ` Use a ${provider}/model ID.`}
|
||
</p>
|
||
</div>
|
||
)}
|
||
{adapterType === "hermes_gateway" && (
|
||
<Field label="Hermes API base URL">
|
||
<Input
|
||
aria-label="Hermes API base URL"
|
||
value={gatewayUrl}
|
||
onChange={(event) => {
|
||
setGatewayUrl(event.target.value);
|
||
resetTest();
|
||
}}
|
||
placeholder="https://hermes.example.com"
|
||
/>
|
||
</Field>
|
||
)}
|
||
{usingKimiApi && (
|
||
<div className="grid gap-5 sm:grid-cols-2">
|
||
<Field label="Kimi API model name">
|
||
<Input
|
||
aria-label="Kimi API model name"
|
||
value={kimiModel}
|
||
onChange={(event) => {
|
||
setKimiModel(event.target.value);
|
||
resetTest();
|
||
}}
|
||
placeholder="kimi-for-coding"
|
||
/>
|
||
</Field>
|
||
<Field label="Kimi API protocol">
|
||
<select
|
||
aria-label="Kimi API protocol"
|
||
className={controlClass}
|
||
value={kimiProtocol}
|
||
onChange={(event) => {
|
||
setKimiProtocol(event.target.value);
|
||
resetTest();
|
||
}}
|
||
>
|
||
{["kimi", "anthropic", "openai"].map(
|
||
(value) => (
|
||
<option key={value}>{value}</option>
|
||
),
|
||
)}
|
||
</select>
|
||
</Field>
|
||
<Field
|
||
label="Kimi API base URL"
|
||
hint="Optional override for your provider endpoint."
|
||
>
|
||
<Input
|
||
aria-label="Kimi API base URL"
|
||
value={kimiBaseUrl}
|
||
onChange={(event) => {
|
||
setKimiBaseUrl(event.target.value);
|
||
resetTest();
|
||
}}
|
||
placeholder="Provider default"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
)}
|
||
{adapterType === "cursor_cloud" && (
|
||
<>
|
||
{nativeCursorCloud && <p className="text-sm text-muted-foreground">Experimental. Cursor hosts this agent’s execution environment. A Paperclip computer is not required. Cursor manages cloud command permissions; the effective model is not acknowledged.</p>}
|
||
<div className="grid gap-5 sm:grid-cols-2">
|
||
<Field label="GitHub repository">
|
||
<Input
|
||
aria-label="GitHub repository"
|
||
value={repository}
|
||
onChange={(event) => {
|
||
setRepository(event.target.value);
|
||
resetTest();
|
||
}}
|
||
placeholder="https://github.com/your-org/repo"
|
||
/>
|
||
</Field>
|
||
<Field label="Branch">
|
||
<Input
|
||
aria-label="Branch"
|
||
placeholder="Repository default"
|
||
value={branch}
|
||
onChange={(event) => {
|
||
setBranch(event.target.value);
|
||
resetTest();
|
||
}}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</>
|
||
)}
|
||
{["codex_local", "claude_local", "opencode_local", "pi_local", "cursor_cloud"].includes(adapterType) && <details className="space-y-3">
|
||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||
<AgentRunnerSelect value={runner} disabled={busy}
|
||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||
defaultRunner={adapters.data?.find(item => item.type === adapterType)?.defaultRunner}
|
||
supportedRunners={adapters.data?.find(item => item.type === adapterType)?.supportedRunners}
|
||
onChange={value => { setRunner(value); resetTest(); }} />
|
||
</details>}
|
||
</section>
|
||
{adapterType !== "hermes_gateway" && adapterType !== "cursor_cloud" && (
|
||
<section className="space-y-5">
|
||
<h3 className="text-sm font-semibold">Environment</h3>
|
||
<select
|
||
aria-label="Environment"
|
||
className={controlClass}
|
||
value={environmentOverride}
|
||
disabled={forced.forced}
|
||
onChange={(event) => {
|
||
void changeEnvironment(event.target.value);
|
||
}}
|
||
>
|
||
<option value="">
|
||
Default: {environmentLabel}
|
||
</option>
|
||
{(envs.data ?? [])
|
||
.filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local"))
|
||
.map((env) => (
|
||
<option key={env.id} value={env.id}>
|
||
{environmentDisplayLabel(env)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</section>
|
||
)}
|
||
</fieldset>
|
||
<RuntimeTestCard
|
||
variant={isDot ? "prerequisites" : "connection"}
|
||
metadataOnly={accountBoundCatalog}
|
||
state={testState}
|
||
result={result}
|
||
error={error}
|
||
disabled={pendingAntigravity || !ready || busy || Boolean(connectionAdapter && !connection)}
|
||
onTest={() => void runTest()}
|
||
/>
|
||
{error && testState !== "fail" && (
|
||
<p role="alert" className="text-sm text-destructive">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{environmentSaveError && <p role="alert" className="text-sm text-destructive">{environmentSaveError}</p>}
|
||
{environmentSaveError && !environmentSelectionSaved && <Button disabled={busy} onClick={() => void changeEnvironment()}>Retry saving environment</Button>}
|
||
<div className="flex justify-between border-t border-border py-5">
|
||
<div className="flex items-center gap-3">
|
||
<Button type="button" variant="ghost" disabled={busy} onClick={() => void saveAndExit()}>{pendingAntigravity ? "Back to agents" : "Save & exit"}</Button>
|
||
{connectionAdapter ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
disabled={busy}
|
||
onClick={editConnection}
|
||
>
|
||
<ArrowLeft className="size-4" />
|
||
Connection
|
||
</Button>
|
||
) : (
|
||
null
|
||
)}
|
||
</div>
|
||
{connectionAdapter && !connection ? <Button
|
||
type="button"
|
||
disabled={!ready || busy}
|
||
onClick={() => setScreen("connect")}
|
||
>
|
||
Connect model
|
||
<ArrowRight className="size-4" />
|
||
</Button> : <Button
|
||
type="submit"
|
||
disabled={
|
||
!ready ||
|
||
pendingAntigravity ||
|
||
busy ||
|
||
testState === "fail" ||
|
||
Boolean(connectionAdapter && !connection)
|
||
}
|
||
>
|
||
{saving ? "Saving…" : "Finish setup"}
|
||
<Check className="size-4" />
|
||
</Button>}
|
||
</div>
|
||
</form>
|
||
)}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</MotionConfig>
|
||
);
|
||
}
|