Files
PaperClipAI/ui/src/api/agents.ts
T
DottaandPaperclip 8d6232e7b0 feat: reuse provider sign-in across AI connection workflows (#13248)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Users connect provider accounts during onboarding and agent setup.
> - They should reuse and manage those accounts through the existing
Connectors interface.
> - A second login wizard would diverge from the established provider
workflows.
> - This pull request composes the existing sign-in components into
Connections and agent configuration.
> - Users can select accounts without changing their agent's harness or
model.

## Linked Issues or Issue Description

**Problem or motivation**
AI credentials are configured separately from Connections. Agents cannot
consistently reuse a responsible user's account or a permitted shared
account.

**Proposed solution**
Manage AI accounts with the existing Connections grants and permissions.
Keep model and harness selection independent from credential selection.
Preserve legacy authentication until validated adoption.

**Alternatives considered**
A separate credential registry would duplicate ownership and access
policy. Automatic fallback would risk using the wrong account.

**Roadmap alignment**
This extends the shipped Apps, multi-user, secrets, and agent-runtime
capabilities. The maintainer requested the feature and reviewed the UI.
Related groundwork: #11899 (connection permissions), #10910 (connection
wizard), #11692 (Claude subscription profiles), and #11854 (Codex
account rotation).

## What Changed

- Add compact AI-account management to the existing Connectors pages.
- Reuse AgentProviderConnection, AdapterLoginPanel, AdapterLoginChrome,
and authentication controllers.
- Add the shared connection picker to agent setup/settings and task
requests.
- Preserve onboarding's sequence and reuse existing accounts.
- Add local-login recovery, retry, cancellation, and React StrictMode
handling.
- Add interactive Storybook scenarios, design-guide examples, and app
acceptance checks.

This is part 2 of the AI Connections change. The runtime foundation in
#13247 is merged. This PR now targets master.

## Verification

- Updated against master `47ded8bf9`, including the landed runtime
foundation and upstream task-search changes.
- Full workspace typecheck, production build, Storybook build, and token
gates passed on the integrated branch. Final local-login changes passed
59 focused tests; new-agent and inbox regression suites passed 63 tests.
- Browser checks verified automatic local Claude account detection,
resumable Codex login commands, retry, focus restoration, and
desktop/phone layouts. Commands create their isolated directory before
invoking the CLI.
- All CI test, browser, build, packaging, and runner jobs passed on
final head `dd17d3211931dd70aaa6ea619d83a7f9966dd18e`. The fresh
Greptile review is 5/5, the security scan passed, and there are no
unresolved review threads. The final CI aggregate gates passed.
- Local general-server coverage passed 11,804 tests; three
port-collision failures passed in an isolated 25-test rerun. All 6,111
UI tests passed. CLI coverage passed 484 tests; its remaining doctor
test requires port 3199, which is occupied by an unrelated report server
on this Mac. The complete CLI suite passed in CI.
- Live browser testing verified Codex API-key reconnect inside a task
card on desktop and phone. Real provider runs resumed and completed with
unchanged connection/grant identity and agent routing.
- Tested opening, cancelling, reopening, and completing connection
creation. A regression confirms Connect another account cannot submit
the new-agent form or copy provider keys into agent settings.
- Added shared inline repair, automatic local sign-in checks, and
responsive connection dialogs. Standalone Daytona installation ignores
workspace configuration and suppresses dependency scripts. Its
standalone build also passed with CI's exact pnpm 9.15.4.
- Destructive live tests are excluded by default. Explicit opt-in, local
deployment checks, and matching disposable fixture identities are
required before any mutation.

## Risks

- Local Codex/Grok creation requires the connection-specific terminal
login command.
- Browser sign-in uses the existing supported-environment controllers.
- This update verifies live local Claude detection and Codex API-key
task repair. New subscription authorization/refresh and
independent-human/native-runner isolation were not reverified in this
update.
- No agent automatically adopts managed Connections.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, code
execution, and browser testing. The exact runtime model identifier and
context-window size are not exposed in this session.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-12 16:51:26 -05:00

367 lines
16 KiB
TypeScript

import type {
Agent,
AgentDesiredSkillEntry,
AgentSkillAssignmentMode,
AgentPermissions,
AgentDetail,
AgentInstructionsBundle,
AgentInstructionsFileDetail,
AgentSkillSnapshot,
AdapterEnvironmentTestResult,
AdapterAuthSignalResponse,
AdapterAuthSessionResponse,
AdapterAuthSessionOwnerResponse,
ClaudeSetupTokenSessionResponse,
ClaudeSetupTokenSessionOwnerResponse,
ClaudeSetupTokenSessionPrompt,
ClaudeSetupTokenCompletionResponse,
ClaudeSetupTokenOverwrite,
ClaudeOAuthTokenStatusResponse,
SubmitBrowserCodeRequest,
AgentKeyCreated,
AgentRuntimeState,
AgentTaskSession,
AgentWakeupResponse,
ChatFailedRunRetryResponse,
HeartbeatRun,
Approval,
AgentConfigRevision,
ClearAgentErrorResponse,
AgentApiKeyScope,
} from "@paperclipai/shared";
import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
import { ApiError, api } from "./client";
export interface AgentKey {
id: string;
name: string;
scope: AgentApiKeyScope;
createdAt: Date;
revokedAt: Date | null;
}
export interface AdapterModel {
id: string;
label: string;
}
export interface DetectedAdapterModel {
model: string;
provider: string;
source: string;
candidates?: string[];
}
export interface ClaudeLoginResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
loginUrl: string | null;
stdout: string;
stderr: string;
}
export interface OrgNode {
id: string;
name: string;
role: string;
status: string;
reports: OrgNode[];
}
export interface AgentHireResponse {
agent: Agent;
approval: Approval | null;
}
export interface AgentPermissionUpdate {
canCreateAgents: boolean;
canCreateSkills: boolean;
canAssignTasks: boolean;
trustPreset?: AgentPermissions["trustPreset"];
authorizationPolicy?: AgentPermissions["authorizationPolicy"];
}
export interface AgentWakeRequest {
source?: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string | null;
payload?: Record<string, unknown> | null;
idempotencyKey?: string | null;
forceFreshSession?: boolean;
debug?: { providerTrace: "raw" };
}
function withCompanyScope(path: string, companyId?: string) {
if (!companyId) return path;
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}companyId=${encodeURIComponent(companyId)}`;
}
function agentPath(id: string, companyId?: string, suffix = "") {
return withCompanyScope(`/agents/${encodeURIComponent(id)}${suffix}`, companyId);
}
export const agentsApi = {
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
listConfigurations: (companyId: string) =>
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
get: async (id: string, companyId?: string) => {
try {
return await api.get<AgentDetail>(agentPath(id, companyId));
} catch (error) {
// Backward-compat fallback: if backend shortname lookup reports ambiguity,
// resolve using company agent list while ignoring terminated agents.
if (
!(error instanceof ApiError) ||
error.status !== 409 ||
!companyId ||
isUuidLike(id)
) {
throw error;
}
const urlKey = normalizeAgentUrlKey(id);
if (!urlKey) throw error;
const agents = await api.get<Agent[]>(`/companies/${companyId}/agents`);
const matches = agents.filter(
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
);
if (matches.length !== 1) throw error;
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
}
},
getConfiguration: (id: string, companyId?: string) =>
api.get<Record<string, unknown>>(agentPath(id, companyId, "/configuration")),
listConfigRevisions: (id: string, companyId?: string) =>
api.get<AgentConfigRevision[]>(agentPath(id, companyId, "/config-revisions")),
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
api.get<AgentConfigRevision>(agentPath(id, companyId, `/config-revisions/${revisionId}`)),
rollbackConfigRevision: (id: string, revisionId: string, companyId?: string) =>
api.post<Agent>(agentPath(id, companyId, `/config-revisions/${revisionId}/rollback`), {}),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Agent>(`/companies/${companyId}/agents`, data),
hire: (companyId: string, data: Record<string, unknown>) =>
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId), data),
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
instructionsBundle: (id: string, companyId?: string) =>
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
updateInstructionsBundle: (
id: string,
data: {
mode?: "managed" | "external";
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
},
companyId?: string,
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
instructionsFile: (id: string, relativePath: string, companyId?: string) =>
api.get<AgentInstructionsFileDetail>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
),
saveInstructionsFile: (
id: string,
data: { path: string; content: string; clearLegacyPromptTemplate?: boolean },
companyId?: string,
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) =>
api.delete<AgentInstructionsBundle>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
),
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
clearError: (id: string, companyId?: string) =>
api.post<ClearAgentErrorResponse>(agentPath(id, companyId, "/clear-error"), {}),
approve: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/approve"), {}),
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
skills: (id: string, companyId?: string) =>
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
syncSkills: (
id: string,
desiredSkills: Array<string | AgentDesiredSkillEntry>,
mode: AgentSkillAssignmentMode,
companyId?: string,
) => api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills, mode }),
createKey: (id: string, name: string, companyId?: string, scope?: AgentApiKeyScope) =>
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name, ...(scope ? { scope } : {}) }),
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)),
runtimeState: (id: string, companyId?: string) =>
api.get<AgentRuntimeState>(agentPath(id, companyId, "/runtime-state")),
taskSessions: (id: string, companyId?: string) =>
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
adapterModels: (
companyId: string,
type: string,
options?: { refresh?: boolean; environmentId?: string | null; provider?: string },
) => {
const params = new URLSearchParams();
if (options?.refresh) params.set("refresh", "1");
if (options?.provider) params.set("provider", options.provider);
if (options?.environmentId) params.set("environmentId", options.environmentId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${query}`,
);
},
detectModel: (companyId: string, type: string) =>
api.get<DetectedAdapterModel | null>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
),
testEnvironment: (
companyId: string,
type: string,
data: {
adapterConfig: Record<string, unknown>;
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
agentId?: string;
testCredentials?: Record<string, string>;
environmentId?: string | null;
},
) =>
api.post<AdapterEnvironmentTestResult>(
`/companies/${companyId}/adapters/${type}/test-environment`,
data,
),
getAdapterAuthSignal: (companyId: string, type: string, environmentId?: string | null) => {
const query = environmentId ? `?environmentId=${encodeURIComponent(environmentId)}` : "";
return api.get<AdapterAuthSignalResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/auth-signal${query}`,
);
},
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
wakeup: (
id: string,
data: AgentWakeRequest,
companyId?: string,
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
retryFailedRun: async (
id: string,
failedRunId: string,
companyId: string,
) => {
const result = await api.post<
AgentWakeupResponse | ChatFailedRunRetryResponse
>(agentPath(id, companyId, "/wakeup"), {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
failedRunId,
});
if ("id" in result) return { runId: result.id, issueId: null };
if ("actionId" in result) {
if (result.status === "failed" || result.status === "cancelled") {
throw new Error(
"This retry could not start. Open the task to review its current access and recovery state.",
);
}
return { runId: result.runId, issueId: result.issueId };
}
throw new Error(result.message ?? "Retry was skipped.");
},
loginWithClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
startAdapterAuthLogin: (
companyId: string,
type: string,
data: { environmentId: string; ttlSeconds?: number; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent },
) =>
api.post<AdapterAuthSessionResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions`,
data,
),
// The owner response repeats the live prompt on every read while the
// session holds an active public status, so this polling call carries the
// same no-store request option as the active-session read below.
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string) =>
api.get<AdapterAuthSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
),
// Reads the caller's active login session for one company and adapter, with
// no session id, so the browser rediscovers its own session after a reload
// with no local state. A 404 means no active session for the caller.
getActiveAdapterAuthLoginSession: (companyId: string, type: string) =>
api.get<AdapterAuthSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/active`,
{ cache: "no-store" },
),
cancelAdapterAuthLogin: (companyId: string, type: string, sessionId: string) =>
api.post<AdapterAuthSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}/cancel`,
{},
),
// The Claude submitted-browser-code login uses the company-and-environment
// setup-token routes. The route fixes the `claude_local` adapter. The start
// response carries the panel mode; the authorization URL rides only through the
// guarded prompt read. The completion response carries a non-secret
// `storedSessionId` claim and no token.
// Reads the stored Claude OAuth token status for the authenticated owner. A
// 200 carries only the secret id and the latest version; a 404 means the owner
// has no stored value (indistinguishable from a foreign value). The client
// applies the stored token first and captures the version for a later
// version-checked overwrite.
getClaudeOAuthTokenStatus: (companyId: string) =>
api.get<ClaudeOAuthTokenStatusResponse>(
`/companies/${encodeURIComponent(companyId)}/claude-oauth-token-status`,
),
startClaudeSetupTokenLogin: (
companyId: string,
data: { environmentId: string; overwrite?: ClaudeSetupTokenOverwrite; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent },
) =>
api.post<ClaudeSetupTokenSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`,
{ adapterType: "claude_local", ...data },
),
getClaudeSetupTokenLoginStatus: (companyId: string, sessionId: string) =>
api.get<ClaudeSetupTokenSessionResponse>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}`,
),
// Reads the caller's active Claude setup-token login session, with no
// session id, so the browser rediscovers its own session after a reload with
// no local state. A 404 means no active session for the caller.
getActiveClaudeSetupTokenLoginSession: (companyId: string) =>
api.get<ClaudeSetupTokenSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/active`,
{ cache: "no-store" },
),
getClaudeSetupTokenLoginPrompt: (companyId: string, sessionId: string) =>
api.get<ClaudeSetupTokenSessionPrompt>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/prompt`,
),
submitClaudeSetupTokenBrowserCode: (companyId: string, sessionId: string, browserCode: string) =>
api.post<ClaudeSetupTokenSessionResponse>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/code`,
{ browserCode } satisfies SubmitBrowserCodeRequest,
),
completeClaudeSetupTokenLogin: (companyId: string, sessionId: string) =>
api.post<ClaudeSetupTokenCompletionResponse>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/completion`,
{},
),
cancelClaudeSetupTokenLogin: (companyId: string, sessionId: string) =>
api.post<void>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/cancel`,
{},
),
availableSkills: () =>
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
};
export interface AvailableSkill {
name: string;
description: string;
isPaperclipManaged: boolean;
}