mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
feat(dot): add guided invitations and persistent agent connections (#15581)
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>
This commit is contained in:
1 parent
04de3132c2
commit
2b4a1d7073
61 files changed
+7529
-4422
No files matched your search
@@ -0,0 +1,156 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import express, { type Request, type ErrorRequestHandler } from "express";
|
||||
import request from "supertest";
|
||||
import sharp from "sharp";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||
import { createDb, agents, companies, assets, activityLog, authUsers, companyMemberships, mcpOauthClients, mcpOauthGrants, dotAgentBindings, principalPermissionGrants } from "@paperclipai/db";
|
||||
import { appearanceForPalette, MAX_AGENT_AVATAR_BYTES } from "@paperclipai/shared";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { createStorageService } from "../storage/service.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { normalizeAgentAvatar, setAgentProfileAvatar } from "../services/agent-profile-avatar.js";
|
||||
import { agentProfileAvatarRoutes } from "../routes/agent-profile-avatar.js";
|
||||
import { assetRoutes } from "../routes/assets.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { createDotRunnerMcpTools } from "../services/dot-runner-broker.js";
|
||||
import type { McpPrincipal } from "../services/public-mcp/oauth.js";
|
||||
import { isSecretSensitiveHttpRequest } from "../middleware/http-log-policy.js";
|
||||
|
||||
let storage: StorageService;
|
||||
vi.mock("../storage/index.js", () => ({ getStorageService: () => storage }));
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let root: string;
|
||||
let imageBase64: string;
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-profile-avatar-");
|
||||
db = createDb(temporary.connectionString);
|
||||
root = await mkdtemp(join(tmpdir(), "profile-avatar-"));
|
||||
storage = createStorageService(createLocalDiskStorageProvider(root));
|
||||
imageBase64 = (await sharp({ create: { width: 800, height: 600, channels: 3, background: "orange" } }).png().toBuffer()).toString("base64");
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true });
|
||||
}, 120000);
|
||||
afterAll(async () => { await temporary?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); });
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Avatar test", issuePrefix: randomUUID().slice(0, 8) }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", status: "active", adapterType: "paperclip_runner", appearance: appearanceForPalette("deep-tide") }).returning();
|
||||
const actor = { actorType: "agent" as const, actorId: agent!.id, agentId: agent!.id };
|
||||
return { company: company!, agent: agent!, actor };
|
||||
}
|
||||
function appFor(actor: Request["actor"]) {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", agentProfileAvatarRoutes(db, storage), assetRoutes(db, storage));
|
||||
app.use(((error, _req, res, _next) => res.status(error.status ?? 400).json({ error: error.message })) as ErrorRequestHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("uploads, normalizes, serves privately, retries without duplication, and resets the avatar", async () => {
|
||||
const f = await fixture();
|
||||
const app = appFor({ type: "agent", agentId: f.agent.id, companyId: f.company.id });
|
||||
const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`;
|
||||
const upload = await request(app).put(path).send({ imageBase64 }).expect(200);
|
||||
expect(upload.body.appearance.paletteId).toBe(f.agent.appearance?.paletteId);
|
||||
const retry = await request(app).put(path).send({ imageBase64 }).expect(200);
|
||||
expect(retry.body).toEqual(upload.body);
|
||||
expect(await db.select().from(assets).where(eq(assets.companyId, f.company.id))).toHaveLength(1);
|
||||
const audit = await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id));
|
||||
expect(audit.filter(row => row.action === "agent.avatar_updated")).toHaveLength(1);
|
||||
expect(JSON.stringify(audit)).not.toContain(imageBase64);
|
||||
const content = await request(app).get(upload.body.avatarUrl).expect(200);
|
||||
expect(content.headers["cache-control"]).toContain("private");
|
||||
expect(await sharp(content.body).metadata()).toMatchObject({ format: "png", width: 512, height: 384 });
|
||||
expect((await agentService(db).getById(f.agent.id))?.avatarUrl).toBe(upload.body.avatarUrl);
|
||||
const reset = await request(app).put(path).send({ imageBase64: null }).expect(200);
|
||||
expect(reset.body.appearance).toEqual(f.agent.appearance);
|
||||
expect(reset.body.avatarUrl).toContain("/api/agent-avatars/");
|
||||
});
|
||||
|
||||
it("rejects other agents, companies, viewers, and foreign asset references", async () => {
|
||||
const f = await fixture(); const other = await fixture();
|
||||
const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`;
|
||||
for (const actor of [
|
||||
{ type: "agent", agentId: other.agent.id, companyId: f.company.id },
|
||||
{ type: "agent", agentId: other.agent.id, companyId: other.company.id },
|
||||
{ type: "board", source: "session", companyIds: [f.company.id], memberships: [{ companyId: f.company.id, status: "active", membershipRole: "viewer" }] },
|
||||
] satisfies Request["actor"][]) await request(appFor(actor)).put(path).send({ imageBase64 }).expect(403);
|
||||
await request(appFor({ type: "none" })).put(path).send({ imageBase64 }).expect(401);
|
||||
const foreign = await setAgentProfileAvatar(db, storage, other.company.id, other.agent.id, { imageBase64 }, other.actor);
|
||||
await expect(agentService(db).update(f.agent.id, { appearance: foreign.appearance })).rejects.toThrow("avatar upload");
|
||||
await request(appFor({ type: "agent", agentId: f.agent.id, companyId: f.company.id })).get(foreign.avatarUrl).expect(404);
|
||||
});
|
||||
|
||||
it("requires an agent configuration grant for board avatar changes", async () => {
|
||||
const f = await fixture();
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Member", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "member", status: "active" });
|
||||
const app = appFor({ type: "board", source: "session", userId, companyIds: [f.company.id], memberships: [{ companyId: f.company.id, status: "active", membershipRole: "member" }] });
|
||||
const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`;
|
||||
await request(app).put(path).send({ imageBase64 }).expect(403);
|
||||
await request(app).put(path).send({ imageBase64: null }).expect(403);
|
||||
expect(await db.select().from(assets).where(eq(assets.companyId, f.company.id))).toHaveLength(0);
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.company.id, principalType: "user", principalId: userId, permissionKey: "agents:suggest-changes" });
|
||||
await request(app).put(path).send({ imageBase64 }).expect(403);
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.company.id, principalType: "user", principalId: userId, permissionKey: "agents:configure" });
|
||||
await request(app).put(path).send({ imageBase64 }).expect(200);
|
||||
await request(app).put(path).send({ imageBase64: null }).expect(200);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "task_bridge" as const, parentIssueId: randomUUID() },
|
||||
{ kind: "skill_test" as const, issueId: randomUUID() },
|
||||
])("rejects profile changes by restricted %s credentials", async keyScope => {
|
||||
const f = await fixture();
|
||||
const app = appFor({ type: "agent", agentId: f.agent.id, companyId: f.company.id, keyScope });
|
||||
const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`;
|
||||
await request(app).put(path).send({ imageBase64 }).expect(403);
|
||||
await request(app).put(path).send({ imageBase64: null }).expect(403);
|
||||
expect(await db.select().from(assets).where(eq(assets.companyId, f.company.id))).toHaveLength(0);
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id))).toHaveLength(0);
|
||||
expect((await agentService(db).getById(f.agent.id))?.appearance).toEqual(f.agent.appearance);
|
||||
});
|
||||
|
||||
it("rejects URLs, SVG, corrupt and oversized images, unsupported fields and inactive agents", async () => {
|
||||
for (const input of ["https://example.test/avatar.png", Buffer.from("<svg xmlns=\"http://www.w3.org/2000/svg\"/>").toString("base64"), Buffer.from("broken").toString("base64"), Buffer.alloc(MAX_AGENT_AVATAR_BYTES + 1).toString("base64")]) {
|
||||
await expect(normalizeAgentAvatar(input)).rejects.toThrow();
|
||||
}
|
||||
const tooManyPixels = await sharp({ create: { width: 5000, height: 5000, channels: 3, background: "white" } }).png().toBuffer();
|
||||
await expect(normalizeAgentAvatar(tooManyPixels.toString("base64"))).rejects.toThrow("16 megapixels");
|
||||
const f = await fixture();
|
||||
const app = appFor({ type: "agent", agentId: f.agent.id, companyId: f.company.id });
|
||||
await request(app).put(`/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`).send({ imageBase64, agentId: randomUUID() }).expect(400);
|
||||
await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agent.id));
|
||||
await expect(setAgentProfileAvatar(db, storage, f.company.id, f.agent.id, { imageBase64 }, f.actor)).rejects.toThrow("current state");
|
||||
});
|
||||
|
||||
it("lets a paired Dot upload without an assignment, with feature and current grant authorization", async () => {
|
||||
const f = await fixture(); const userId = randomUUID(); const clientId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await db.insert(mcpOauthClients).values({ id: clientId, name: "Dot", redirectUris: [] });
|
||||
const [grant] = await db.insert(mcpOauthGrants).values({ companyId: f.company.id, userId, clientId, resource: "https://paperclip.example/mcp/runner", purpose: "agent", agentId: f.agent.id, scopes: ["paperclip:agent"] }).returning();
|
||||
await db.insert(dotAgentBindings).values({ companyId: f.company.id, agentId: f.agent.id, operatorId: userId, grantId: grant!.id, status: "connected" });
|
||||
const principal: McpPrincipal = { grant: grant!, company: f.company, actor: { type: "agent", companyId: f.company.id, agentId: f.agent.id } };
|
||||
const tools = createDotRunnerMcpTools(db);
|
||||
expect((await tools.listTools(principal)).find(tool => tool.name === "paperclip_dot_set_avatar")?.annotations).toMatchObject({ readOnlyHint: false });
|
||||
expect(await tools.callTool(principal, "paperclip_dot_set_avatar", { imageBase64 })).toMatchObject({ outcome: "completed", result: { agentId: f.agent.id } });
|
||||
await expect(tools.callTool(principal, "paperclip_dot_set_avatar", { imageBase64, agentId: randomUUID() })).rejects.toThrow();
|
||||
await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: false });
|
||||
try { await expect(tools.callTool(principal, "paperclip_dot_set_avatar", { imageBase64 })).rejects.toThrow("disabled"); }
|
||||
finally { await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: true }); }
|
||||
await db.update(mcpOauthGrants).set({ revokedAt: new Date() }).where(eq(mcpOauthGrants.id, grant!.id));
|
||||
await expect(tools.callTool(principal, "paperclip_dot_set_avatar", { imageBase64 })).rejects.toThrow("authority");
|
||||
});
|
||||
|
||||
it("redacts HTTP diagnostics containing image bytes", () => {
|
||||
expect(isSecretSensitiveHttpRequest("PUT", "/api/companies/company/agents/agent/avatar")).toBe(true);
|
||||
expect(isSecretSensitiveHttpRequest("POST", "/mcp/runner")).toBe(true);
|
||||
});
|
||||
@@ -4138,9 +4138,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
for (const canary of [configToken, signingSecret, clientSecret, f.token]) expect(publicState).not.toContain(canary);
|
||||
await request(f.app).patch(`/api/chat-endpoints/${f.endpoint.id}`).send({ slackApp: appDetails }).expect(409);
|
||||
});
|
||||
it("uploads avatar bytes without fetching a tenant-session-protected URL, after app credentials are durable", async () => {
|
||||
it.each([undefined, "22222222-2222-4222-8222-222222222222"])("uploads preset bytes after credentials are durable, even with private avatar %s", async customAvatarAssetId => {
|
||||
const f = await fixture();
|
||||
await db.update(agents).set({ appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "cherry-pop" } }).where(eq(agents.id, f.assignedAgentId));
|
||||
await db.update(agents).set({ appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "cherry-pop", customAvatarAssetId } }).where(eq(agents.id, f.assignedAgentId));
|
||||
f.state.beforeIcon = async () => {
|
||||
const row = (await f.service.slackRegistration.registration(f.endpoint.id))!;
|
||||
expect(row).toMatchObject({ status: "install", appId: f.appId });
|
||||
|
||||
@@ -91,7 +91,7 @@ async function createApp(actor: any, db: any = {} as any) {
|
||||
}
|
||||
|
||||
describe("cli auth routes", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
@@ -99,6 +99,9 @@ describe("cli auth routes", () => {
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
// Cold route transforms are fixture setup, not HTTP request latency.
|
||||
// Use the hook budget while preserving the request tests' normal timeout.
|
||||
await Promise.all([import("../routes/access.js"), import("../middleware/index.js")]);
|
||||
});
|
||||
|
||||
it("creates a CLI auth challenge with approval metadata", async () => {
|
||||
|
||||
@@ -1727,20 +1727,30 @@ describe("company portability", () => {
|
||||
expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("round-trips the saved visual persona through the portable bundle", async () => {
|
||||
it.each([undefined, "22222222-2222-4222-8222-222222222222"])("round-trips the preset without private avatar references (%s)", async customAvatarAssetId => {
|
||||
const appearance = { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "arctic-blue" };
|
||||
const source = await agentSvc.list();
|
||||
agentSvc.list.mockResolvedValue(source.map((agent: Record<string, unknown>) => ({ ...agent, appearance })));
|
||||
agentSvc.list.mockResolvedValue(source.map((agent: Record<string, unknown>) => ({ ...agent, appearance: { ...appearance, ...(customAvatarAssetId ? { customAvatarAssetId } : {}) } })));
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const include = { company: true, agents: true, projects: false, issues: false, skills: false };
|
||||
const exported = await portability.exportBundle("company-1", { include });
|
||||
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("arctic-blue");
|
||||
const extensionText = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extensionText).not.toContain("customAvatarAssetId");
|
||||
if (customAvatarAssetId) {
|
||||
expect(exported.warnings.some(warning => warning.includes("uploaded avatar was omitted"))).toBe(true);
|
||||
// Even a hand-edited package must not carry another agent's private asset into import.
|
||||
exported.files[".paperclip.yaml"] = extensionText.replace(/^(\s*)paletteId:.*$/gm,
|
||||
match => `${match}\n${match.match(/^\s*/)?.[0]}customAvatarAssetId: ${customAvatarAssetId}`);
|
||||
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain(customAvatarAssetId);
|
||||
}
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ ...input, id: `imported-${input.name}` }));
|
||||
await portability.importBundle({ source: { type: "inline", files: exported.files, rootPath: exported.rootPath }, include,
|
||||
target: { mode: "new_company", newCompanyName: "Imported personas" }, collisionStrategy: "rename", agents: "all" }, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalled();
|
||||
for (const [, input] of agentSvc.create.mock.calls) expect(input).toMatchObject({ appearance });
|
||||
for (const [, input] of agentSvc.create.mock.calls) expect(input.appearance).toEqual(appearance);
|
||||
});
|
||||
|
||||
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||
import { createDb, agents, approvals, principalPermissionGrants, authUsers, companies, companyMemberships, dotAgentBindings, activityLog } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { dotInvitationService } from "../services/dot-invitations.js";
|
||||
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
import { dotRunnerRoutes } from "../routes/dot-runner.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let root: string;
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("dot-invitations-");
|
||||
db = createDb(temporary.connectionString);
|
||||
root = await mkdtemp(join(tmpdir(), "dot-invite-home-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", root);
|
||||
vi.stubEnv("PAPERCLIP_IN_WORKTREE", "false");
|
||||
await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: true, enablePublicMcp: true });
|
||||
}, 120000);
|
||||
afterAll(async () => { await temporary?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); vi.unstubAllEnvs(); });
|
||||
async function fixture(requireApproval = false) {
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot invitation", issuePrefix: "DI" + randomBytes(3).toString("hex"), requireBoardApprovalForNewAgents: requireApproval }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await db.insert(principalPermissionGrants).values({ companyId: company!.id, principalType: "user", principalId: userId, permissionKey: "agents:create" });
|
||||
return { userId, companyId: company!.id };
|
||||
}
|
||||
it("atomically resumes one pending invite across retries and never persists a pairing secret", async () => {
|
||||
const f = await fixture();
|
||||
const service = dotInvitationService(db);
|
||||
const [a, b] = await Promise.all([service.create(f.companyId, f.userId), service.create(f.companyId, f.userId)]);
|
||||
expect(a.agent.id).toBe(b.agent.id);
|
||||
expect(a.agent.status).toBe("idle");
|
||||
expect(await service.resume(f.companyId, f.userId)).toMatchObject({ agent: { id: a.agent.id } });
|
||||
expect(await service.resume(f.companyId, randomUUID())).toBeNull();
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1);
|
||||
const code = await dotRunnerBroker(db).createPairing({ ...f, operatorId: f.userId, agentId: a.agent.id });
|
||||
const logs = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId));
|
||||
expect(JSON.stringify(logs)).not.toContain(code.pairingCode);
|
||||
expect(JSON.stringify(await service.resume(f.companyId, f.userId))).not.toContain(code.pairingCode);
|
||||
});
|
||||
it("honors hiring approval before issuing a pairing capability", async () => {
|
||||
const f = await fixture(true);
|
||||
const invite = await dotInvitationService(db).create(f.companyId, f.userId);
|
||||
expect(invite.agent.status).toBe("pending_approval");
|
||||
expect(invite.approvalId).toBeTruthy();
|
||||
const [approval] = await db.select().from(approvals).where(eq(approvals.id, invite.approvalId!));
|
||||
expect(approval).toMatchObject({ status: "pending", type: "hire_agent", payload: { agentId: invite.agent.id } });
|
||||
await expect(dotRunnerBroker(db).createPairing({ ...f, operatorId: f.userId, agentId: invite.agent.id })).rejects.toThrow("approved");
|
||||
expect((await dotInvitationService(db).create(f.companyId, f.userId)).approvalId).toBe(invite.approvalId);
|
||||
});
|
||||
it("replaces only the named pending capability and refuses to revoke a connected binding", async () => {
|
||||
const f = await fixture(); const broker = dotRunnerBroker(db);
|
||||
const invite = await dotInvitationService(db).create(f.companyId, f.userId);
|
||||
const input = { companyId: f.companyId, agentId: invite.agent.id, operatorId: f.userId };
|
||||
const first = await broker.createPairing(input);
|
||||
const second = await broker.createPairing({ ...input, replaceBindingId: first.bindingId });
|
||||
expect(second.bindingId).not.toBe(first.bindingId);
|
||||
const [old] = await db.select().from(dotAgentBindings).where(eq(dotAgentBindings.id, first.bindingId));
|
||||
expect(old).toMatchObject({ status: "revoked", pairingCodeHash: null });
|
||||
await expect(broker.createPairing({ ...input, replaceBindingId: first.bindingId })).rejects.toThrow("changed");
|
||||
await db.update(dotAgentBindings).set({ status: "connected" }).where(eq(dotAgentBindings.id, second.bindingId));
|
||||
await expect(broker.createPairing({ ...input, replaceBindingId: second.bindingId })).rejects.toThrow("changed");
|
||||
});
|
||||
it("keeps invite routes company-scoped and denies viewers and agent actors", async () => {
|
||||
const f = await fixture();
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
const mode = req.headers["x-test-actor"];
|
||||
req.actor = mode === "agent" ? { type: "agent", agentId: randomUUID(), companyId: f.companyId }
|
||||
: { type: "board", source: "session", userId: f.userId, companyIds: [f.companyId],
|
||||
memberships: [{ companyId: f.companyId, membershipRole: mode === "viewer" ? "viewer" : "owner", status: "active" }] };
|
||||
next();
|
||||
});
|
||||
app.use(dotRunnerRoutes(db, "https://paperclip.example/mcp/runner"));
|
||||
app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(error instanceof HttpError ? error.status : 500).json({ error: error.message });
|
||||
});
|
||||
const path = `/companies/${f.companyId}/dot-invitations`;
|
||||
for (const actor of ["viewer", "agent"]) expect((await request(app).post(path).set("x-test-actor", actor).send({})).status).toBe(403);
|
||||
expect((await request(app).post(`/companies/${randomUUID()}/dot-invitations`).send({})).status).toBe(403);
|
||||
const result = await request(app).post(path).send({});
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(200);
|
||||
expect(result.body.agent.id).toBeTruthy();
|
||||
expect((await request(app).get(path)).body.agent.id).toBe(result.body.agent.id);
|
||||
expect(await db.select().from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.status, "idle")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not trust editable agent metadata as an invitation ownership receipt", async () => {
|
||||
const f = await fixture();
|
||||
const [spoof] = await db.insert(agents).values({ companyId: f.companyId, name: "Unrelated agent", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "openai_dot" }, metadata: { dotInvitation: { operatorId: f.userId } } }).returning();
|
||||
const service = dotInvitationService(db);
|
||||
expect(await service.resume(f.companyId, f.userId)).toBeNull();
|
||||
expect((await service.create(f.companyId, f.userId)).agent.id).not.toBe(spoof!.id);
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, mcpOauthGrants, mcpOauthRequests } from "@paperclipai/db";
|
||||
import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
import { publicMcpManagementRoutes, publicMcpIngressRoutes } from "../routes/public-mcp.js";
|
||||
@@ -26,7 +27,7 @@ describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
await db.insert(authUsers).values({ id: userId, name: "Operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot onboarding", issuePrefix: "DO" + randomBytes(3).toString("hex") }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", status: "active" }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot" }, status: "active" }).returning();
|
||||
const oauth = createPublicMcpOAuth(db, { ...config, ...(authorizationOrigin ? { authorizationOrigin } : {}) });
|
||||
const client = await oauth.register({ client_name: "Dot", redirect_uris: [callback], grant_types: ["authorization_code", "refresh_token", DEVICE_GRANT] }, randomUUID());
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
@@ -37,6 +38,72 @@ describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
const id = await begin();
|
||||
return { userId, company: company!, agent: agent!, oauth, client, verifier, input, pairing, id, begin };
|
||||
}
|
||||
async function connect(f: Awaited<ReturnType<typeof fixture>>) {
|
||||
const consent = await f.oauth.consentDotPairing(f.id, f.pairing.pairingCode);
|
||||
return f.oauth.token({ grant_type: "authorization_code", client_id: f.client.client_id,
|
||||
redirect_uri: callback, resource: config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: f.verifier });
|
||||
}
|
||||
it("refreshes a Dot connection after years of inactivity, still rotates and revokes on replay", async () => {
|
||||
const f = await fixture();
|
||||
const tokens = await connect(f);
|
||||
const [stored] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(tokens.refresh_token!)));
|
||||
expect(stored!.expiresAt).toBeNull();
|
||||
const refresh = { grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: tokens.refresh_token };
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
vi.setSystemTime(new Date(Date.now() + 2 * 365 * 24 * 60 * 60_000));
|
||||
await expect(f.oauth.authenticate(tokens.access_token)).rejects.toThrow();
|
||||
const next = await f.oauth.token(refresh);
|
||||
expect(next.refresh_token).not.toBe(tokens.refresh_token);
|
||||
expect(next.expires_in).toBe(900);
|
||||
expect((await f.oauth.authenticate(next.access_token)).actor).toMatchObject({ type: "agent", agentId: f.agent.id });
|
||||
const [rotated] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(next.refresh_token!)));
|
||||
expect(rotated!.expiresAt).toBeNull();
|
||||
await expect(f.oauth.token(refresh)).rejects.toThrow();
|
||||
await expect(f.oauth.authenticate(next.access_token)).rejects.toThrow();
|
||||
expect(await dotRunnerBroker(db).bindingForAgent(f.company.id, f.agent.id)).toBeNull();
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
it("upgrades valid Dot refresh tokens without reviving expired, used or revoked credentials", async () => {
|
||||
const f = await fixture();
|
||||
const tokens = await connect(f);
|
||||
const grant = (await f.oauth.authenticate(tokens.access_token)).grant;
|
||||
const validExpiry = new Date(Date.now() + 30 * 24 * 60 * 60_000);
|
||||
await db.update(mcpOauthTokens).set({ expiresAt: validExpiry }).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(tokens.refresh_token!)));
|
||||
const stale = await fixture();
|
||||
const staleTokens = await connect(stale);
|
||||
const expired = new Date(0);
|
||||
await db.update(mcpOauthTokens).set({ expiresAt: expired }).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(staleTokens.refresh_token!)));
|
||||
const personalId = randomUUID();
|
||||
const revokedId = randomUUID();
|
||||
const usedHash = randomUUID();
|
||||
await db.insert(mcpOauthGrants).values([
|
||||
{ ...grant, id: personalId, purpose: "personal", agentId: null, resource: config.origin + "/mcp/paperclip" },
|
||||
{ ...grant, id: revokedId, revokedAt: new Date() },
|
||||
]);
|
||||
await db.insert(mcpOauthTokens).values([
|
||||
{ grantId: personalId, tokenHash: randomUUID(), kind: "refresh", expiresAt: validExpiry },
|
||||
{ grantId: revokedId, tokenHash: randomUUID(), kind: "refresh", expiresAt: validExpiry },
|
||||
{ grantId: grant.id, tokenHash: usedHash, kind: "refresh", expiresAt: validExpiry, usedAt: new Date() },
|
||||
]);
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0319_heavy_captain_midlands.sql", import.meta.url), "utf8");
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
for (const statement of migration.split("--> statement-breakpoint")) await db.execute(sql.raw(statement));
|
||||
}
|
||||
const [personal] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.grantId, personalId));
|
||||
const [revoked] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.grantId, revokedId));
|
||||
const [used] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, usedHash));
|
||||
const [staleToken] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(staleTokens.refresh_token!)));
|
||||
expect(personal!.expiresAt).toEqual(validExpiry);
|
||||
expect(revoked!.expiresAt).toEqual(validExpiry);
|
||||
expect(used!.expiresAt).toEqual(validExpiry);
|
||||
expect(staleToken!.expiresAt).toEqual(expired);
|
||||
await expect(stale.oauth.token({ grant_type: "refresh_token", client_id: stale.client.client_id, resource: config.resource, refresh_token: staleTokens.refresh_token })).rejects.toThrow();
|
||||
const next = await f.oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: tokens.refresh_token });
|
||||
expect((await f.oauth.authenticate(next.access_token)).actor).toMatchObject({ type: "agent", agentId: f.agent.id });
|
||||
await f.oauth.revokeToken(next.access_token, f.client.client_id);
|
||||
await expect(f.oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: next.refresh_token })).rejects.toThrow();
|
||||
});
|
||||
it("pins browser ingress without changing the issuer, resource or token endpoint", async () => {
|
||||
const browserOrigin = "https://paperclip-browser.example:10000";
|
||||
const f = await fixture(browserOrigin);
|
||||
@@ -75,6 +142,7 @@ describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
expect((await request(app).post(path).send({ pairingCode: f.pairing.pairingCode })).status).toBe(403);
|
||||
const preview = await request(app).post(path + "/preview").set("Origin", config.origin).send({ pairingCode: f.pairing.pairingCode });
|
||||
expect(preview.status).toBe(200);
|
||||
expect(preview.body.accessDuration).toBe("Ongoing until revoked. This connection does not expire from inactivity.");
|
||||
expect(preview.body).toMatchObject({ company: { id: f.company.id, name: f.company.name }, agent: { id: f.agent.id, name: f.agent.name } });
|
||||
expect(JSON.stringify(preview.body)).not.toContain(f.pairing.pairingCode);
|
||||
expect(await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.userId, f.userId))).toHaveLength(0);
|
||||
|
||||
@@ -10,7 +10,7 @@ import express, { type Request } from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations,
|
||||
heartbeatRuns, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, workspaceOperations, workAssessments, statusDecisions, issueThreadInteractions } from "@paperclipai/db";
|
||||
heartbeatRuns, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, mcpEventSubscriptions, workspaceOperations, workAssessments, statusDecisions, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createPublicMcpOAuth, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { createPublicMcpExecutor } from "../services/public-mcp/capabilities.js";
|
||||
@@ -125,7 +125,7 @@ describe("durable Dot Runner integration", () => {
|
||||
const events = createPublicMcpEvents(db, oauth, async () => { throw new Error("personal API dispatch forbidden"); }, { enableDotRunner: true, fetch: fetcher });
|
||||
const subscription = { name: "paperclip.dot.mailbox_updated", arguments: { companyId: company!.id, bindingId: pairing.bindingId }, delivery: { mode: "webhook", url: "https://example.com/dot-hook", secret } };
|
||||
await events.subscribe(principal, subscription);
|
||||
await broker.challenge(company!.id, agent!.id); await events.tick();
|
||||
await events.tick();
|
||||
if (!received.length) throw new Error("Dot readiness delivery missing: " + JSON.stringify(await db.select({ outcome: mcpEventDeliveries.outcome, event: mcpEventDeliveries.event }).from(mcpEventDeliveries)));
|
||||
expect(received.at(-1)?.data.kind).toBe("readiness_challenge");
|
||||
expect(received.at(-1)?.data).not.toHaveProperty("challenge");
|
||||
@@ -168,6 +168,25 @@ describe("durable Dot Runner integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["revoked binding", "removed subscription"])("handles an automatic event-test race with a %s", async change => {
|
||||
const f = await fixture();
|
||||
await db.update(dotAgentBindings).set({ readyAt: null, status: "connected" }).where(eq(dotAgentBindings.id, f.snapshot.bindingId));
|
||||
const readBinding = f.broker.bindingForAgent.bind(f.broker);
|
||||
const read = vi.spyOn(f.broker, "bindingForAgent").mockImplementationOnce(async (companyId, agentId) => {
|
||||
const state = await readBinding(companyId, agentId);
|
||||
if (change === "revoked binding") await db.update(dotAgentBindings).set({ revokedAt: new Date() }).where(eq(dotAgentBindings.id, f.snapshot.bindingId));
|
||||
else await db.update(mcpEventSubscriptions).set({ stoppedAt: new Date() }).where(eq(mcpEventSubscriptions.bindingId, f.snapshot.bindingId));
|
||||
return state;
|
||||
});
|
||||
try {
|
||||
await expect(f.broker.challenge(f.company.id, f.agent.id, { automatic: true, bindingId: f.snapshot.bindingId })).resolves.toEqual({ status: "unavailable" });
|
||||
await expect(f.broker.challenge(f.company.id, f.agent.id)).rejects.toThrow("Connect and subscribe");
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
await f.events.unsubscribe(f.principal, f.subscription); await f.events.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("admits one durable idle intake without requiring a preassigned task", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(companies).set({ defaultResponsibleUserId: f.userId }).where(eq(companies.id, f.company.id));
|
||||
|
||||
@@ -21,6 +21,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"agents.ts": "/api",
|
||||
"agent-commentary.ts": "/api",
|
||||
"agent-avatars.ts": "/api",
|
||||
"agent-profile-avatar.ts": "/api",
|
||||
"announcements.ts": "/api",
|
||||
"ai-connections.ts": "/api",
|
||||
"decision-models.ts": "/api",
|
||||
@@ -229,10 +230,13 @@ function loadActualRoutes() {
|
||||
}
|
||||
}
|
||||
if (file === "dot-runner.ts") {
|
||||
const basePath = /const path = "([^"]+)"/.exec(source)?.[1];
|
||||
if (!basePath) throw new Error("Dot binding route prefix is missing");
|
||||
for (const match of source.matchAll(/router\.(get|post|delete)\(path(?: \+ "([^"]+)")?/g)) {
|
||||
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
|
||||
for (const name of ["path", "invitePath"]) {
|
||||
const basePath = new RegExp(`const ${name} = "([^"]+)"`).exec(source)?.[1];
|
||||
if (!basePath) throw new Error(`Dot ${name} route prefix is missing`);
|
||||
const methods = new RegExp(`router\\.(get|post|delete)\\(${name}(?: \\+ "([^"]+)")?`, "g");
|
||||
for (const match of source.matchAll(methods)) {
|
||||
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +862,19 @@ describe("openapi routes", () => {
|
||||
expect(path.put.responses["422"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("documents operator Dot invitations and current pairing and event-test fields", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const invitations = spec.paths["/api/companies/{companyId}/dot-invitations"];
|
||||
for (const operation of [invitations.get, invitations.post]) {
|
||||
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
|
||||
expect(operation.responses["200"]).toBeDefined();
|
||||
}
|
||||
expect(invitations.post.requestBody.content["application/json"].schema.additionalProperties).toBe(false);
|
||||
const base = "/api/companies/{companyId}/agents/{agentId}/dot-binding";
|
||||
expect(spec.paths[base].post.requestBody.content["application/json"].schema.properties.replaceBindingId).toMatchObject({ type: "string", format: "uuid" });
|
||||
expect(spec.paths[base + "/event-test"].post.requestBody.content["application/json"].schema.properties.bindingId).toMatchObject({ type: "string", format: "uuid" });
|
||||
});
|
||||
|
||||
it("documents board-only repository discovery and selection", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get;
|
||||
|
||||
@@ -479,6 +479,15 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
await expect(oauth.authenticate(next.access_token)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([new Date(0), null])("rejects personal refresh tokens with invalid expiry %s", async expiresAt => {
|
||||
const f = await fixture();
|
||||
const [stored] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(f.tokens.refresh_token!)));
|
||||
expect(stored!.expiresAt!.getTime() - Date.now()).toBeGreaterThan(29 * 24 * 60 * 60_000);
|
||||
await db.update(mcpOauthTokens).set({ expiresAt }).where(eq(mcpOauthTokens.id, stored!.id));
|
||||
await expect(oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id,
|
||||
resource: config.resource, refresh_token: f.tokens.refresh_token })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects token expiry, revocation and membership loss on the next call", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(mcpOauthTokens).set({ expiresAt: new Date(0) }).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(f.tokens.access_token)));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { browserUseService } from "./services/browser-use.js";
|
||||
import { slackToolRoutes } from "./routes/slack-tools.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig } from "./services/public-mcp/oauth.js";
|
||||
import { createDotRunnerMcpTools } from "./services/dot-runner-broker.js";
|
||||
import { agentProfileAvatarRoutes } from "./routes/agent-profile-avatar.js";
|
||||
import { dotRunnerRoutes } from "./routes/dot-runner.js";
|
||||
import { createPublicMcpTransfers } from "./services/public-mcp/file-transfers.js";
|
||||
import { createMcpApiDispatch, createPublicMcpExecutor } from "./services/public-mcp/capabilities.js";
|
||||
@@ -781,6 +782,7 @@ export async function createApp(
|
||||
}),
|
||||
);
|
||||
api.use(assetRoutes(db, opts.storageService));
|
||||
api.use(agentProfileAvatarRoutes(db, opts.storageService));
|
||||
api.use(projectToolRoutes(db));
|
||||
api.use(projectRoutes(db));
|
||||
api.use(caseRoutes(db, opts.storageService));
|
||||
|
||||
@@ -89,6 +89,8 @@ export function isSecretSensitiveHttpRequest(
|
||||
if (isPrivateWebhookHttpRequest(method, url)) return true;
|
||||
if (url && normalizePath(url).startsWith("/mcp/files/")) return true;
|
||||
if (isPrivateAgentCommentaryHttpRequest(url)) return true;
|
||||
// Avatar uploads and dedicated Dot MCP calls may contain private image bytes.
|
||||
if (url && /^(?:\/mcp\/runner(?:\/|$)|\/api\/companies\/[^/]+\/agents\/[^/]+\/avatar(?:\/|$))/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (!method || !url) return false;
|
||||
if (/^\/api\/chat-slack\/oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (/^\/api\/chat-endpoints\/[^/]+\/slack(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { setAgentAvatarSchema } from "@paperclipai/shared";
|
||||
import { z } from "zod";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { setAgentProfileAvatar } from "../services/agent-profile-avatar.js";
|
||||
|
||||
export function agentProfileAvatarRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
router.put("/companies/:companyId/agents/:agentId/avatar", async (req, res) => {
|
||||
const companyId = z.uuid().parse(req.params.companyId);
|
||||
const agentId = z.uuid().parse(req.params.agentId);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "agent") {
|
||||
if (req.actor.keyScope?.kind === "task_bridge" || req.actor.keyScope?.kind === "skill_test") {
|
||||
throw forbidden("Task-scoped credentials cannot update agent profiles");
|
||||
}
|
||||
if (req.actor.agentId !== agentId) throw forbidden("Agents can update only their own avatar");
|
||||
} else if (req.actor.type !== "board" || req.actor.memberships?.find(m => m.companyId === companyId)?.membershipRole === "viewer") {
|
||||
throw forbidden("Operator or agent access required");
|
||||
}
|
||||
if (req.actor.type === "board") {
|
||||
const decision = await accessService(db).decide({
|
||||
actor: req.actor, action: "agent_config:update",
|
||||
resource: { type: "agent", companyId, agentId },
|
||||
});
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
res.json(await setAgentProfileAvatar(db, storage, companyId, agentId, setAgentAvatarSchema.parse(req.body), actor));
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -4,34 +4,61 @@ import type { Db } from "@paperclipai/db";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { forbidden, HttpError } from "../errors.js";
|
||||
import { publicMcpConfig, McpOAuthError } from "../services/public-mcp/oauth.js";
|
||||
import { dotInvitationService } from "../services/dot-invitations.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { notFound } from "../errors.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
|
||||
export function dotRunnerRoutes(db: Db, resourceUrl?: string) {
|
||||
const router = Router();
|
||||
const broker = dotRunnerBroker(db);
|
||||
const path = "/companies/:companyId/agents/:agentId/dot-binding";
|
||||
function scope(req: Request, write = false) {
|
||||
function companyScope(req: Request, write = false) {
|
||||
const companyId = z.uuid().parse(req.params.companyId);
|
||||
const agentId = z.uuid().parse(req.params.agentId);
|
||||
assertBoard(req); assertCompanyAccess(req, companyId);
|
||||
if (write && (!req.actor.userId || !["session", "cloud_tenant"].includes(req.actor.source ?? "")
|
||||
|| req.actor.memberships?.find(m => m.companyId === companyId)?.membershipRole === "viewer")) {
|
||||
throw forbidden("Sign in as an operator to connect a Dot agent.");
|
||||
}
|
||||
return { companyId, agentId, operatorId: req.actor.userId! };
|
||||
return { companyId, operatorId: req.actor.userId! };
|
||||
}
|
||||
function scope(req: Request, write = false) {
|
||||
return { ...companyScope(req, write), agentId: z.uuid().parse(req.params.agentId) };
|
||||
}
|
||||
const invitations = dotInvitationService(db);
|
||||
const invitePath = "/companies/:companyId/dot-invitations";
|
||||
async function inviteScope(req: Request) {
|
||||
const input = companyScope(req, true);
|
||||
const decision = await accessService(db).decide({ actor: req.actor, action: "agents:create", resource: { type: "company", companyId: input.companyId } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
return input;
|
||||
}
|
||||
router.get(invitePath, async (req, res) => {
|
||||
const { companyId, operatorId } = await inviteScope(req);
|
||||
res.set("Cache-Control", "no-store").json(await invitations.resume(companyId, operatorId));
|
||||
});
|
||||
router.post(invitePath, async (req, res) => {
|
||||
const { companyId, operatorId } = await inviteScope(req);
|
||||
z.object({}).strict().parse(req.body ?? {});
|
||||
res.set("Cache-Control", "no-store").json(await invitations.create(companyId, operatorId));
|
||||
});
|
||||
router.get(path, async (req, res) => {
|
||||
const input = scope(req);
|
||||
const agent = await agentService(db).getById(input.agentId);
|
||||
if (!agent || agent.companyId !== input.companyId) throw notFound("Agent not found");
|
||||
const config = resourceUrl ? null : publicMcpConfig(process.env);
|
||||
res.json({ enabled: await broker.enabled(), resourceUrl: resourceUrl ?? (config ? config.origin + "/mcp/runner" : null), binding: await broker.bindingForAgent(input.companyId, input.agentId) });
|
||||
res.json({ agentStatus: agent.status, enabled: await broker.enabled(), resourceUrl: resourceUrl ?? (config ? config.origin + "/mcp/runner" : null), binding: await broker.bindingForAgent(input.companyId, input.agentId) });
|
||||
});
|
||||
router.post(path, async (req, res) => {
|
||||
const input = scope(req, true);
|
||||
const body = z.object({ dotUrl: z.string().max(2048).optional() }).strict().parse(req.body ?? {});
|
||||
res.status(201).json(await broker.createPairing({ ...input, ...body }));
|
||||
const body = z.object({ dotUrl: z.string().max(2048).optional(), replaceBindingId: z.uuid().optional() }).strict().parse(req.body ?? {});
|
||||
res.set("Cache-Control", "no-store").status(201).json(await broker.createPairing({ ...input, ...body }));
|
||||
});
|
||||
router.post(path + "/event-test", async (req, res) => {
|
||||
const input = scope(req, true); res.json(await broker.challenge(input.companyId, input.agentId));
|
||||
const input = scope(req, true);
|
||||
const body = z.object({ bindingId: z.uuid().optional() }).strict().parse(req.body ?? {});
|
||||
res.json(await broker.challenge(input.companyId, input.agentId, { bindingId: body.bindingId }));
|
||||
});
|
||||
router.delete(path, async (req, res) => {
|
||||
const input = scope(req, true); await broker.revoke(input.companyId, input.agentId, input.operatorId); res.status(204).end();
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
AGENT_AVATAR_SIZES,
|
||||
CHARACTER_STATES,
|
||||
agentAppearanceSchema,
|
||||
setAgentAvatarSchema,
|
||||
createAgentSchema,
|
||||
createAgentHireSchema,
|
||||
updateAgentSchema,
|
||||
@@ -1714,6 +1715,7 @@ function resolveOperationAuthLevel(
|
||||
|| key === "POST /api/mcp/requests/{id}/dot-pairing/preview") return "public";
|
||||
if (path === "/api/mcp/setup" || path === "/api/mcp/device/consent" || path.startsWith("/api/mcp/requests/") || path.startsWith("/api/mcp/connections")) return "board";
|
||||
if (/^\/api\/companies\/\{companyId\}\/agents\/\{agentId\}\/dot-binding(?:\/event-test)?$/.test(path)) return "board";
|
||||
if (path === "/api/companies/{companyId}/dot-invitations") return "board";
|
||||
if (PUBLIC_OPERATIONS.has(key)) return "public";
|
||||
if (key === "POST /api/companies/{companyId}/agent-commentary") return "agent_heartbeat";
|
||||
if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run";
|
||||
@@ -2978,6 +2980,26 @@ for (const route of [
|
||||
// ─── Agents ──────────────────────────────────────────────────────────────────
|
||||
|
||||
registry.register("AgentAppearance", agentAppearanceSchema);
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/api/companies/{companyId}/agents/{agentId}/avatar",
|
||||
tags: ["agents"],
|
||||
summary: "Upload or reset an agent profile avatar",
|
||||
description: "Board users require agent_config:update permission to change company agent avatars. Agents can update only their own avatar. Task bridge and skill test credentials cannot change profiles. Upload a base64 raster image, or null to restore the preset portrait. Stored avatars use company-scoped private assets.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string().uuid(), agentId: z.string().uuid() }),
|
||||
body: jsonBody(setAgentAvatarSchema),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(z.object({ agentId: z.string().uuid(), appearance: agentAppearanceSchema, avatarUrl: z.string() })),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/agent-avatars/{version}/{palette}/{file}",
|
||||
@@ -11733,6 +11755,27 @@ registerCurrentRoute({
|
||||
},
|
||||
});
|
||||
|
||||
const dotInvitationResponseSchema = z.object({
|
||||
agent: z.object({ id: z.string().uuid(), name: z.string(), status: z.string() }),
|
||||
approvalId: z.string().uuid().nullable(),
|
||||
binding: z.object({
|
||||
id: z.string().uuid(), status: z.string(), connected: z.boolean(),
|
||||
subscriptionVerified: z.boolean(), hasPendingChallenge: z.boolean(),
|
||||
pairingExpiresAt: z.string().datetime().nullable(), challengeExpiresAt: z.string().datetime().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/companies/{companyId}/dot-invitations", tags: ["agents"],
|
||||
summary: "Resume the signed-in operator's unfinished Dot invitation",
|
||||
responses: { 200: r.ok(dotInvitationResponseSchema.nullable()), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/dot-invitations", tags: ["agents"],
|
||||
summary: "Create or resume a Dot invitation with company hire approval gates",
|
||||
body: z.object({}).strict(),
|
||||
responses: { 200: r.ok(dotInvitationResponseSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding", tags: ["agents"],
|
||||
summary: "Read the experimental Dot agent connection and event readiness",
|
||||
@@ -11741,13 +11784,14 @@ registerCurrentRoute({
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding", tags: ["agents"],
|
||||
summary: "Create a one-use Dot pairing code as a company operator",
|
||||
body: z.object({ dotUrl: z.string().max(2048).optional() }).strict(),
|
||||
body: z.object({ dotUrl: z.string().max(2048).optional(), replaceBindingId: z.string().uuid().optional() }).strict(),
|
||||
responses: { 201: r.ok(z.object({ bindingId: z.string().uuid(), pairingCode: z.string(), expiresAt: z.string().datetime(), instructions: z.string() })),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding/event-test", tags: ["agents"],
|
||||
summary: "Request a harmless Dot readiness challenge as a company operator",
|
||||
body: z.object({ bindingId: z.string().uuid().optional() }).strict(),
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
import { agents, assets, companies, type Db } from "@paperclipai/db";
|
||||
import { MAX_AGENT_AVATAR_BYTES, resolveAgentAppearance, agentAvatarUrl, setAgentAvatarSchema, type SetAgentAvatarInput } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication, type LogActivityInput } from "./activity-log.js";
|
||||
|
||||
/** Decode and re-encode: exclude SVG, animation, metadata and unbounded pixel counts. No URL fetching. */
|
||||
export async function normalizeAgentAvatar(input: string) {
|
||||
const bytes = Buffer.from(input, "base64");
|
||||
if (!bytes.length || bytes.length > MAX_AGENT_AVATAR_BYTES || bytes.toString("base64") !== input) {
|
||||
throw unprocessable("Avatar must contain at most 512 KiB of base64-encoded image bytes");
|
||||
}
|
||||
const rasterSignature = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||
|| (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
|
||||
|| (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP");
|
||||
if (!rasterSignature) throw unprocessable("Use a PNG, JPEG, or WebP image");
|
||||
try {
|
||||
const image = sharp(bytes, { limitInputPixels: 16_777_216, failOn: "warning" });
|
||||
const metadata = await image.metadata();
|
||||
if (!["png", "jpeg", "webp"].includes(metadata.format ?? "") || (metadata.pages ?? 1) !== 1) throw new Error("Unsupported image");
|
||||
return await image.rotate().resize(512, 512, { fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
||||
} catch {
|
||||
throw unprocessable("Use a valid static PNG, JPEG, or WebP image with at most 16 megapixels");
|
||||
}
|
||||
}
|
||||
|
||||
type AvatarActor = Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId" | "agentApiKeyId">;
|
||||
export async function setAgentProfileAvatar(db: Db, storage: StorageService, companyId: string, agentId: string, input: SetAgentAvatarInput, actor: AvatarActor) {
|
||||
const parsed = setAgentAvatarSchema.parse(input);
|
||||
// Authorization belongs to the REST/MCP boundary. Validate scope and eligibility before decoding.
|
||||
const [target] = await db.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)));
|
||||
if (!target) throw notFound("Agent not found");
|
||||
if (["terminated", "pending_approval"].includes(target.status)) throw conflict("Agent avatar cannot be updated in its current state");
|
||||
const png = parsed.imageBase64 === null ? null : await normalizeAgentAvatar(parsed.imageBase64);
|
||||
const digest = png ? createHash("sha256").update(png).digest("hex") : null;
|
||||
let newObjectKey: string | undefined;
|
||||
const publications: ActivityPublication[] = [];
|
||||
try {
|
||||
const result = await db.transaction(async tx => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).for("update");
|
||||
const [company] = await tx.select().from(companies).where(eq(companies.id, companyId));
|
||||
if (!agent || company?.status !== "active" || ["terminated", "pending_approval"].includes(agent.status)) throw conflict("Agent avatar cannot be updated in its current state");
|
||||
const appearance = resolveAgentAppearance(agent.appearance, agent.id);
|
||||
const current = appearance.customAvatarAssetId
|
||||
? (await tx.select().from(assets).where(and(eq(assets.id, appearance.customAvatarAssetId), eq(assets.companyId, companyId))))[0]
|
||||
: null;
|
||||
if ((png && current?.sha256 === digest) || (!png && !appearance.customAvatarAssetId)) {
|
||||
return { agentId, appearance, avatarUrl: agentAvatarUrl(appearance) };
|
||||
}
|
||||
delete appearance.customAvatarAssetId;
|
||||
if (png) {
|
||||
const stored = await storage.putFile({ companyId, namespace: `agent-avatars/${agentId}`, originalFilename: "avatar.png", contentType: "image/png", body: png });
|
||||
newObjectKey = stored.objectKey;
|
||||
const [asset] = await tx.insert(assets).values({ ...stored, companyId, createdByAgentId: agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null }).returning();
|
||||
appearance.customAvatarAssetId = asset!.id;
|
||||
}
|
||||
await agentService(txDb).update(agentId, { appearance }, { recordRevision: {
|
||||
createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "avatar-upload",
|
||||
} });
|
||||
await logActivity(txDb, { ...actor, companyId, action: "agent.avatar_updated", entityType: "agent", entityId: agentId,
|
||||
details: { assetId: appearance.customAvatarAssetId ?? null, byteSize: png?.length ?? 0 } }, publications);
|
||||
return { agentId, appearance, avatarUrl: agentAvatarUrl(appearance) };
|
||||
});
|
||||
publications.forEach(publishActivity);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (newObjectKey) await storage.deleteObject(companyId, newObjectKey).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
assets,
|
||||
toolConnectionInstalls,
|
||||
agentConfigRevisions,
|
||||
agentApiKeys,
|
||||
@@ -755,6 +756,15 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
assertBuiltInAgentMetadataMutationAllowed(existing.metadata, data.metadata, options);
|
||||
}
|
||||
|
||||
if (data.appearance?.customAvatarAssetId) {
|
||||
const [asset] = await db.select().from(assets).where(and(
|
||||
eq(assets.id, data.appearance.customAvatarAssetId), eq(assets.companyId, existing.companyId),
|
||||
eq(assets.createdByAgentId, id),
|
||||
));
|
||||
if (!asset || asset.contentType !== "image/png" || !asset.objectKey.startsWith(`${existing.companyId}/agent-avatars/${id}/`)) {
|
||||
throw unprocessable("Use the avatar upload endpoint to set this agent's image");
|
||||
}
|
||||
}
|
||||
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
||||
if (data.permissions !== undefined) {
|
||||
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions);
|
||||
@@ -913,6 +923,7 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
getById,
|
||||
|
||||
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">, options?: CreateAgentOptions) => {
|
||||
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);
|
||||
|
||||
@@ -218,7 +218,9 @@ export function slackChatRegistrationService(db: Db, options: {
|
||||
// Cloud ingress requires a tenant session. Send the preset PNG bytes so
|
||||
// Slack does not need to fetch an authenticated Paperclip URL.
|
||||
const render = options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render;
|
||||
const png = await render({ appearance: resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId),
|
||||
const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) };
|
||||
delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets.
|
||||
const png = await render({ appearance,
|
||||
size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" });
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
|
||||
@@ -3083,6 +3083,16 @@ function readAgentSkillRefs(frontmatter: Record<string, unknown>) {
|
||||
));
|
||||
}
|
||||
|
||||
/** Uploaded avatars belong to one company/agent; portable packages retain the preset fallback. */
|
||||
function portableAgentAppearance(value: unknown, warnings: string[], slug: string) {
|
||||
const appearance = agentAppearanceSchema.parse(value);
|
||||
if (appearance.customAvatarAssetId) {
|
||||
delete appearance.customAvatarAssetId;
|
||||
warnings.push(`Agent ${slug} uploaded avatar was omitted because image assets are instance-local. Upload it again after import.`);
|
||||
}
|
||||
return appearance;
|
||||
}
|
||||
|
||||
function buildManifestFromPackageFiles(
|
||||
files: Record<string, CompanyPortabilityFileEntry>,
|
||||
opts?: { sourceLabel?: { companyId: string; companyName: string } | null },
|
||||
@@ -3241,7 +3251,7 @@ function buildManifestFromPackageFiles(
|
||||
role: asString(extension.role) ?? asString(frontmatter.role) ?? "agent",
|
||||
title,
|
||||
icon: asString(extension.icon),
|
||||
appearance: extension.appearance == null ? undefined : agentAppearanceSchema.parse(extension.appearance),
|
||||
appearance: extension.appearance == null ? undefined : portableAgentAppearance(extension.appearance, warnings, slug),
|
||||
capabilities: asString(extension.capabilities),
|
||||
reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo),
|
||||
reportsToExistingAgentId: asString(extension.reportsToExistingAgentId),
|
||||
@@ -4262,7 +4272,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const extension = stripEmptyValues({
|
||||
role: agent.role !== "agent" ? agent.role : undefined,
|
||||
icon: agent.icon ?? null,
|
||||
appearance: agent.appearance,
|
||||
appearance: agent.appearance == null ? undefined : portableAgentAppearance(agent.appearance, warnings, slug),
|
||||
capabilities: agent.capabilities ?? null,
|
||||
adapter: {
|
||||
type: agent.adapterType,
|
||||
@@ -5609,7 +5619,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
role: manifestAgent.role,
|
||||
title: manifestAgent.title,
|
||||
icon: manifestAgent.icon,
|
||||
...(manifestAgent.appearance ? { appearance: manifestAgent.appearance } : {}),
|
||||
...(manifestAgent.appearance ? { appearance: portableAgentAppearance(manifestAgent.appearance, warnings, manifestAgent.slug) } : {}),
|
||||
capabilities: manifestAgent.capabilities,
|
||||
reportsTo: null,
|
||||
adapterType: normalizedAdapter.adapterType,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, asc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { setAgentAvatarSchema, type SetAgentAvatarInput } from "@paperclipai/shared";
|
||||
import { setAgentProfileAvatar } from "./agent-profile-avatar.js";
|
||||
import { getStorageService } from "../storage/index.js";
|
||||
import { agents, companies, heartbeatRuns, issues, agentWakeupRequests, nativeRunFinalizations, mcpOauthGrants,
|
||||
mcpEventSubscriptions, dotAgentBindings as bindings, dotRunnerAssignments as assignments,
|
||||
dotRunnerOperations as operations, dotMailboxItems as mailbox, type Db } from "@paperclipai/db";
|
||||
@@ -30,7 +33,7 @@ function createBroker(db: Db) {
|
||||
const experimental = await settings.getExperimental();
|
||||
return experimental.enableOpenAiDot && experimental.enablePublicMcp;
|
||||
}
|
||||
const ports = new Map<string, { token: symbol; send: (op: ExternalProviderOperation) => Promise<void>; revoke?: () => Promise<void> }>();
|
||||
const ports = new Map<string, { token: symbol; controllerGeneration: number; send: (op: ExternalProviderOperation) => Promise<void>; revoke?: () => Promise<void> }>();
|
||||
async function principalBinding(principal: McpPrincipal, requireReady = true, allowPaused = false) {
|
||||
if (principal.grant.purpose !== "agent" || !principal.grant.scopes.includes("paperclip:agent")) throw fail("Use the dedicated Dot agent connection.");
|
||||
const [binding] = await db.select().from(bindings).where(and(eq(bindings.grantId, principal.grant.id),
|
||||
@@ -85,6 +88,9 @@ function createBroker(db: Db) {
|
||||
const [a] = await db.select().from(assignments).where(eq(assignments.id, row.assignmentId));
|
||||
const port = a && ports.get(a.runId);
|
||||
if (!port || !["reserved", "admitted"].includes(row.status)) return;
|
||||
const [owner] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, a.runId));
|
||||
if (!owner || owner.controllerGeneration !== port.controllerGeneration || a.controllerGeneration !== port.controllerGeneration
|
||||
|| !owner.leaseExpiresAt || owner.leaseExpiresAt <= new Date()) return;
|
||||
// Sending the same stable command is safe after a lost ACK. The Runner's
|
||||
// command/semantic receipts decide whether it was already admitted.
|
||||
if (!a || a.status === "fenced" || a.expiresAt <= new Date()) return;
|
||||
@@ -103,22 +109,31 @@ function createBroker(db: Db) {
|
||||
|
||||
return {
|
||||
enabled,
|
||||
async createPairing(input: { companyId: string; agentId: string; operatorId: string; dotUrl?: string }) {
|
||||
async createPairing(input: { companyId: string; agentId: string; operatorId: string; dotUrl?: string; replaceBindingId?: string }) {
|
||||
if (!await enabled()) throw fail("Enable OpenAI Dot and Assistant connections (MCP) in experimental settings.");
|
||||
if (input.dotUrl && !/^https:\/\/chatgpt\.com\/dots\/[A-Za-z0-9-]+$/.test(input.dotUrl)) throw fail("Use the Dot's ChatGPT URL.");
|
||||
const code = randomBytes(24).toString("base64url");
|
||||
const binding = await db.transaction(async tx => {
|
||||
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))).for("update");
|
||||
if (!agent || agent.adapterType !== "paperclip_runner" || agent.status === "pending_approval" || agent.status === "terminated") throw fail("Choose an approved Paperclip Runner agent.");
|
||||
if (!agent || agent.adapterType !== "paperclip_runner" || agent.adapterConfig.provider !== "openai_dot" || ["pending_approval", "terminated", "paused"].includes(agent.status)) throw fail("Choose an approved Paperclip Runner agent.");
|
||||
const [existing] = await tx.select().from(bindings).where(and(eq(bindings.companyId, input.companyId), eq(bindings.agentId, input.agentId), isNull(bindings.revokedAt)));
|
||||
if (existing) throw fail("Revoke the existing connection before creating another pairing code.");
|
||||
const [created] = await tx.insert(bindings).values({ ...input, pairingCodeHash: hash(code), pairingExpiresAt: new Date(Date.now() + 15 * 60_000) }).returning();
|
||||
if (existing) {
|
||||
if (existing.id !== input.replaceBindingId || existing.status !== "pairing" || existing.operatorId !== input.operatorId) {
|
||||
throw fail("This invitation changed. Refresh before creating a new prompt.");
|
||||
}
|
||||
// Consume only the pending binding named by the caller. An OAuth race
|
||||
// cannot revoke a connection that has already been established.
|
||||
const [replaced] = await tx.update(bindings).set({ revokedAt: new Date(), status: "revoked", pairingCodeHash: null, updatedAt: new Date() })
|
||||
.where(and(eq(bindings.id, existing.id), eq(bindings.status, "pairing"), isNull(bindings.revokedAt))).returning();
|
||||
if (!replaced) throw fail("Dot connected while this prompt was being replaced. Refresh the connection.");
|
||||
} else if (input.replaceBindingId) throw fail("This invitation changed. Refresh before creating a new prompt.");
|
||||
const [created] = await tx.insert(bindings).values({ companyId: input.companyId, agentId: input.agentId, operatorId: input.operatorId, dotUrl: input.dotUrl, pairingCodeHash: hash(code), pairingExpiresAt: new Date(Date.now() + 15 * 60_000) }).returning();
|
||||
await logActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "user", actorId: input.operatorId,
|
||||
action: "dot.pairing_created", entityType: "agent", entityId: input.agentId, details: { bindingId: created!.id } });
|
||||
return created!;
|
||||
});
|
||||
return { bindingId: binding.id, pairingCode: code, expiresAt: binding.pairingExpiresAt,
|
||||
instructions: "Connect the private Paperclip Dot plugin at /mcp/runner, approve agent access, then call paperclip_dot_pair with this code. Subscribe to paperclip.dot.mailbox_updated for the returned binding. Run the event test before assigning work." };
|
||||
instructions: "Connect the private Paperclip Dot plugin at /mcp/runner, approve agent access, then call paperclip_dot_pair with this code. Subscribe to paperclip.dot.mailbox_updated for the returned binding. Paperclip sends the event test automatically before work can be assigned." };
|
||||
},
|
||||
async pair(principal: McpPrincipal, code: string) {
|
||||
if (!await enabled() || principal.grant.purpose !== "agent" || !principal.grant.scopes.includes("paperclip:agent")) throw fail("A dedicated Dot agent grant is required.");
|
||||
@@ -149,7 +164,7 @@ function createBroker(db: Db) {
|
||||
const [active] = await db.select({ id: assignments.id, runId: assignments.runId, status: assignments.status, lastActivityAt: assignments.lastActivityAt }).from(assignments)
|
||||
.where(and(eq(assignments.bindingId, b.id), inArray(assignments.status, ["offered", "accepted"])));
|
||||
return { id: b.id, companyId: b.companyId, agentId: b.agentId, generation: b.generation, status: b.status, dotUrl: b.dotUrl,
|
||||
readyAt: b.readyAt, connected: !!b.grantId, subscriptionVerified: activeSubscriptions.length > 0,
|
||||
readyAt: b.readyAt, pairingExpiresAt: b.pairingExpiresAt, challengeExpiresAt: b.challengeExpiresAt, connected: !!b.grantId, subscriptionVerified: activeSubscriptions.length > 0,
|
||||
hasPendingChallenge: !!b.challengeHash && !!b.challengeExpiresAt && b.challengeExpiresAt > new Date(),
|
||||
assignment: active ? { ...active, attentionRequired: active.status === "accepted" && active.lastActivityAt.getTime() < Date.now() - 15 * 60_000 } : null };
|
||||
},
|
||||
@@ -228,6 +243,12 @@ function createBroker(db: Db) {
|
||||
return db.select({ id: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status }).from(issues)
|
||||
.where(and(eq(issues.companyId, b.companyId), eq(issues.assigneeAgentId, b.agentId), inArray(issues.status, ["todo", "in_progress", "blocked"]), after ? gt(issues.id, after) : undefined)).orderBy(asc(issues.id)).limit(50);
|
||||
},
|
||||
async setAvatar(principal: McpPrincipal, input: SetAgentAvatarInput) {
|
||||
if (!await enabled()) throw fail("Dot is disabled in experimental settings.");
|
||||
const b = await principalBinding(principal, false);
|
||||
return setAgentProfileAvatar(db, getStorageService(), b.companyId, b.agentId, input,
|
||||
{ actorType: "agent", actorId: b.agentId, agentId: b.agentId });
|
||||
},
|
||||
async capabilities(principal: McpPrincipal) {
|
||||
const b = await principalBinding(principal, false);
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, b.agentId));
|
||||
@@ -238,6 +259,7 @@ function createBroker(db: Db) {
|
||||
permissions: agent!.permissions, ready: state?.status === "ready" && state.subscriptionVerified,
|
||||
assignment: state?.assignment ?? null,
|
||||
idle: { read: ["paperclip_dot_capabilities", "paperclip_dot_tasks", "paperclip_dot_inbox"],
|
||||
profile: "paperclip_dot_set_avatar",
|
||||
start: "paperclip_dot_request_turn", instruction: "You can start work without an existing task. Call paperclip_dot_request_turn with the user's request and a stable UUID. Drain the inbox, read and accept the assignment, then use its full catalog through paperclip_dot_tool. Task tools run as this agent under normal permissions, never as the owner." },
|
||||
runtime: { skills: "pinned_read", mcp: "assigned_gateway", taskAttachments: agent!.adapterConfig?.dotAttachmentAccess === true ? "assigned_task_read" : "disabled",
|
||||
attachmentPrerequisite: "Enable task attachment reading on this Dot agent to send verified contents of its current assigned task files to OpenAI. This does not enable workspace commands.", workspace: agent!.adapterConfig?.dotWorkspaceAccess === true ? "sandboxed_tool_bridge" : "disabled",
|
||||
@@ -355,19 +377,35 @@ function createBroker(db: Db) {
|
||||
return outcome;
|
||||
});
|
||||
},
|
||||
async challenge(companyId: string, agentId: string) {
|
||||
async challenge(companyId: string, agentId: string, options: { automatic?: boolean; bindingId?: string } = {}) {
|
||||
const state = await this.bindingForAgent(companyId, agentId);
|
||||
if (!await enabled() || !state || !state.subscriptionVerified) throw fail("Connect and subscribe the Dot first.");
|
||||
if (!await enabled() || !state || !state.subscriptionVerified || (options.bindingId && options.bindingId !== state.id)) {
|
||||
if (options.automatic) return { status: "unavailable" };
|
||||
throw fail("Connect and subscribe the Dot first.");
|
||||
}
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
await db.transaction(async tx => {
|
||||
const available = await db.transaction(async tx => {
|
||||
const [b] = await tx.select().from(bindings).where(eq(bindings.id, state.id)).for("update");
|
||||
if (!b || b.revokedAt || b.challengeHash && b.challengeExpiresAt && b.challengeExpiresAt > new Date()) throw fail("An event test is already pending.");
|
||||
if (!b || b.revokedAt || b.generation !== state.generation) {
|
||||
if (options.automatic) return false;
|
||||
throw fail("Binding authority changed.");
|
||||
}
|
||||
if (options.automatic && (b.challengeHash || b.readyAt)) return;
|
||||
if (b.challengeHash && b.challengeExpiresAt && b.challengeExpiresAt > new Date()) return;
|
||||
const [subscription] = await tx.select({ id: mcpEventSubscriptions.id }).from(mcpEventSubscriptions).where(and(
|
||||
eq(mcpEventSubscriptions.bindingId, b.id), eq(mcpEventSubscriptions.grantId, b.grantId!),
|
||||
isNull(mcpEventSubscriptions.stoppedAt), gt(mcpEventSubscriptions.expiresAt, new Date())));
|
||||
if (!subscription) {
|
||||
if (options.automatic) return false;
|
||||
throw fail("The event subscription is no longer verified.");
|
||||
}
|
||||
await tx.update(bindings).set({ challengeHash: hash(nonce), challengeExpiresAt: new Date(Date.now() + 10 * 60_000), updatedAt: new Date() }).where(eq(bindings.id, b.id));
|
||||
await tx.insert(mailbox).values({ companyId, bindingId: b.id, bindingGeneration: b.generation,
|
||||
kind: "readiness_challenge", sourceEventId: "challenge_" + randomUUID(), references: { challenge: nonce } });
|
||||
await logActivity(tx as unknown as Db, { companyId, actorType: "user", actorId: b.operatorId,
|
||||
action: "dot.event_test_requested", entityType: "agent", entityId: agentId, details: { bindingId: b.id } });
|
||||
});
|
||||
if (available === false) return { status: "unavailable" };
|
||||
return { status: "pending", message: "Waiting for the Dot to receive the event, read its mailbox and complete the harmless challenge." };
|
||||
},
|
||||
async confirmChallenge(principal: McpPrincipal, nonce: string) {
|
||||
@@ -375,7 +413,7 @@ function createBroker(db: Db) {
|
||||
const connection = await this.bindingForAgent(b.companyId, b.agentId);
|
||||
if (!connection?.subscriptionVerified) throw fail("The event subscription is no longer verified.");
|
||||
const [updated] = await db.update(bindings).set({ status: "ready", readyAt: new Date(), challengeHash: null, challengeExpiresAt: null, updatedAt: new Date() })
|
||||
.where(and(eq(bindings.id, b.id), eq(bindings.generation, b.generation), eq(bindings.challengeHash, hash(nonce)), gt(bindings.challengeExpiresAt, new Date()))).returning();
|
||||
.where(and(eq(bindings.id, b.id), eq(bindings.generation, b.generation), isNull(bindings.revokedAt), eq(bindings.grantId, principal.grant.id), eq(bindings.challengeHash, hash(nonce)), gt(bindings.challengeExpiresAt, new Date()))).returning();
|
||||
if (!updated) throw fail("Event test expired or does not match.");
|
||||
await logActivity(db, { companyId: b.companyId, actorType: "agent", actorId: b.agentId,
|
||||
action: "dot.event_test_completed", entityType: "agent", entityId: b.agentId, details: { bindingId: b.id } });
|
||||
@@ -422,14 +460,34 @@ function createBroker(db: Db) {
|
||||
if (existing && hasProviderCheckpoint !== true) throw fail("Dot bridge checkpoint missing; reconcile external work before continuing.");
|
||||
const token = Symbol(runId);
|
||||
if (ports.has(runId)) throw fail("A Dot run controller is already attached.");
|
||||
ports.set(runId, { token, send, revoke });
|
||||
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId));
|
||||
if (!coordinator) { ports.delete(runId); throw fail("Run controller ownership is unavailable."); }
|
||||
await db.update(assignments).set({ controllerGeneration: coordinator.controllerGeneration }).where(and(eq(assignments.runId, runId), eq(assignments.bindingGeneration, execution.provider.binding.bindingGeneration)));
|
||||
const rows = await db.select({ operation: operations }).from(operations).innerJoin(assignments, eq(assignments.id, operations.assignmentId))
|
||||
.where(and(eq(assignments.runId, runId), inArray(operations.status, ["reserved", "admitted"]))).orderBy(asc(operations.createdAt));
|
||||
for (const { operation } of rows) void forward(operation).catch(() => {});
|
||||
return async () => { if (ports.get(runId)?.token === token) ports.delete(runId); };
|
||||
if (!coordinator?.leaseExpiresAt || coordinator.leaseExpiresAt <= new Date()) throw fail("Run controller ownership is unavailable.");
|
||||
// Reserve synchronously after the ownership read; another attachment
|
||||
// may have completed while that read was in flight.
|
||||
if (ports.has(runId)) throw fail("A Dot run controller is already attached.");
|
||||
ports.set(runId, { token, send, revoke, controllerGeneration: coordinator.controllerGeneration });
|
||||
try {
|
||||
await db.update(assignments).set({ controllerGeneration: coordinator.controllerGeneration }).where(and(eq(assignments.runId, runId),
|
||||
eq(assignments.bindingGeneration, execution.provider.binding.bindingGeneration), sql`${assignments.controllerGeneration} <= ${coordinator.controllerGeneration}`));
|
||||
} catch (error) { if (ports.get(runId)?.token === token) ports.delete(runId); throw error; }
|
||||
// Any HTTP replica can reserve an operation. Only this run's current
|
||||
// controller drains it. No Runner/sandbox is needed for an idle binding.
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const drain = async () => {
|
||||
try {
|
||||
if (ports.get(runId)?.token !== token) return;
|
||||
const rows = await db.select({ operation: operations }).from(operations).innerJoin(assignments, eq(assignments.id, operations.assignmentId))
|
||||
.where(and(eq(assignments.runId, runId), inArray(operations.status, ["reserved", "admitted"]))).orderBy(asc(operations.createdAt)).limit(32);
|
||||
for (const { operation } of rows) {
|
||||
if (ports.get(runId)?.token !== token) break;
|
||||
await forward(operation).catch(() => {}); // durable rows remain retryable
|
||||
}
|
||||
} finally {
|
||||
if (ports.get(runId)?.token === token) { timer = setTimeout(() => { void drain().catch(() => {}); }, 2000); timer.unref(); }
|
||||
}
|
||||
};
|
||||
void drain().catch(() => {});
|
||||
return async () => { if (timer) clearTimeout(timer); if (ports.get(runId)?.token === token) ports.delete(runId); };
|
||||
},
|
||||
dispatch: async event => {
|
||||
const p = event.payload;
|
||||
@@ -499,6 +557,7 @@ export function createDotRunnerMcpTools(db: Db): PublicMcpToolExtension {
|
||||
const broker = dotRunnerBroker(db);
|
||||
const request = { assignmentId: z.uuid(), requestId: z.uuid() };
|
||||
const definitions = [
|
||||
{ name: "paperclip_dot_set_avatar", description: "Update only your bound Paperclip agent's avatar, including while idle after pairing. If you can obtain your own avatar, send its PNG/JPEG/WebP bytes as raw base64 (max 512 KiB, static image). Do not send a URL or invent an image. Use imageBase64: null to restore the Paperclip character. Repeating the same image is safe. No active assignment is needed.", schema: setAgentAvatarSchema },
|
||||
{ name: "paperclip_dot_capabilities", description: "Discover your agent identity, responsible person, configured permissions, enforcement limits, idle entry point, runtime capabilities, and prerequisites. Use this before concluding you cannot act.", schema: z.object({}).strict() },
|
||||
{ name: "paperclip_dot_request_turn", description: "Start a governed Runner turn for a request from your Dot conversation, even when no task is assigned. Creates one auditable intake task using normal permissions and admission. Retry the exact prompt with the same UUID.", schema: z.object({ prompt: z.string().trim().min(1).max(20_000), requestId: z.uuid() }).strict() },
|
||||
{ name: "paperclip_dot_renew", description: "Renew an accepted assignment before expiry. Choose an expiry no more than two hours ahead or 24 hours after admission. Cannot revive expired, stopped, or revoked work.", schema: z.object({ ...request, expiresAtUnixMs: z.number().int().positive() }).strict() },
|
||||
@@ -523,7 +582,8 @@ export function createDotRunnerMcpTools(db: Db): PublicMcpToolExtension {
|
||||
const definition = definitions.find(d => d.name === name); if (!definition) throw fail("Unknown Dot tool.");
|
||||
const input = definition.schema.parse(raw) as Record<string, unknown>;
|
||||
let result: unknown;
|
||||
if (name === "paperclip_dot_capabilities") result = await broker.capabilities(principal);
|
||||
if (name === "paperclip_dot_set_avatar") result = await broker.setAvatar(principal, setAgentAvatarSchema.parse(input));
|
||||
else if (name === "paperclip_dot_capabilities") result = await broker.capabilities(principal);
|
||||
else if (name === "paperclip_dot_request_turn") result = await broker.requestTurn(principal, String(input.prompt), String(input.requestId));
|
||||
else if (name === "paperclip_dot_tasks") result = await broker.tasks(principal, input.after ? String(input.after) : null);
|
||||
else if (name === "paperclip_dot_request_work") result = await broker.requestWork(principal, String(input.issueId), String(input.requestId));
|
||||
|
||||
@@ -212,7 +212,12 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
|
||||
async function enqueue(s: Subscription) {
|
||||
if (s.bindingId) {
|
||||
if (!options.enableDotRunner) return;
|
||||
const [b] = await db.select().from(dotAgentBindings).where(and(eq(dotAgentBindings.id, s.bindingId), eq(dotAgentBindings.companyId, s.companyId), isNull(dotAgentBindings.revokedAt)));
|
||||
const [binding] = await db.select().from(dotAgentBindings).where(and(eq(dotAgentBindings.id, s.bindingId), eq(dotAgentBindings.companyId, s.companyId), isNull(dotAgentBindings.revokedAt)));
|
||||
if (!binding) return;
|
||||
// SQL idempotency makes this safe across restarts, replicas and callback
|
||||
// renewals. Reload before filtering delivery against the newly issued test.
|
||||
await dotRunnerBroker(db).challenge(binding.companyId, binding.agentId, { automatic: true, bindingId: binding.id });
|
||||
const [b] = await db.select().from(dotAgentBindings).where(and(eq(dotAgentBindings.id, binding.id), isNull(dotAgentBindings.revokedAt)));
|
||||
if (!b) return;
|
||||
// Materialize only references to newly recorded task input. This does not steer
|
||||
// an external provider or create execution authority; the current run reads its history.
|
||||
|
||||
@@ -126,7 +126,8 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
const refresh = grant.scopes.includes("offline_access") ? secret("pcmcp_rt_") : null;
|
||||
await tx.insert(mcpOauthTokens).values([
|
||||
{ grantId: grant.id, tokenHash: hashMcpSecret(access), kind: "access", expiresAt: new Date(Date.now() + accessLifetime) },
|
||||
...(refresh ? [{ grantId: grant.id, tokenHash: hashMcpSecret(refresh), kind: "refresh" as const, expiresAt: new Date(Date.now() + refreshLifetime) }] : []),
|
||||
...(refresh ? [{ grantId: grant.id, tokenHash: hashMcpSecret(refresh), kind: "refresh" as const,
|
||||
expiresAt: agentConnection && grant.purpose === "agent" ? null : new Date(Date.now() + refreshLifetime) }] : []),
|
||||
]);
|
||||
return {
|
||||
access_token: access, token_type: "Bearer", expires_in: accessLifetime / 1000,
|
||||
@@ -440,7 +441,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
if (!access.user || !membership || membership.membershipRole === "viewer" || !company || company.status === "archived") throw invalidGrant();
|
||||
return { company: { id: company.id, name: company.name }, agent: { id: agent.id, name: agent.name },
|
||||
permissions: "Start and accept work as this agent, coordinate permitted tasks and people, read assigned skills, and use assigned app tools. Reading assigned task attachment contents sends those contents to OpenAI and requires the agent’s separate attachment setting. Workspace files and sandboxed commands require the agent’s separate workspace setting. No board account or other-company access.",
|
||||
accessDuration: "Ongoing until revoked. Reconnect after 30 days without refreshing the connection.",
|
||||
accessDuration: "Ongoing until revoked. This connection does not expire from inactivity.",
|
||||
pairingExpiresAt: binding.pairingExpiresAt!.toISOString() };
|
||||
});
|
||||
},
|
||||
@@ -515,7 +516,9 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (token.expiresAt <= new Date()) return null;
|
||||
// Only agent connections support refresh tokens without an inactivity limit.
|
||||
// Access tokens still expire, refresh still rotates, and replay still revokes.
|
||||
if (token.expiresAt ? token.expiresAt <= new Date() : !agentConnection || grant.purpose !== "agent") return null;
|
||||
await actorForGrant(grant, tx as unknown as Db);
|
||||
if (input.scope !== undefined && input.scope !== grant.scopes.join(" ")) {
|
||||
throw new McpOAuthError("invalid_scope", "Refresh cannot change the consented scopes; reconnect instead.");
|
||||
|
||||
Reference in new issue
Block a user