* public-gh/master: (46 commits) chore(lockfile): refresh pnpm-lock.yaml (#1377) fix: manage codex home per company by default Ensure agent home directories exist before use Handle directory entries in imported zip archives Fix portability import and org chart test blockers Fix PR verify failures after merge fix: address greptile follow-up feedback Address remaining Greptile portability feedback docs: clarify quickstart npx usage Add guarded dev restart handling Fix PAP-576 settings toggles and transcript default Add username log censor setting fix: use standard toggle component for permission controls fix: add missing setPrincipalPermission mock in portability tests fix: use fixed 1280x640 dimensions for org chart export image Adjust default CEO onboarding task copy fix: link Agent Company to agentcompanies.io in export README fix: strip agents and projects sections from COMPANY.md export body fix: default company export page to README.md instead of first file Add default agent instructions bundle ... # Conflicts: # packages/adapters/pi-local/src/server/execute.ts # packages/db/src/migrations/meta/0039_snapshot.json # packages/db/src/migrations/meta/_journal.json # server/src/__tests__/agent-permissions-routes.test.ts # server/src/__tests__/agent-skills-routes.test.ts # server/src/services/company-portability.ts # skills/paperclip/references/company-skills.md # ui/src/api/agents.ts
276 lines
8.4 KiB
TypeScript
276 lines
8.4 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { agentRoutes } from "../routes/agents.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
|
|
const agentId = "11111111-1111-4111-8111-111111111111";
|
|
const companyId = "22222222-2222-4222-8222-222222222222";
|
|
|
|
const baseAgent = {
|
|
id: agentId,
|
|
companyId,
|
|
name: "Builder",
|
|
urlKey: "builder",
|
|
role: "engineer",
|
|
title: "Builder",
|
|
icon: null,
|
|
status: "idle",
|
|
reportsTo: null,
|
|
capabilities: null,
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
budgetMonthlyCents: 0,
|
|
spentMonthlyCents: 0,
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
permissions: { canCreateAgents: false },
|
|
lastHeartbeatAt: null,
|
|
metadata: null,
|
|
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
|
};
|
|
|
|
const mockAgentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
create: vi.fn(),
|
|
updatePermissions: vi.fn(),
|
|
getChainOfCommand: vi.fn(),
|
|
resolveByReference: vi.fn(),
|
|
}));
|
|
|
|
const mockAccessService = vi.hoisted(() => ({
|
|
canUser: vi.fn(),
|
|
hasPermission: vi.fn(),
|
|
getMembership: vi.fn(),
|
|
ensureMembership: vi.fn(),
|
|
listPrincipalGrants: vi.fn(),
|
|
setPrincipalPermission: vi.fn(),
|
|
}));
|
|
|
|
const mockApprovalService = vi.hoisted(() => ({
|
|
create: vi.fn(),
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockBudgetService = vi.hoisted(() => ({
|
|
upsertPolicy: vi.fn(),
|
|
}));
|
|
|
|
const mockHeartbeatService = vi.hoisted(() => ({
|
|
listTaskSessions: vi.fn(),
|
|
resetRuntimeSession: vi.fn(),
|
|
}));
|
|
|
|
const mockIssueApprovalService = vi.hoisted(() => ({
|
|
linkManyForApproval: vi.fn(),
|
|
}));
|
|
|
|
const mockIssueService = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
}));
|
|
|
|
const mockSecretService = vi.hoisted(() => ({
|
|
normalizeAdapterConfigForPersistence: vi.fn(),
|
|
resolveAdapterConfigForRuntime: vi.fn(),
|
|
}));
|
|
|
|
const mockAgentInstructionsService = vi.hoisted(() => ({
|
|
materializeManagedBundle: vi.fn(),
|
|
}));
|
|
const mockCompanySkillService = vi.hoisted(() => ({
|
|
listRuntimeSkillEntries: vi.fn(),
|
|
resolveRequestedSkillKeys: vi.fn(),
|
|
}));
|
|
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
agentService: () => mockAgentService,
|
|
agentInstructionsService: () => mockAgentInstructionsService,
|
|
accessService: () => mockAccessService,
|
|
approvalService: () => mockApprovalService,
|
|
companySkillService: () => mockCompanySkillService,
|
|
budgetService: () => mockBudgetService,
|
|
heartbeatService: () => mockHeartbeatService,
|
|
issueApprovalService: () => mockIssueApprovalService,
|
|
issueService: () => mockIssueService,
|
|
logActivity: mockLogActivity,
|
|
secretService: () => mockSecretService,
|
|
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
|
workspaceOperationService: () => mockWorkspaceOperationService,
|
|
}));
|
|
|
|
function createDbStub() {
|
|
return {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
then: vi.fn().mockResolvedValue([{
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
requireBoardApprovalForNewAgents: false,
|
|
}]),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createApp(actor: Record<string, unknown>) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = actor;
|
|
next();
|
|
});
|
|
app.use("/api", agentRoutes(createDbStub() as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
describe("agent permission routes", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockAgentService.getById.mockResolvedValue(baseAgent);
|
|
mockAgentService.getChainOfCommand.mockResolvedValue([]);
|
|
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
|
|
mockAgentService.create.mockResolvedValue(baseAgent);
|
|
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
|
|
mockAccessService.getMembership.mockResolvedValue({
|
|
id: "membership-1",
|
|
companyId,
|
|
principalType: "agent",
|
|
principalId: agentId,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
|
});
|
|
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
|
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
|
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
|
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
|
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
|
|
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
|
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
|
|
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
|
|
bundle: null,
|
|
adapterConfig: {
|
|
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
|
|
instructionsBundleMode: "managed",
|
|
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
|
|
instructionsEntryFile: "AGENTS.md",
|
|
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
|
|
promptTemplate: files["AGENTS.md"] ?? "",
|
|
},
|
|
}),
|
|
);
|
|
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
|
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
|
|
async (_companyId: string, requested: string[]) => requested,
|
|
);
|
|
mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
|
|
mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config }));
|
|
mockLogActivity.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("grants tasks:assign by default when board creates a new agent", async () => {
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "Builder",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockAccessService.ensureMembership).toHaveBeenCalledWith(
|
|
companyId,
|
|
"agent",
|
|
agentId,
|
|
"member",
|
|
"active",
|
|
);
|
|
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
|
companyId,
|
|
"agent",
|
|
agentId,
|
|
"tasks:assign",
|
|
true,
|
|
"board-user",
|
|
);
|
|
});
|
|
|
|
it("exposes explicit task assignment access on agent detail", async () => {
|
|
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
|
{
|
|
id: "grant-1",
|
|
companyId,
|
|
principalType: "agent",
|
|
principalId: agentId,
|
|
permissionKey: "tasks:assign",
|
|
scope: null,
|
|
grantedByUserId: "board-user",
|
|
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await request(app).get(`/api/agents/${agentId}`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.access.canAssignTasks).toBe(true);
|
|
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
|
|
});
|
|
|
|
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
|
|
mockAgentService.updatePermissions.mockResolvedValue({
|
|
...baseAgent,
|
|
permissions: { canCreateAgents: true },
|
|
});
|
|
|
|
const app = createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/agents/${agentId}/permissions`)
|
|
.send({ canCreateAgents: true, canAssignTasks: false });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
|
companyId,
|
|
"agent",
|
|
agentId,
|
|
"tasks:assign",
|
|
true,
|
|
"board-user",
|
|
);
|
|
expect(res.body.access.canAssignTasks).toBe(true);
|
|
expect(res.body.access.taskAssignSource).toBe("agent_creator");
|
|
});
|
|
});
|