mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - AI Connections store reusable provider credentials with company and owner boundaries. > - Self-hosted instances can require board authentication while running agents on the local server. > - The login UI treated those instances as unsupported and displayed instructions without a command. > - Removing that restriction must not expose the server operator's existing CLI account. > - This pull request enables owner-scoped login attempts and reuses the existing login UI. > - Users can connect Codex and Claude subscriptions on an authenticated self-hosted instance. ## Linked Issues or Issue Description **What happened?** On an authenticated self-hosted instance, OpenAI subscription setup displayed terminal instructions with no command and a disabled Connect button. Claude also could not complete the local connection flow. **Expected behavior** An authorized board user can prepare an isolated sign-in attempt, sign in on the server, and save the verified account as a Connection. This must not import another user's or the operator's ambient credentials. **Steps to reproduce** Run Paperclip in authenticated mode with a local environment. Open Connections, choose OpenAI or Anthropic, and select Subscription. The previous UI never enabled local login preparation. Related: #13247, #13248, and #10751. This fix preserves the restriction on remote access to the operator's ambient Claude login. ## What Changed - Allow company-authorized users to create, check, cancel, and complete their own isolated local login attempts. - Keep ambient Claude credential import restricted to the local operator. - Support isolated Claude credential files without falling back to the host account or mutating process-wide environment variables. - Use Codex device authorization so sign-in does not depend on a browser callback to the remote server's localhost. - Gate server-host login on authenticated public deployments unless a trusted runtime host is configured. Publish the capability through health so setup shows supported alternatives. - Read isolated Claude credential files through bounded, descriptor-bound opens with ownership, permission, and symlink checks. Try the alternate filename after malformed JSON. - Reuse shared login instructions and lifecycle hooks in onboarding, agent setup, and Connections. Show health-query failures explicitly. - Document authenticated self-hosted behavior and add authorization, isolation, lifecycle, and UI regression tests. ## Verification - Passed 71 focused tests across connection routes, credential isolation, legacy compatibility, the shared login hook, and agent setup. - Passed 89 onboarding regression tests. - Passed `pnpm -r typecheck`, `pnpm build`, Storybook build, and `pnpm check:token-gates`. - Completed real Codex device authorization and Claude browser authorization on an authenticated Linux self-hosted instance. Both accounts were detected automatically and saved as Connected. Both completed attempt directories were removed. - These live checks cover login, credential validation, and connection creation. They do not establish a new model execution or long-running refresh result. - Review follow-up: 68 focused checks passed after rerunning one route socket error; the full route/health rerun passed all 49 tests. The 70-test onboarding suite also passed. Final workspace typecheck, production build, and Storybook build passed again. - The broad local run exposed an instance-name assumption in two new assertions. The fixture now uses an explicit non-default instance, and all 32 connection tests passed with a different inherited instance name. The superseded broad run was stopped; this is not a claim that the full local suite completed. Full CI results will be recorded before merge. ## Risks - The server must have the provider CLI installed. Users still run the displayed command on the server that hosts Paperclip. - Authorization checks must keep login attempts scoped to the company, owner, provider, and reconnect target. Regression tests cover cross-user and cross-company access. - Existing local-trusted Claude behavior stays available. Authenticated remote users cannot use its ambient import path. - No database migration, dependency change, agent binding change, or provider routing change is included. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code execution, and browser tools. The runtime does not expose a more specific model version or context-window size. ## 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 - [ ] 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>
429 lines
18 KiB
TypeScript
429 lines
18 KiB
TypeScript
import { healthApi } from "@/api/health";
|
|
import { aiConnectionsApi } from "@/api/ai-connections";
|
|
import { useLocalAiLogin } from "../ai-connections/useLocalAiLogin";
|
|
import type { AiConnectionBinding, AiConnectionLoginIntent } from "@paperclipai/shared";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { motion } from "motion/react";
|
|
import {
|
|
SavedProviderKeySelect,
|
|
useSavedProviderKeys,
|
|
} from "../onboarding/SavedProviderKeySelect";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { AdapterLoginPanel } from "../AgentConfigForm";
|
|
import {
|
|
LocalProviderLoginInstructions,
|
|
OnboardingCardField,
|
|
OnboardingLoginCard,
|
|
} from "../AdapterLoginChrome";
|
|
import { ModelSourceTiles } from "../onboarding/ModelSourceTiles";
|
|
import { CredentialModeLink } from "../onboarding/CredentialModeLink";
|
|
import { FooterNav } from "../onboarding/FooterNav";
|
|
import { MAKE_ROOM, CARD_ENTER } from "../onboarding/onboarding-motion";
|
|
import { buildFixedClaudeOAuthBinding } from "../environment-variables-editor/model";
|
|
import type { EnvBinding } from "@paperclipai/shared";
|
|
|
|
export type ProviderConnection = {
|
|
env: Record<string, EnvBinding>;
|
|
aiConnection?: AiConnectionBinding;
|
|
/** Kept in memory until the user finishes setup. */
|
|
credentials?: Record<string, string>;
|
|
storedSessionId?: string;
|
|
applyStoredClaudeLogin?: boolean;
|
|
};
|
|
export function AgentProviderConnection({
|
|
companyId,
|
|
adapterType,
|
|
environmentId,
|
|
canLogin,
|
|
localEnvironment = false,
|
|
onConnected,
|
|
onBack,
|
|
testConnection,
|
|
testError,
|
|
managedAccount,
|
|
}: {
|
|
companyId: string;
|
|
adapterType: "claude_local" | "codex_local" | "grok_local";
|
|
environmentId: string | null;
|
|
canLogin: boolean;
|
|
localEnvironment?: boolean;
|
|
onConnected: (connection: ProviderConnection) => void;
|
|
onBack: () => void;
|
|
testConnection: (connection: ProviderConnection) => Promise<boolean>;
|
|
testError?: string | null;
|
|
/** Connections supplies its access intent; presentation and login controllers stay shared. */
|
|
managedAccount?: {
|
|
intent: AiConnectionLoginIntent;
|
|
initialMethod?: "subscription" | "api_key";
|
|
fixedMethod?: boolean;
|
|
disabled?: boolean;
|
|
onComplete: (result: { connectionId: string; grantId: string; method: "subscription" | "api_key" }) => void;
|
|
};
|
|
}) {
|
|
const health = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get, enabled: localEnvironment });
|
|
const canUseLocalLogin = localEnvironment && (health.data?.localAiLoginSupported ?? health.data?.deploymentMode === "local_trusted");
|
|
const epoch = useRef(0);
|
|
useEffect(
|
|
() => () => {
|
|
epoch.current++;
|
|
},
|
|
[],
|
|
);
|
|
const cancel = () => {
|
|
epoch.current++;
|
|
setBusy(false);
|
|
setOpened(false);
|
|
setAuthorizationUrl(null);
|
|
setLoginPhase("preparing");
|
|
};
|
|
const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(managedAccount?.initialMethod === "api_key" ? "api" : managedAccount ? "subscription" : null);
|
|
const [opened, setOpened] = useState(false);
|
|
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
|
|
const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing");
|
|
const phaseBeforeSubmit = useRef<"ready" | "waiting">("ready");
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [storedConnection, setStoredConnection] =
|
|
useState<ProviderConnection | null>(null);
|
|
const provider = adapterType === "claude_local" ? "Claude" : adapterType === "grok_local" ? "Grok" : "OpenAI";
|
|
const envKey =
|
|
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : adapterType === "grok_local" ? "XAI_API_KEY" : "OPENAI_API_KEY";
|
|
const aiProvider = adapterType === "claude_local" ? "anthropic" : adapterType === "grok_local" ? "xai" : "openai";
|
|
const availableKeys = useSavedProviderKeys(companyId, envKey);
|
|
// Add/reconnect creates the requested account, never copies a saved account's
|
|
// credential or silently changes its ownership. Agent setup retains reuse.
|
|
const savedKeys = managedAccount
|
|
? { ...availableKeys, options: [], subscriptions: [], loading: false }
|
|
: availableKeys;
|
|
const [subscriptionId, setSubscriptionId] = useState<string | null>(null);
|
|
const savedSubscription =
|
|
savedKeys.subscriptions.length
|
|
? savedKeys.subscriptions.find(
|
|
(option) =>
|
|
option.id === (subscriptionId ?? savedKeys.subscriptions[0]?.id),
|
|
)
|
|
: undefined;
|
|
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
|
|
const selectedKey = savedKeys.options.find(
|
|
(option) => option.id === (selectedKeyId ?? savedKeys.options[0]?.id),
|
|
);
|
|
const storedLogin = managedAccount
|
|
? { ...savedKeys.storedLogin, data: undefined, isPending: false, isError: false }
|
|
: savedKeys.storedLogin;
|
|
const savedManagedAccount = useRef<{ connectionId: string; grantId: string } | null>(null);
|
|
const method = methodChoice ?? (
|
|
(savedKeys.subscriptions.length > 0 || (adapterType === "claude_local" && !savedSubscription && storedLogin.data))
|
|
? "subscription" : savedKeys.options.length ? "api" : "subscription"
|
|
);
|
|
const localLogin = useLocalAiLogin(companyId, managedAccount?.intent ?? {
|
|
provider: aiProvider, method: "subscription", name: `My ${provider} subscription`,
|
|
ownership: "personal", agentIds: [], allAgents: true,
|
|
}, canUseLocalLogin && method === "subscription" && !savedSubscription && !storedLogin.data,
|
|
{ allowHostClaude: health.data?.deploymentMode === "local_trusted" });
|
|
const auth = useQuery({
|
|
queryKey: queryKeys.agents.authSignal(
|
|
companyId,
|
|
adapterType,
|
|
environmentId,
|
|
),
|
|
queryFn: () =>
|
|
agentsApi.getAdapterAuthSignal(
|
|
companyId,
|
|
adapterType,
|
|
environmentId ?? undefined,
|
|
),
|
|
retry: false,
|
|
enabled: !managedAccount,
|
|
});
|
|
async function connect() {
|
|
if (busy || managedAccount?.disabled) return;
|
|
const run = ++epoch.current;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
if (managedAccount) {
|
|
if (method === "subscription" && !canUseLocalLogin) return;
|
|
const result = savedManagedAccount.current ?? await (method === "api"
|
|
? aiConnectionsApi.create(companyId, { ...managedAccount.intent, method: "api_key", apiKey: apiKey.trim() })
|
|
: localLogin.connect(managedAccount.intent));
|
|
savedManagedAccount.current = result;
|
|
setApiKey("");
|
|
if (run === epoch.current) managedAccount.onComplete({ ...result, method: method === "api" ? "api_key" : "subscription" });
|
|
return;
|
|
}
|
|
let connection: ProviderConnection =
|
|
method === "api"
|
|
? selectedKey
|
|
? selectedKey.aiConnection ? { env: {}, aiConnection: selectedKey.aiConnection } : { env: { [envKey]: selectedKey.binding } }
|
|
: (storedConnection ?? {
|
|
env: {},
|
|
credentials: { [envKey]: apiKey.trim() },
|
|
})
|
|
: {
|
|
...(savedSubscription?.aiConnection ? { aiConnection: savedSubscription.aiConnection } : {}),
|
|
env: savedSubscription?.binding
|
|
? { CODEX_HOME: savedSubscription.binding }
|
|
: {},
|
|
...(adapterType === "claude_local" && !savedSubscription && storedLogin.data
|
|
? {
|
|
env: buildFixedClaudeOAuthBinding(),
|
|
applyStoredClaudeLogin: true,
|
|
}
|
|
: {}),
|
|
};
|
|
if (method === "subscription" && canUseLocalLogin && !savedSubscription && !storedLogin.data) {
|
|
savedManagedAccount.current ??= await localLogin.connect();
|
|
connection = { env: {}, aiConnection: { provider: aiProvider, method: "subscription", mode: "responsible_user" } };
|
|
}
|
|
if (connection.credentials) {
|
|
await aiConnectionsApi.create(companyId, { provider: aiProvider, method: "api_key", name: `My ${provider} API`, ownership: "personal", apiKey: connection.credentials[envKey], agentIds: [], allAgents: true });
|
|
connection = { env: {}, aiConnection: { provider: aiProvider, method: "api_key", mode: "responsible_user" } };
|
|
}
|
|
if (run !== epoch.current) return;
|
|
if (method === "api") {
|
|
setApiKey("");
|
|
if (!selectedKey) setStoredConnection(connection);
|
|
}
|
|
const connected = await testConnection(connection);
|
|
if (run !== epoch.current) return;
|
|
if (connected) onConnected(connection);
|
|
else
|
|
setError(
|
|
"The provider did not respond. Check the connection and try again.",
|
|
);
|
|
} catch (cause) {
|
|
if (run !== epoch.current) return;
|
|
if (managedAccount) setApiKey("");
|
|
setError(
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Could not connect to the provider.",
|
|
);
|
|
} finally {
|
|
if (run === epoch.current) setBusy(false);
|
|
}
|
|
}
|
|
const needsLogin =
|
|
method === "subscription" &&
|
|
canLogin &&
|
|
environmentId &&
|
|
!savedSubscription &&
|
|
!savedKeys.loading &&
|
|
!storedLogin.data &&
|
|
(Boolean(managedAccount) || auth.data?.status !== "present" || subscriptionId === "");
|
|
return (
|
|
<div className="min-w-0 max-w-full">
|
|
<ModelSourceTiles
|
|
label="Connect your model provider"
|
|
sources={[
|
|
{
|
|
id: adapterType,
|
|
label: provider,
|
|
icon: (
|
|
<img
|
|
src={adapterType === "grok_local" ? "/brands/adapters/grok.svg" : `/brands/${adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
|
|
className="size-6"
|
|
alt=""
|
|
/>
|
|
),
|
|
},
|
|
]}
|
|
mode={method}
|
|
selectedId={opened ? adapterType : null}
|
|
collapsed={opened}
|
|
onSelect={() => { if (!managedAccount?.disabled) setOpened(true); }}
|
|
/>
|
|
{!opened && !managedAccount?.fixedMethod && (
|
|
<div className="-ml-3 mt-1">
|
|
<CredentialModeLink
|
|
mode={method}
|
|
onChange={(next) => {
|
|
savedManagedAccount.current = null;
|
|
setMethod(next);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{!opened && savedKeys.options.length > 0 && (
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
{savedKeys.options.length} saved API{" "}
|
|
{savedKeys.options.length === 1 ? "key available" : "keys available"}.
|
|
</p>
|
|
)}
|
|
{method === "subscription" &&
|
|
savedKeys.subscriptions.length > 0 && (
|
|
<SavedProviderKeySelect
|
|
options={savedKeys.subscriptions}
|
|
value={savedSubscription?.id ?? ""}
|
|
onChange={setSubscriptionId}
|
|
loading={false}
|
|
error={false}
|
|
kind="subscription"
|
|
disabled={busy}
|
|
/>
|
|
)}
|
|
<motion.div
|
|
initial={false}
|
|
animate={{ height: opened ? "auto" : 0, opacity: opened ? 1 : 0 }}
|
|
transition={{ height: MAKE_ROOM, opacity: CARD_ENTER }}
|
|
className="overflow-hidden"
|
|
>
|
|
{opened && (
|
|
<div className="pt-5">
|
|
{method === "api" ? (
|
|
<OnboardingLoginCard
|
|
instruction={
|
|
savedKeys.options.length
|
|
? "Choose a saved API key or enter a new one"
|
|
: `Provide your ${provider} API key to connect`
|
|
}
|
|
>
|
|
<SavedProviderKeySelect
|
|
{...savedKeys}
|
|
value={selectedKey?.id ?? ""}
|
|
disabled={busy}
|
|
onChange={(id) => {
|
|
setSelectedKeyId(id);
|
|
setApiKey("");
|
|
setStoredConnection(null);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
{!selectedKey && (
|
|
<OnboardingCardField
|
|
label="API key"
|
|
masked
|
|
autoFocus
|
|
value={apiKey}
|
|
placeholder={
|
|
storedConnection
|
|
? "Key entered. Retry the connection."
|
|
: "Enter API key here"
|
|
}
|
|
onChange={(value) => {
|
|
setSelectedKeyId("");
|
|
setApiKey(value);
|
|
setStoredConnection(null);
|
|
}}
|
|
onSubmit={() => void connect()}
|
|
disabled={busy}
|
|
/>
|
|
)}
|
|
</OnboardingLoginCard>
|
|
) : needsLogin ? (
|
|
<AdapterLoginPanel
|
|
companyId={companyId}
|
|
adapterType={adapterType}
|
|
environmentId={environmentId}
|
|
chrome="onboarding"
|
|
aiConnection={managedAccount?.intent ?? { provider: aiProvider, method: "subscription", name: `My ${provider} subscription`, ownership: "personal", agentIds: [], allAgents: true }}
|
|
autoStart
|
|
onStored={() => {}}
|
|
onPromptReady={(url) => {
|
|
setAuthorizationUrl(url);
|
|
setLoginPhase((phase) => url ? (phase === "preparing" ? "ready" : phase) : "preparing");
|
|
}}
|
|
onCodeSubmitted={() => {
|
|
phaseBeforeSubmit.current = loginPhase === "waiting" ? "waiting" : "ready";
|
|
setLoginPhase("connecting");
|
|
}}
|
|
onSubmitFailed={() => {
|
|
setLoginPhase((phase) => phase === "connecting" ? phaseBeforeSubmit.current : phase);
|
|
}}
|
|
onConnected={(sessionId) => {
|
|
if (managedAccount) {
|
|
if (!sessionId) { setError("The login did not return a saved connection. Try again."); return; }
|
|
const run = epoch.current;
|
|
setLoginPhase("connecting");
|
|
void aiConnectionsApi.loginResult(companyId, sessionId).then((result) => {
|
|
if (run === epoch.current) managedAccount.onComplete({ ...result, method: "subscription" });
|
|
}).catch(() => {
|
|
if (run !== epoch.current) return;
|
|
setLoginPhase("ready");
|
|
setError("Could not retrieve the saved connection. Go back and retry.");
|
|
});
|
|
return;
|
|
}
|
|
const connection: ProviderConnection = { env: {}, aiConnection: { provider: aiProvider, method: "subscription", mode: "responsible_user" } };
|
|
setStoredConnection(connection);
|
|
onConnected(connection);
|
|
}}
|
|
/>
|
|
) : savedSubscription ? null : canUseLocalLogin && !storedLogin.data ? (
|
|
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { setError(null); localLogin.retry(); } }} />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
{storedLogin.data
|
|
? "Use your saved Claude subscription for this agent."
|
|
: canLogin
|
|
? "Use the existing provider connection for this environment."
|
|
: "This environment does not support browser sign-in. Choose a sign-in environment or connect with an API key."}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
{method === "subscription" && storedLogin.isError && (
|
|
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
Could not check your saved Claude subscription. Try again.
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
{testError ?? error}
|
|
</p>
|
|
)}
|
|
{localEnvironment && health.isError && (
|
|
<p role="alert" className="mt-4 text-sm text-destructive">Could not prepare sign-in. Reload this page to try again.</p>
|
|
)}
|
|
<FooterNav
|
|
onBack={() => {
|
|
if (opened) cancel();
|
|
else onBack();
|
|
}}
|
|
primaryLabel={
|
|
opened && needsLogin
|
|
? loginPhase === "waiting" ? "Waiting for code"
|
|
: loginPhase === "connecting" ? "Connecting"
|
|
: `Sign in to ${provider}`
|
|
: busy
|
|
? "Connecting"
|
|
: method === "subscription" &&
|
|
(storedLogin.data || savedSubscription)
|
|
? "Use saved subscription"
|
|
: method === "api" && selectedKey
|
|
? "Use saved API key"
|
|
: "Connect"
|
|
}
|
|
primaryDisabled={
|
|
managedAccount?.disabled ||
|
|
(Boolean(managedAccount) && method === "subscription" && !canLogin && !canUseLocalLogin) ||
|
|
(localEnvironment && health.isPending) || localLogin.preparing || Boolean(localLogin.error) ||
|
|
(!managedAccount && auth.isPending) ||
|
|
savedKeys.loading ||
|
|
(adapterType === "claude_local" && storedLogin.isPending) ||
|
|
!opened ||
|
|
(Boolean(needsLogin) && (!authorizationUrl || loginPhase !== "ready")) ||
|
|
(method === "api" &&
|
|
!apiKey.trim() &&
|
|
!storedConnection &&
|
|
!selectedKey)
|
|
}
|
|
loading={busy}
|
|
primaryIcon={opened && needsLogin ? loginPhase === "ready" ? "none" : "spinner" : undefined}
|
|
onPrimary={() => {
|
|
if (needsLogin) {
|
|
if (!authorizationUrl || loginPhase !== "ready") return;
|
|
window.open(authorizationUrl, "_blank", "noreferrer,noopener");
|
|
setLoginPhase("waiting");
|
|
} else void connect();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|