Files
PaperClipAI/server/src/services/agent-hiring.ts
T
DottaandPaperclip 7956056b2f Merge current master and qualify managed Codex onboarding
Reconcile actual master 833769ab with preserved Codex 1a0ca662 and reviewed private onboarding preparation a758b226. Preserve the merged prerequisite and current native/security changes, saved runner choices, managed-image qualification and current Storybook identities.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 10:21:06 -05:00

145 lines
7.7 KiB
TypeScript

import { agentAppearanceSchema, randomAgentAppearance } from "@paperclipai/shared";
import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils";
import { agents, toolConnectionInstalls, principalPermissionGrants, type Db } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { unprocessable } from "../errors.js";
import { agentRecordQueries, isPlainRecord, configPatchFromApprovalPayload, normalizeRuntimeConfigForNewAgent,
deduplicateAgentName, type CreateAgentData, type CreateAgentOptions, type AgentConfigurationPatch, type AgentHireRecord } from "../lib/agent-records.js";
import { normalizeAgentPermissions, permissionsImplyLowTrust, NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS,
newStandardAgentGrantScope } from "../lib/agent-permissions.js";
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js";
import { agentCredentialService } from "./agent-credentials.js";
import { agentIdentityService } from "./agent-identity.js";
import { initializePrimaryAgent } from "./primary-agent.js";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData> {
const { runner, ...data } = input;
const selection = options?.runnerResolved
? { adapterType: data.adapterType ?? "process", adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {} }
: await resolveNewAgentRunnerForCompany(db, companyId, {
...data, adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {}, runner,
});
data.adapterType = selection.adapterType;
data.adapterConfig = selection.adapterConfig;
const { ensureManager, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db);
if (data.appearance?.customAvatarAssetId) throw unprocessable("Create the agent before uploading its avatar");
assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options);
if (data.reportsTo) {
await ensureManager(companyId, data.reportsTo);
}
const existingAgents = await db
.select({ id: agents.id, name: agents.name, status: agents.status })
.from(agents)
.where(eq(agents.companyId, companyId));
const uniqueName = deduplicateAgentName(data.name, existingAgents);
const role = data.role ?? "general";
const normalizedPermissions = normalizeAgentPermissions(data.permissions, { context: "create" });
const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig);
const adapterType = data.adapterType ?? "process";
const rawAdapterConfig = isPlainRecord(data.adapterConfig)
? await secretService(db).normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType })
: {};
const adapterConfig = normalizePaperclipRunnerAdapterConfig(adapterType, rawAdapterConfig);
await agentCredentialService(db).enforceClaudeOAuthBindingClaim(db, {
companyId, consume: true, environmentId: data.defaultEnvironmentId ?? null,
childAdapterConfig: adapterConfig, claudeLogin: options?.claudeLogin,
decision: assertClaudeOAuthBindingInvariant({ adapterType, nextConfig: adapterConfig }),
});
return { ...data, name: uniqueName,
appearance: data.appearance == null ? randomAgentAppearance() : agentAppearanceSchema.parse(data.appearance),
role, adapterType, adapterConfig, permissions: normalizedPermissions, runtimeConfig };
}
export async function initializeAgentHire(txDb: Db, created: AgentHireRecord, options?: CreateAgentOptions) {
const tx = txDb;
const companyId = created.companyId;
await agentIdentityService(txDb).ensureAgentIdentity(companyId, created.id);
// New standard agents receive the standard direct grants at activation.
// Low-trust and bundled agents keep their explicit, narrower grants.
if (created.status !== "pending_approval" && !permissionsImplyLowTrust(created.permissions) &&
!readBuiltInAgentMarker(created.metadata)) {
await tx.insert(principalPermissionGrants).values(
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
companyId,
principalType: "agent" as const,
principalId: created.id,
permissionKey,
scope: newStandardAgentGrantScope(permissionKey, created.id),
})),
).onConflictDoNothing();
}
if (options?.aiConnectionInstall) {
const install = options.aiConnectionInstall;
const connectionIds = [...new Set([install.connectionId, ...(install.memberConnectionIds ?? [])])];
await tx.insert(toolConnectionInstalls).values(connectionIds.map(connectionId => ({
companyId, connectionId,
targetType: "agent" as const, targetId: created.id,
createdByUserId: install.createdByUserId,
}))).onConflictDoNothing();
}
await agentCredentialService(txDb).syncAgentSecretBindings(created, txDb);
if (options?.createdByUserId && !readBuiltInAgentMarker(created.metadata)) {
await initializePrimaryAgent(txDb, companyId, options.createdByUserId, created.id);
}
}
export async function prepareAgentHireApproval(txDb: Db, existing: AgentHireRecord, approvedPayload?: Record<string, unknown> | null): Promise<AgentConfigurationPatch> {
const approvedPatch = approvedPayload ? configPatchFromApprovalPayload(approvedPayload) : {};
const patch = { ...approvedPatch } as AgentConfigurationPatch;
const hasApprovedAdapterConfig = Object.prototype.hasOwnProperty.call(patch, "adapterConfig") && isPlainRecord(patch.adapterConfig);
if (
Object.prototype.hasOwnProperty.call(patch, "adapterConfig") &&
isPlainRecord(patch.adapterConfig)
) {
const normalizedAdapterConfig = await secretService(txDb).normalizeAdapterConfigForPersistence(
existing.companyId, patch.adapterConfig, { adapterType: (patch.adapterType ?? existing.adapterType) as string },
);
patch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
(patch.adapterType ?? existing.adapterType) as string,
normalizedAdapterConfig,
);
} else if (
Object.prototype.hasOwnProperty.call(patch, "adapterType")
&& isPlainRecord(existing.adapterConfig)
) {
patch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
patch.adapterType as string,
existing.adapterConfig,
);
}
if (patch.permissions !== undefined) {
// The pending-approval activation replays the original hire
// request, so the new-agent creation default applies.
patch.permissions = normalizeAgentPermissions(patch.permissions, { context: "create" });
}
if (hasApprovedAdapterConfig) {
await agentCredentialService(txDb).enforceClaudeOAuthBindingClaim(txDb, {
companyId: existing.companyId, consume: false, environmentId: null,
childAdapterConfig: patch.adapterConfig,
decision: assertClaudeOAuthBindingInvariant({ adapterType: patch.adapterType ?? existing.adapterType,
nextConfig: patch.adapterConfig, priorConfig: existing.adapterConfig }),
});
}
return patch;
}
export async function completeAgentHireApproval(txDb: Db, updated: AgentHireRecord, existing: AgentHireRecord) {
const tx = txDb;
await agentCredentialService(txDb).syncAgentSecretBindings(updated, txDb, existing.adapterConfig);
if (!permissionsImplyLowTrust(updated.permissions) && !readBuiltInAgentMarker(updated.metadata)) {
await tx.insert(principalPermissionGrants).values(
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
companyId: updated.companyId,
principalType: "agent" as const,
principalId: updated.id,
permissionKey,
scope: newStandardAgentGrantScope(permissionKey, updated.id),
})),
).onConflictDoNothing();
}
}