mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:03:27 +01:00
Add guided external-agent invitations with live Dot connection checks and ongoing access until revoked. Keep expired credentials invalid and enforce permissions for private avatar uploads. Preserve Runner recovery and remote launch boundaries. Co-Authored-By: Paperclip <noreply@paperclip.ing>
79 lines
5.5 KiB
TypeScript
79 lines
5.5 KiB
TypeScript
import { and, desc, eq, isNull, ne, sql } from "drizzle-orm";
|
|
import { activityLog, agents, companies, dotAgentBindings, type Db } from "@paperclipai/db";
|
|
import { agentService } from "./agents.js";
|
|
import { approvalService } from "./approvals.js";
|
|
import { accessService } from "./access.js";
|
|
import { agentInstructionsService } from "./agent-instructions.js";
|
|
import { loadDefaultAgentInstructionsBundle } from "./default-agent-instructions.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
import { dotRunnerBroker } from "./dot-runner-broker.js";
|
|
import { notFound, unprocessable } from "../errors.js";
|
|
|
|
/** One unfinished invitation per operator/company. Only non-secret references are persisted. */
|
|
export function dotInvitationService(db: Db) {
|
|
async function pending(database: Db, companyId: string, operatorId: string) {
|
|
const rows = await database.select({ agent: agents, binding: dotAgentBindings })
|
|
.from(agents).leftJoin(dotAgentBindings, and(eq(dotAgentBindings.agentId, agents.id), isNull(dotAgentBindings.revokedAt)))
|
|
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"),
|
|
eq(agents.adapterType, "paperclip_runner"), sql`${agents.adapterConfig}->>'provider' = 'openai_dot'`,
|
|
sql`exists (select 1 from ${activityLog} where ${activityLog.companyId} = ${agents.companyId}
|
|
and ${activityLog.entityId} = ${agents.id}::text and ${activityLog.entityType} = 'agent'
|
|
and ${activityLog.actorType} = 'user' and ${activityLog.actorId} = ${operatorId}
|
|
and ${activityLog.action} = 'agent.hire_created' and ${activityLog.details}->>'source' = 'dot-invitation')`)).orderBy(desc(agents.createdAt));
|
|
return rows.find(row => row.binding?.status !== "ready")?.agent ?? null;
|
|
}
|
|
async function describe(agent: typeof agents.$inferSelect) {
|
|
const approval = await approvalService(db).findOpenHireApprovalForAgent(agent.companyId, agent.id);
|
|
return { agent: { id: agent.id, name: agent.name, status: agent.status }, approvalId: approval?.id ?? null,
|
|
binding: await dotRunnerBroker(db).bindingForAgent(agent.companyId, agent.id) };
|
|
}
|
|
return {
|
|
async resume(companyId: string, operatorId: string) {
|
|
const agent = await pending(db, companyId, operatorId);
|
|
return agent ? describe(agent) : null;
|
|
},
|
|
async create(companyId: string, operatorId: string) {
|
|
if (!await dotRunnerBroker(db).enabled()) throw unprocessable("Enable OpenAI Dot and Assistant connections (MCP) in experimental settings.");
|
|
const agent = await db.transaction(async tx => {
|
|
const database = tx as unknown as Db;
|
|
// SQL locking also serializes retries on different control-plane replicas.
|
|
const [company] = await tx.select().from(companies).where(eq(companies.id, companyId)).for("update");
|
|
if (!company) throw notFound("Company not found");
|
|
if (company.status !== "active") throw unprocessable("Activate this company before inviting Dot.");
|
|
const existing = await pending(database, companyId, operatorId);
|
|
if (existing) return existing;
|
|
const svc = agentService(database);
|
|
let created = await svc.create(companyId, {
|
|
name: "Dot", role: "general", adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true,
|
|
dotWorkspaceAccess: false, dotAttachmentAccess: false },
|
|
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } },
|
|
status: company.requireBoardApprovalForNewAgents ? "pending_approval" : "idle",
|
|
metadata: { dotInvitation: { operatorId } },
|
|
}, { createdByUserId: operatorId });
|
|
const bundle = await agentInstructionsService(database).materializeManagedBundle(created,
|
|
await loadDefaultAgentInstructionsBundle("default"), { entryFile: "AGENTS.md", replaceExisting: false });
|
|
created = (await svc.update(created.id, { adapterConfig: bundle.adapterConfig }, { allowPendingApprovalConfigUpdate: true }))!;
|
|
const access = accessService(database);
|
|
await access.ensureMembership(companyId, "agent", created.id, "member", "active");
|
|
await access.setPrincipalPermission(companyId, "agent", created.id, "tasks:assign", true, operatorId);
|
|
const approval = company.requireBoardApprovalForNewAgents ? await approvalService(database).create(companyId, {
|
|
type: "hire_agent", status: "pending", requestedByUserId: operatorId,
|
|
payload: { agentId: created.id, name: created.name, role: created.role, adapterType: created.adapterType,
|
|
adapterConfig: created.adapterConfig, runtimeConfig: created.runtimeConfig, metadata: created.metadata,
|
|
budgetMonthlyCents: created.budgetMonthlyCents, requestedConfigurationSnapshot: {
|
|
adapterType: created.adapterType, runtimeConfig: created.runtimeConfig, adapterConfig: created.adapterConfig,
|
|
} },
|
|
}) : null;
|
|
await logActivity(database, { companyId, actorType: "user", actorId: operatorId, action: "agent.hire_created",
|
|
entityType: "agent", entityId: created.id, details: { name: created.name, role: created.role,
|
|
requiresApproval: !!approval, approvalId: approval?.id ?? null, source: "dot-invitation" } });
|
|
if (approval) await logActivity(database, { companyId, actorType: "user", actorId: operatorId, action: "approval.created",
|
|
entityType: "approval", entityId: approval.id, details: { type: "hire_agent", agentId: created.id } });
|
|
return created;
|
|
});
|
|
return describe(agent);
|
|
},
|
|
};
|
|
}
|