mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents need to inspect configuration and instructions when they set up teammates. > - Configuration reads currently depend on configure or suggestion grants. > - Legacy standard agents did not receive those newer grants. Some self-read routes also lose the target agent identity. > - This pull request adds an independent read grant and applies only that grant to eligible existing agents. > - The benefit is that agents can inspect and verify configuration without new write authority. ## Linked Issues or Issue Description Refs #15283. Related earlier proposals: #8064 and #12050. Those proposals target older authorization code; this change uses the current configure/suggest model and adds a read-only rollout for existing agents. Related bounded peer metadata proposal: #14160. **What existing behavior does this improve?** Agent configuration, configured skill, instruction, and revision reads through the existing APIs and native instruction tools. **Current behavior** An agent needs `agents:configure` or `agents:suggest-changes` to inspect a peer. Older standard agents can hire a teammate but cannot verify the saved instructions. Configuration and revision routes also reject self-reads because they authorize a company resource instead of the target agent. **Proposed behavior** Add `agents:read` for redacted configuration and instruction reads. Keep `agents:configure` as read/write and preserve suggestion-grant compatibility. Standard new agents receive the read grant. A one-time migration grants only reads to eligible existing standard agents. Individual routes authorize the target agent and grant scope. **Reason and benefit** Read access can be granted without configuration changes, rollback, or environment probes. Legacy agents can verify teammates without receiving new write permissions. ## What Changed - Add `agents:read` to the shared permission contract and standard-agent defaults. - Accept read, configure, or suggestion grants for configuration reads, with target scope and the responsible-user ceiling. - Fix target-aware self and scoped configuration, skill, and revision reads. - Add an audited data migration. Skip low-trust, built-in, inactive, pending, terminated, setup, and scoped configuration/suggestion cases. Preserve existing read scopes and later revocation. - Update the specifications, default-permission documentation, and agent access map. ## Verification - Focused acceptance: 276 tests passed across seven suites on commit `a37314df88`: authorization, real-key HTTP access, route authorization, canonical instructions, default grants, migration replay, and lifecycle configuration. - The HTTP journey uses a real hashed agent API key and a disposable PostgreSQL database. It verifies redaction, scoped reads, denied edits and rollback, cross-company isolation, and both agent and responsible-user revocation. - New read-grant regressions failed before the authorization change. A realistic paused/pausing/resuming migration fixture also failed before the lifecycle filter correction and passed afterward. Setup-held paused agents stay excluded. - `pnpm -r typecheck` and `pnpm build` passed on the rebased branch; the final lifecycle migration correction also passed the database package typecheck and build. The latest commit changes only a test assertion. - `pnpm --filter @paperclipai/db check:migrations`, `node scripts/check-maps.mjs`, and `git diff --check` passed. - Full current-head [CI run](https://github.com/paperclipai/paperclip/actions/runs/41641531426) passed: typecheck, build, all general and serialized test lanes, Runner checks, all eight browser shards, canary dry run, policy, and aggregate gates. The local full-suite attempt was stopped after exposing the outdated cross-company 403 assertion; interrupted local full-suite attempts are not counted as passes. ## Risks The migration deliberately expands redacted configuration and instruction visibility for eligible existing standard agents in the same company. An absent older write grant does not deny the new read capability. It adds no write grant. Scoped configure/suggestion grants and all existing read-grant scopes stay intact. Administrators can remove the independent read grant after the one-time rollout; ordinary updates and startup do not restore it. Configure and suggestion grants still imply reads within their own scopes. Cross-company configuration/history reads now return 404 through the common target-aware helper. No schema change or credential-value disclosure is added. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, and code execution. The host does not expose a more specific runtime model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2405 lines
86 KiB
TypeScript
2405 lines
86 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
|
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
|
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
|
|
|
|
vi.mock("acpx/runtime", () => ({
|
|
createAcpRuntime: vi.fn(),
|
|
createAgentRegistry: vi.fn(),
|
|
createRuntimeStore: vi.fn(),
|
|
isAcpRuntimeError: vi.fn(() => false),
|
|
}));
|
|
|
|
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(),
|
|
getConfigRevision: vi.fn(),
|
|
listConfigRevisions: vi.fn(),
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
activatePendingApproval: vi.fn(),
|
|
terminate: vi.fn(),
|
|
update: vi.fn(),
|
|
rollbackConfigRevision: vi.fn(),
|
|
updatePermissions: vi.fn(),
|
|
getChainOfCommand: vi.fn(),
|
|
resolveByReference: vi.fn(),
|
|
}));
|
|
|
|
const mockBuiltInAgentService = vi.hoisted(() => ({
|
|
ensureCompanyDefaultAgentGrants: vi.fn(),
|
|
}));
|
|
|
|
const mockAccessService = vi.hoisted(() => ({
|
|
canUser: vi.fn(),
|
|
decide: 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(),
|
|
findOpenHireApprovalForAgent: vi.fn(),
|
|
approveHire: vi.fn(),
|
|
rejectHire: vi.fn(),
|
|
}));
|
|
|
|
const mockBudgetService = vi.hoisted(() => ({
|
|
upsertPolicy: vi.fn(),
|
|
}));
|
|
|
|
const mockHeartbeatService = vi.hoisted(() => ({
|
|
listTaskSessions: vi.fn(),
|
|
resetRuntimeSession: vi.fn(),
|
|
getRun: vi.fn(),
|
|
cancelRun: vi.fn(),
|
|
cancelInvocationsForAgents: 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());
|
|
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
|
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
|
const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn());
|
|
const mockEnvironmentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
listBoundCompanyIds: vi.fn(),
|
|
}));
|
|
|
|
const mockInstanceSettingsService = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
getGeneral: vi.fn(),
|
|
getExperimental: vi.fn(),
|
|
}));
|
|
|
|
const mockRunSshCommand = vi.hoisted(() => vi.fn());
|
|
|
|
function registerModuleMocks() {
|
|
vi.doMock("../lib/agent-runner-platform.js", () => ({
|
|
localAgentRunnerTarget: () => ({ driver: "local", platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }),
|
|
localPaperclipRunnerSupported: () => true,
|
|
}));
|
|
vi.doMock("@paperclipai/adapter-utils/ssh", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@paperclipai/adapter-utils/ssh")>()),
|
|
runSshCommand: mockRunSshCommand,
|
|
}));
|
|
vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
|
|
return {
|
|
...actual,
|
|
ensureOpenCodeModelConfiguredAndAvailable: mockEnsureOpenCodeModelConfiguredAndAvailable,
|
|
};
|
|
});
|
|
|
|
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
|
trackAgentCreated: mockTrackAgentCreated,
|
|
trackErrorHandlerCrash: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../telemetry.js", () => ({
|
|
getTelemetryClient: mockGetTelemetryClient,
|
|
}));
|
|
|
|
vi.doMock("../services/agents.js", () => ({
|
|
agentService: () => mockAgentService,
|
|
}));
|
|
|
|
vi.doMock("../services/access.js", () => ({
|
|
accessService: () => mockAccessService,
|
|
}));
|
|
|
|
vi.doMock("../services/approvals.js", () => ({
|
|
approvalService: () => mockApprovalService,
|
|
}));
|
|
|
|
vi.doMock("../services/company-skills.js", () => ({
|
|
companySkillService: () => mockCompanySkillService,
|
|
}));
|
|
|
|
vi.doMock("../services/budgets.js", () => ({
|
|
budgetService: () => mockBudgetService,
|
|
}));
|
|
|
|
vi.doMock("../services/heartbeat.js", () => ({
|
|
heartbeatService: () => mockHeartbeatService,
|
|
}));
|
|
|
|
vi.doMock("../services/issue-approvals.js", () => ({
|
|
issueApprovalService: () => mockIssueApprovalService,
|
|
}));
|
|
|
|
vi.doMock("../services/issues.js", () => ({
|
|
issueService: () => mockIssueService,
|
|
}));
|
|
|
|
vi.doMock("../services/secrets.js", () => ({
|
|
secretService: () => mockSecretService,
|
|
}));
|
|
|
|
vi.doMock("../services/environments.js", () => ({
|
|
environmentService: () => mockEnvironmentService,
|
|
}));
|
|
|
|
vi.doMock("../services/agent-instructions.js", () => ({
|
|
agentInstructionsService: () => mockAgentInstructionsService,
|
|
agentInstructionsBundleMode: (agent: { adapterConfig?: unknown }) => {
|
|
const config = agent.adapterConfig as Record<string, unknown> | undefined;
|
|
return config?.instructionsBundleMode === "external" ? "external" : "managed";
|
|
},
|
|
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
|
}));
|
|
|
|
vi.doMock("../services/workspace-operations.js", () => ({
|
|
workspaceOperationService: () => mockWorkspaceOperationService,
|
|
}));
|
|
|
|
vi.doMock("../services/activity-log.js", () => ({
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
|
|
vi.doMock("../services/instance-settings.js", () => ({
|
|
instanceSettingsService: () => mockInstanceSettingsService,
|
|
}));
|
|
|
|
vi.doMock("../services/index.js", () => ({
|
|
agentService: () => mockAgentService,
|
|
agentInstructionsService: () => mockAgentInstructionsService,
|
|
accessService: () => mockAccessService,
|
|
approvalService: () => mockApprovalService,
|
|
builtInAgentService: () => mockBuiltInAgentService,
|
|
companySkillService: () => mockCompanySkillService,
|
|
budgetService: () => mockBudgetService,
|
|
heartbeatService: () => mockHeartbeatService,
|
|
ISSUE_LIST_DEFAULT_LIMIT: 500,
|
|
issueApprovalService: () => mockIssueApprovalService,
|
|
issueService: () => mockIssueService,
|
|
logActivity: mockLogActivity,
|
|
secretService: () => mockSecretService,
|
|
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
|
workspaceOperationService: () => mockWorkspaceOperationService,
|
|
environmentService: () => mockEnvironmentService,
|
|
}));
|
|
}
|
|
|
|
function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } = {}) {
|
|
return {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
then: vi.fn((resolve) =>
|
|
Promise.resolve(resolve([{
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
requireBoardApprovalForNewAgents: options.requireBoardApprovalForNewAgents ?? false,
|
|
}])),
|
|
),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function requestApp(
|
|
app: express.Express,
|
|
buildRequest: (baseUrl: string) => request.Test,
|
|
) {
|
|
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
|
const server = createServer(app);
|
|
try {
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected HTTP server to listen on a TCP port");
|
|
}
|
|
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
|
} finally {
|
|
if (server.listening) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) reject(error);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("agent permission routes", () => {
|
|
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
|
|
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
|
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
|
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
|
]);
|
|
return { errorHandler, agentRoutes };
|
|
});
|
|
|
|
function createApp(actor: Record<string, unknown>, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) {
|
|
const { errorHandler, agentRoutes } = routeModules.value;
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = {
|
|
...actor,
|
|
companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", agentRoutes(createDbStub(dbOptions) as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
mockAgentService.getById.mockReset();
|
|
mockAgentService.getConfigRevision.mockReset();
|
|
mockAgentService.listConfigRevisions.mockReset();
|
|
mockAgentService.list.mockReset();
|
|
mockAgentService.create.mockReset();
|
|
mockAgentService.activatePendingApproval.mockReset();
|
|
mockAgentService.terminate.mockReset();
|
|
mockAgentService.update.mockReset();
|
|
mockAgentService.rollbackConfigRevision.mockReset();
|
|
mockAgentService.updatePermissions.mockReset();
|
|
mockAgentService.getChainOfCommand.mockReset();
|
|
mockAgentService.resolveByReference.mockReset();
|
|
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockReset();
|
|
mockAccessService.canUser.mockReset();
|
|
mockAccessService.decide.mockReset();
|
|
mockAccessService.hasPermission.mockReset();
|
|
mockAccessService.getMembership.mockReset();
|
|
mockAccessService.ensureMembership.mockReset();
|
|
mockAccessService.listPrincipalGrants.mockReset();
|
|
mockAccessService.setPrincipalPermission.mockReset();
|
|
mockApprovalService.create.mockReset();
|
|
mockApprovalService.getById.mockReset();
|
|
mockApprovalService.findOpenHireApprovalForAgent.mockReset();
|
|
mockApprovalService.approveHire.mockReset();
|
|
mockApprovalService.rejectHire.mockReset();
|
|
mockBudgetService.upsertPolicy.mockReset();
|
|
mockHeartbeatService.listTaskSessions.mockReset();
|
|
mockHeartbeatService.resetRuntimeSession.mockReset();
|
|
mockHeartbeatService.getRun.mockReset();
|
|
mockHeartbeatService.cancelRun.mockReset();
|
|
mockHeartbeatService.cancelInvocationsForAgents.mockReset();
|
|
mockIssueApprovalService.linkManyForApproval.mockReset();
|
|
mockIssueService.list.mockReset();
|
|
mockSecretService.normalizeAdapterConfigForPersistence.mockReset();
|
|
mockSecretService.resolveAdapterConfigForRuntime.mockReset();
|
|
mockAgentInstructionsService.materializeManagedBundle.mockReset();
|
|
mockCompanySkillService.listRuntimeSkillEntries.mockReset();
|
|
mockCompanySkillService.resolveRequestedSkillKeys.mockReset();
|
|
mockLogActivity.mockReset();
|
|
mockTrackAgentCreated.mockReset();
|
|
mockGetTelemetryClient.mockReset();
|
|
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
|
|
mockInstanceSettingsService.get.mockReset().mockResolvedValue({ defaultEnvironmentId: null, experimental: {}, general: {} });
|
|
mockInstanceSettingsService.getExperimental.mockReset().mockResolvedValue({});
|
|
mockInstanceSettingsService.getGeneral.mockReset();
|
|
mockEnvironmentService.listBoundCompanyIds.mockReset().mockResolvedValue([]);
|
|
mockRunSshCommand.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n", stderr: "", code: 0 });
|
|
mockEnvironmentService.getById.mockReset();
|
|
mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset();
|
|
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
|
mockAgentService.getById.mockResolvedValue(baseAgent);
|
|
mockAgentService.getConfigRevision.mockResolvedValue(null);
|
|
mockAgentService.listConfigRevisions.mockResolvedValue([]);
|
|
mockAgentService.list.mockResolvedValue([baseAgent]);
|
|
mockAgentService.getChainOfCommand.mockResolvedValue([]);
|
|
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
|
|
mockAgentService.create.mockResolvedValue(baseAgent);
|
|
mockAgentService.activatePendingApproval.mockResolvedValue({
|
|
agent: baseAgent,
|
|
activated: false,
|
|
});
|
|
mockAgentService.update.mockResolvedValue(baseAgent);
|
|
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
|
|
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0);
|
|
mockAccessService.canUser.mockResolvedValue(true);
|
|
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
|
|
const allowed = Boolean(await mockAccessService.canUser());
|
|
return {
|
|
allowed,
|
|
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
|
|
explanation: allowed ? "Allowed by test grant" : `Missing test grant for ${input.action ?? "action"}`,
|
|
};
|
|
});
|
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
|
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 }));
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
|
censorUsernameInLogs: false,
|
|
});
|
|
mockLogActivity.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("redacts agent detail for authenticated company members without agent admin permission", async () => {
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({
|
|
allowed: input.action === "agent:read",
|
|
reason: input.action === "agent:read" ? "allow_test_read" : "deny_missing_grant",
|
|
explanation: input.action === "agent:read" ? "Allowed by test read grant." : "Missing test grant.",
|
|
}));
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "member-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.adapterConfig).toEqual({});
|
|
expect(res.body.runtimeConfig).toEqual({});
|
|
}, 20_000);
|
|
|
|
it("redacts env values in board agent detail responses", async () => {
|
|
const plaintextValue = "plain-value-must-not-leak";
|
|
mockAgentService.getById.mockResolvedValue({
|
|
...baseAgent,
|
|
permissions: {
|
|
...baseAgent.permissions,
|
|
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
|
},
|
|
adapterConfig: {
|
|
command: "pnpm agent:run",
|
|
env: {
|
|
LEGACY_VALUE: plaintextValue,
|
|
PLAIN_VALUE: { type: "plain", value: plaintextValue },
|
|
SECRET_REFERENCE: {
|
|
type: "secret_ref",
|
|
secretId: "33333333-3333-4333-8333-333333333333",
|
|
version: "latest",
|
|
},
|
|
},
|
|
},
|
|
runtimeConfig: {
|
|
heartbeat: { enabled: false },
|
|
},
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.adapterConfig).toMatchObject({
|
|
command: "pnpm agent:run",
|
|
env: {
|
|
LEGACY_VALUE: { type: "plain", value: "***REDACTED***" },
|
|
PLAIN_VALUE: { type: "plain", value: "***REDACTED***" },
|
|
SECRET_REFERENCE: {
|
|
type: "secret_ref",
|
|
secretId: "33333333-3333-4333-8333-333333333333",
|
|
version: "latest",
|
|
},
|
|
},
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(plaintextValue);
|
|
expect(res.body.runtimeConfig).toMatchObject({
|
|
heartbeat: { enabled: false },
|
|
});
|
|
expect(res.body.permissions).toMatchObject({ trustPreset: LOW_TRUST_REVIEW_PRESET });
|
|
}, 20_000);
|
|
|
|
// TEC-7032 reported the leak against the company agent-list endpoint, which
|
|
// serialises rows directly instead of going through buildAgentDetail.
|
|
it("redacts env values in board GET /api/companies/:companyId/agents responses", async () => {
|
|
const plaintextValue = "listed-value-must-not-leak";
|
|
mockAgentService.list.mockResolvedValue([
|
|
{
|
|
...baseAgent,
|
|
adapterConfig: {
|
|
command: "pnpm agent:run",
|
|
env: {
|
|
LEGACY_VALUE: plaintextValue,
|
|
PLAIN_VALUE: { type: "plain", value: plaintextValue },
|
|
SECRET_REFERENCE: {
|
|
type: "secret_ref",
|
|
secretId: "55555555-5555-4555-8555-555555555555",
|
|
version: "latest",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) =>
|
|
request(baseUrl).get(`/api/companies/${companyId}/agents`),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(1);
|
|
expect(res.body[0].adapterConfig).toMatchObject({
|
|
command: "pnpm agent:run",
|
|
env: {
|
|
LEGACY_VALUE: { type: "plain", value: "***REDACTED***" },
|
|
PLAIN_VALUE: { type: "plain", value: "***REDACTED***" },
|
|
SECRET_REFERENCE: {
|
|
type: "secret_ref",
|
|
secretId: "55555555-5555-4555-8555-555555555555",
|
|
version: "latest",
|
|
},
|
|
},
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(plaintextValue);
|
|
}, 20_000);
|
|
|
|
// Mutation routes echo the stored row back, so they leak the same values the
|
|
// GET paths redact.
|
|
it("redacts env values in agent mutation responses", async () => {
|
|
const plaintextValue = "mutation-value-must-not-leak";
|
|
const storedAgent = {
|
|
...baseAgent,
|
|
adapterConfig: {
|
|
env: { PLAIN_VALUE: { type: "plain", value: plaintextValue } },
|
|
},
|
|
};
|
|
mockAgentService.getById.mockResolvedValue(storedAgent);
|
|
mockAgentService.update.mockResolvedValue(storedAgent);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) =>
|
|
request(baseUrl).patch(`/api/agents/${agentId}`).send({ title: "Renamed" }),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.adapterConfig.env).toEqual({
|
|
PLAIN_VALUE: { type: "plain", value: "***REDACTED***" },
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(plaintextValue);
|
|
}, 20_000);
|
|
|
|
it("redacts env values in GET /api/agents/me responses", async () => {
|
|
const plaintextValue = "self-value-must-not-leak";
|
|
mockAgentService.getById.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterConfig: {
|
|
env: {
|
|
EXISTING_VALUE: plaintextValue,
|
|
NEW_VALUE: { type: "plain", value: plaintextValue },
|
|
SECRET_REFERENCE: {
|
|
type: "secret_ref",
|
|
secretId: "44444444-4444-4444-8444-444444444444",
|
|
version: 2,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
runId: null,
|
|
source: "agent_key",
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/agents/me"));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.adapterConfig.env).toEqual({
|
|
EXISTING_VALUE: { type: "plain", value: "***REDACTED***" },
|
|
NEW_VALUE: { type: "plain", value: "***REDACTED***" },
|
|
SECRET_REFERENCE: {
|
|
type: "secret_ref",
|
|
secretId: "44444444-4444-4444-8444-444444444444",
|
|
version: 2,
|
|
},
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(plaintextValue);
|
|
}, 20_000);
|
|
|
|
it("preserves stored env values when a redacted detail response is submitted unchanged", async () => {
|
|
const plaintextValue = "stored-value-must-be-preserved";
|
|
mockAgentService.getById.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterConfig: {
|
|
env: {
|
|
EXISTING_VALUE: { type: "plain", value: plaintextValue },
|
|
},
|
|
},
|
|
});
|
|
mockAgentService.update.mockResolvedValue(baseAgent);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const redactedResponse = await requestApp(app, (baseUrl) =>
|
|
request(baseUrl).get(`/api/agents/${agentId}`),
|
|
);
|
|
expect(redactedResponse.status).toBe(200);
|
|
const redactedEnv = redactedResponse.body.adapterConfig.env as Record<string, unknown>;
|
|
expect(redactedEnv.EXISTING_VALUE).toEqual({ type: "plain", value: "***REDACTED***" });
|
|
|
|
const patchRes = await requestApp(app, (baseUrl) =>
|
|
request(baseUrl).patch(`/api/agents/${agentId}`).send({
|
|
title: "Renamed while redacted env round-trips",
|
|
adapterConfig: redactedResponse.body.adapterConfig,
|
|
}),
|
|
);
|
|
|
|
expect(patchRes.status).toBe(200);
|
|
const updateCallArgs = mockAgentService.update.mock.calls[0]?.[1] as
|
|
| { adapterConfig?: Record<string, unknown> }
|
|
| undefined;
|
|
expect(updateCallArgs?.adapterConfig?.env).toEqual({
|
|
EXISTING_VALUE: { type: "plain", value: plaintextValue },
|
|
});
|
|
expect(JSON.stringify(updateCallArgs?.adapterConfig ?? {})).not.toContain("***REDACTED***");
|
|
}, 20_000);
|
|
|
|
it("redacts company agent list for authenticated company members without agent admin permission", async () => {
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({
|
|
allowed: input.action === "agent:read",
|
|
reason: input.action === "agent:read" ? "allow_test_read" : "deny_missing_grant",
|
|
explanation: input.action === "agent:read" ? "Allowed by test read grant." : "Missing test grant.",
|
|
}));
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "member-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/companies/${companyId}/agents`));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual([
|
|
expect.objectContaining({
|
|
id: agentId,
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it.each([{ spentMonthlyCents: 0 }, { name: "Renamed", spentMonthlyCents: 0 }])(
|
|
"rejects accounting fields before applying an agent update: %j", async payload => {
|
|
const app = await createApp({ type: "board", userId: "board-user", source: "session", isInstanceAdmin: true, companyIds: [companyId] });
|
|
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`).send(payload));
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.details).toEqual(expect.arrayContaining([expect.objectContaining({ path: ["spentMonthlyCents"] })]));
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("blocks agent updates for authenticated company members without agent admin permission", async () => {
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "member-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ title: "Compromised" }));
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("requires instance administration to enable agent-scoped raw provider traces", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "agent-admin-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ runtimeConfig: { debug: { providerTrace: "raw" } } }));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows instance administrators to enable agent-scoped raw provider traces", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "instance-admin-user",
|
|
source: "session",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ runtimeConfig: { debug: { providerTrace: "raw" } } }));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockAgentService.update).toHaveBeenCalledWith(
|
|
agentId,
|
|
expect.objectContaining({
|
|
runtimeConfig: { debug: { providerTrace: "raw" } },
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["direct creation", `/api/companies/${companyId}/agents`],
|
|
["hire creation", `/api/companies/${companyId}/agent-hires`],
|
|
])("requires instance administration for raw provider traces during %s", async (_label, path) => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "agent-admin-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(path)
|
|
.send({
|
|
name: "Trace attempt",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: { debug: { providerTrace: "raw" } },
|
|
}));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows instance administrators to create and hire with raw provider traces", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "instance-admin-user",
|
|
source: "session",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
const body = {
|
|
name: "Trace capture agent",
|
|
adapterType: "process",
|
|
runtimeConfig: { debug: { providerTrace: "raw" } },
|
|
};
|
|
|
|
const createResponse = await requestApp(app, (baseUrl) =>
|
|
request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send(body),
|
|
);
|
|
const hireResponse = await requestApp(app, (baseUrl) =>
|
|
request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agent-hires`)
|
|
.send(body),
|
|
);
|
|
|
|
expect(createResponse.status, JSON.stringify(createResponse.body)).toBe(201);
|
|
expect(hireResponse.status, JSON.stringify(hireResponse.body)).toBe(201);
|
|
expect(mockAgentService.create).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("rejects non-admin rollback into raw provider trace capture", async () => {
|
|
mockAgentService.getConfigRevision.mockResolvedValue({
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
afterConfig: {
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: { debug: { providerTrace: "raw" } },
|
|
},
|
|
});
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "agent-admin-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const response = await requestApp(app, (baseUrl) =>
|
|
request(baseUrl).post(
|
|
`/api/agents/${agentId}/config-revisions/33333333-3333-4333-8333-333333333333/rollback`,
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("redacts plaintext env values in configuration rollback responses", async () => {
|
|
const revisionId = "33333333-3333-4333-8333-333333333333";
|
|
const plaintextValue = "rollback-value-must-not-leak";
|
|
mockAgentService.getConfigRevision.mockResolvedValue({
|
|
id: revisionId,
|
|
afterConfig: {
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
},
|
|
});
|
|
mockAgentService.rollbackConfigRevision.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterConfig: {
|
|
env: { GENERIC_NAME: { type: "plain", value: plaintextValue } },
|
|
},
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "instance-admin-user",
|
|
source: "session",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) =>
|
|
request(baseUrl).post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.adapterConfig.env).toEqual({
|
|
GENERIC_NAME: { type: "plain", value: "***REDACTED***" },
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(plaintextValue);
|
|
});
|
|
|
|
it("blocks api key creation for authenticated company members without agent admin permission", async () => {
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "member-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/keys`)
|
|
.send({ name: "backdoor" }));
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("blocks wakeups for authenticated company members without agent admin permission", async () => {
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "member-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/wakeup`)
|
|
.send({}));
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("blocks agent-authenticated self-updates that set host-executed workspace commands", async () => {
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({
|
|
adapterConfig: {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
provisionCommand: "touch /tmp/paperclip-rce",
|
|
},
|
|
},
|
|
}));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed workspace commands");
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["command", { command: "sh" }],
|
|
["arguments", { args: ["-c", "id"] }],
|
|
["environment", { env: { PATH: "/tmp" } }],
|
|
])("blocks agent-authenticated process adapter %s updates", async (_label, adapterConfig) => {
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ adapterConfig }));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed process adapters");
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks an agent from switching a peer onto the process adapter", async () => {
|
|
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "codex_local" });
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ adapterType: "process" }));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed process adapters");
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["claude_local", { engine: "cli", command: "/tmp/untrusted" }],
|
|
["codex_local", { args: ["-c", "id"] }],
|
|
["cursor_local", { env: { PATH: "/tmp" } }],
|
|
["hermes_local", { hermesCommand: "/tmp/untrusted" }],
|
|
["gemini_local", { cwd: "/tmp" }],
|
|
["opencode_local", { filesystemSandboxCommand: "/tmp/untrusted" }],
|
|
])("blocks agent-authenticated host settings for %s", async (adapterType, adapterConfig) => {
|
|
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType });
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ adapterConfig }));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed local adapter settings");
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows an agent to update a peer's local model without host settings", async () => {
|
|
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "claude_local" });
|
|
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ adapterConfig: { model: "claude-sonnet-4-5" } }));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockAgentService.update).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("allows a provider credential reference without allowing arbitrary environment variables", async () => {
|
|
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "claude_local" });
|
|
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "33333333-3333-4333-8333-333333333333" } } } }));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockAgentService.update).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each(["ANTHROPIC_BASE_URL", "HTTP_PROXY", "NODE_OPTIONS"])("blocks agent-supplied %s on native Claude updates", async key => {
|
|
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "acpx", acpxAgent: "claude" } });
|
|
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`)
|
|
.send({ adapterConfig: { env: { [key]: "untrusted-setting" } } }));
|
|
expect(res.status).toBe(403);
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows native Claude credential references in partial updates using the saved harness", async () => {
|
|
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "acpx", acpxAgent: "claude" } });
|
|
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`)
|
|
.send({ adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "33333333-3333-4333-8333-333333333333" } } } }));
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockAgentService.update).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("blocks an agent from activating inherited host settings by switching onto a local adapter", async () => {
|
|
mockAgentService.getById.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterType: "paperclip_runner",
|
|
adapterConfig: { env: { PATH: "/tmp" } },
|
|
});
|
|
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({ adapterType: "claude_local", adapterConfig: { engine: "cli" } }));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed local adapter settings");
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["create", `/api/companies/${companyId}/agents`],
|
|
["hire", `/api/companies/${companyId}/agent-hires`],
|
|
])("blocks agent-authenticated process adapter commands on %s", async (_label, path) => {
|
|
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(path)
|
|
.send({ name: "Host process", role: "engineer", adapterType: "process", adapterConfig: { command: "sh" } }));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed process adapters");
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["create", `/api/companies/${companyId}/agents`],
|
|
["hire", `/api/companies/${companyId}/agent-hires`],
|
|
])("blocks agent-authenticated local adapter commands on %s", async (_label, path) => {
|
|
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(path)
|
|
.send({ name: "Local peer", role: "engineer", adapterType: "claude_local", adapterConfig: { engine: "cli", command: "/tmp/untrusted" } }));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed local adapter settings");
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks agent-authenticated rollback into process adapter configuration", async () => {
|
|
mockAgentService.getConfigRevision.mockResolvedValue({
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
afterConfig: { adapterType: "process", adapterConfig: { command: "sh" }, runtimeConfig: {} },
|
|
});
|
|
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/config-revisions/33333333-3333-4333-8333-333333333333/rollback`));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed process adapters");
|
|
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks agent-authenticated rollback into local adapter command configuration", async () => {
|
|
const revisionId = "33333333-3333-4333-8333-333333333333";
|
|
mockAgentService.getConfigRevision.mockResolvedValue({
|
|
id: revisionId,
|
|
afterConfig: { adapterType: "claude_local", adapterConfig: { engine: "cli", command: "/tmp/untrusted" }, runtimeConfig: {} },
|
|
});
|
|
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed local adapter settings");
|
|
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["provisionCommand", "runtimeProvisionCommand", "teardownCommand"])(
|
|
"blocks agent-authenticated rollback of workspace %s",
|
|
async (commandKey) => {
|
|
const revisionId = "33333333-3333-4333-8333-333333333333";
|
|
mockAgentService.getConfigRevision.mockResolvedValue({
|
|
id: revisionId,
|
|
afterConfig: {
|
|
adapterType: "codex_local",
|
|
adapterConfig: { workspaceStrategy: { type: "git_worktree", [commandKey]: "sh -c id" } },
|
|
runtimeConfig: {},
|
|
},
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed workspace commands");
|
|
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("allows an agent to restore a non-process revision for a process peer", async () => {
|
|
const revisionId = "33333333-3333-4333-8333-333333333333";
|
|
mockAgentService.getConfigRevision.mockResolvedValue({
|
|
id: revisionId,
|
|
afterConfig: { adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {} },
|
|
});
|
|
mockAgentService.rollbackConfigRevision.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterType: "codex_local",
|
|
});
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockAgentService.rollbackConfigRevision).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => {
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({
|
|
adapterConfig: {
|
|
instructionsRootPath: "/etc",
|
|
instructionsEntryFile: "passwd",
|
|
},
|
|
}));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("instructions path or bundle configuration");
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
}, 15_000);
|
|
|
|
it("blocks agent-authenticated instructions-path updates", async () => {
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}/instructions-path`)
|
|
.send({ path: "/etc/passwd" }));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("instructions path or bundle configuration");
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks agent-authenticated hires that set instructions bundle config", async () => {
|
|
mockAccessService.hasPermission.mockResolvedValue(true);
|
|
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agent-hires`)
|
|
.send({
|
|
name: "Injected",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {
|
|
instructionsRootPath: "/etc",
|
|
instructionsEntryFile: "passwd",
|
|
},
|
|
}));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("instructions path or bundle configuration");
|
|
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
|
|
expect(mockRunSshCommand).not.toHaveBeenCalled();
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks direct agent creation for authenticated company members without agent create permission", async () => {
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "member-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "Backdoor",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
}));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("agents:create");
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["agents", "agent-hires"])("rejects agent-supplied Codex host commands before resolving the runner via %s", async (route) => {
|
|
mockAccessService.hasPermission.mockResolvedValue(true);
|
|
mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets"));
|
|
const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/${route}`)
|
|
.send({ name: "Injected", role: "engineer", adapterType: "codex_local", adapterConfig: { command: "untrusted-codex" } }));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
|
expect(res.body.error).toContain("host-executed local adapter settings");
|
|
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
|
|
expect(mockRunSshCommand).not.toHaveBeenCalled();
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["agents", "agent-hires"])("requires instance admin for external instructions before resolving the runner via %s", async (route) => {
|
|
mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets"));
|
|
const app = createApp({ type: "board", userId: "member-user", source: "session", companyIds: [companyId], isInstanceAdmin: false });
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/${route}`)
|
|
.send({ name: "External", role: "engineer", adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "external" } }));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
|
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
|
|
expect(mockRunSshCommand).not.toHaveBeenCalled();
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows direct agent creation for authenticated board users with agent create permission when approval is not required", async () => {
|
|
mockAccessService.canUser.mockResolvedValue(true);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "agent-admin-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "Builder",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
}));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
status: "idle",
|
|
}),
|
|
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
|
|
);
|
|
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
|
companyId,
|
|
"agent",
|
|
agentId,
|
|
"tasks:assign",
|
|
true,
|
|
"agent-admin-user",
|
|
);
|
|
expect(mockBuiltInAgentService.ensureCompanyDefaultAgentGrants).toHaveBeenCalledWith(companyId);
|
|
});
|
|
|
|
it("rejects direct agent creation when new agents require board approval", async () => {
|
|
const app = await createApp(
|
|
{
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
},
|
|
{ requireBoardApprovalForNewAgents: true },
|
|
);
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "Builder",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
}));
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toContain("/agent-hires");
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
expect(mockApprovalService.create).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("grants tasks:assign by default when board creates a new agent", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "Builder",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
}));
|
|
|
|
expect([200, 201]).toContain(res.status);
|
|
expect(mockAccessService.ensureMembership).toHaveBeenCalledWith(
|
|
companyId,
|
|
"agent",
|
|
agentId,
|
|
"member",
|
|
"active",
|
|
);
|
|
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
|
companyId,
|
|
"agent",
|
|
agentId,
|
|
"tasks:assign",
|
|
true,
|
|
"board-user",
|
|
);
|
|
}, 15_000);
|
|
|
|
it("rejects unsupported query parameters on the agent list route", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.get(`/api/companies/${companyId}/agents`)
|
|
.query({ urlKey: "builder" }));
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("urlKey");
|
|
expect(mockAgentService.list).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "Builder",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
intervalSec: 3600,
|
|
},
|
|
},
|
|
}));
|
|
|
|
expect([200, 201]).toContain(res.status);
|
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
enabled: false,
|
|
intervalSec: 3600,
|
|
maxConcurrentRuns: 20,
|
|
},
|
|
},
|
|
}),
|
|
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
|
);
|
|
});
|
|
|
|
it("seeds explicit legacy opencode creation with the static default model without live discovery", async () => {
|
|
mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
|
|
new Error("`opencode models` should not be called during creation"),
|
|
);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "OpenCode Builder",
|
|
role: "engineer",
|
|
adapterType: "opencode_local",
|
|
runner: "legacy",
|
|
adapterConfig: {},
|
|
}));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
|
expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled();
|
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
adapterType: "opencode_local",
|
|
adapterConfig: expect.objectContaining({
|
|
model: DEFAULT_OPENCODE_LOCAL_MODEL,
|
|
}),
|
|
}),
|
|
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
|
);
|
|
});
|
|
|
|
it("accepts manual opencode provider/model values without host-side discovery", async () => {
|
|
mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
|
|
new Error("`opencode models` should not be called during creation"),
|
|
);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "OpenCode Builder",
|
|
role: "engineer",
|
|
adapterType: "opencode_local",
|
|
adapterConfig: {
|
|
model: "anthropic/claude-sonnet-4-5",
|
|
},
|
|
}));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
|
expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled();
|
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
adapterType: "paperclip_runner",
|
|
adapterConfig: expect.objectContaining({
|
|
provider: "opencode",
|
|
model: "anthropic/claude-sonnet-4-5",
|
|
}),
|
|
}),
|
|
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
|
);
|
|
});
|
|
|
|
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agent-hires`)
|
|
.send({
|
|
name: "Builder",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
intervalSec: 3600,
|
|
},
|
|
},
|
|
}));
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
enabled: false,
|
|
intervalSec: 3600,
|
|
maxConcurrentRuns: 20,
|
|
},
|
|
},
|
|
}),
|
|
{
|
|
runnerResolved: true,
|
|
aiConnectionInstall: undefined,
|
|
createdByUserId: "board-user", responsibleUserId: "board-user",
|
|
claudeLogin: {
|
|
storedSessionId: null,
|
|
ownerUserId: "board-user",
|
|
applyExistingWithoutClaim: false,
|
|
inheritedFromAgentId: null,
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it("allows board users to directly approve pending agents", async () => {
|
|
const pendingAgent = {
|
|
...baseAgent,
|
|
status: "pending_approval",
|
|
};
|
|
const approvedAgent = {
|
|
...baseAgent,
|
|
status: "idle",
|
|
};
|
|
mockAgentService.getById.mockResolvedValue(pendingAgent);
|
|
mockApprovalService.approveHire.mockResolvedValue({
|
|
agent: approvedAgent,
|
|
applied: true,
|
|
approval: null,
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/approve`)
|
|
.send({}));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockApprovalService.approveHire).toHaveBeenCalledWith(agentId, "board-user");
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
action: "agent.approved",
|
|
entityType: "agent",
|
|
entityId: agentId,
|
|
details: { source: "agent_detail", approvalId: null },
|
|
}));
|
|
});
|
|
|
|
it("resolves the linked hire approval when approving from the agent detail page", async () => {
|
|
const pendingAgent = {
|
|
...baseAgent,
|
|
status: "pending_approval",
|
|
};
|
|
const approvedAgent = {
|
|
...baseAgent,
|
|
status: "idle",
|
|
};
|
|
mockAgentService.getById.mockResolvedValue(pendingAgent);
|
|
mockApprovalService.approveHire.mockResolvedValue({
|
|
agent: approvedAgent,
|
|
approval: { id: "approval-1", status: "approved" },
|
|
applied: true,
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/approve`)
|
|
.send({}));
|
|
|
|
expect(res.status).toBe(200);
|
|
// The shared approval flow handles activation; we must not double-activate.
|
|
expect(mockApprovalService.approveHire).toHaveBeenCalledWith(agentId, "board-user");
|
|
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
action: "agent.approved",
|
|
details: { source: "agent_detail", approvalId: "approval-1" },
|
|
}));
|
|
});
|
|
|
|
it("rejects the linked hire approval when terminating a still-pending agent without double-terminating", async () => {
|
|
const pendingAgent = {
|
|
...baseAgent,
|
|
status: "pending_approval",
|
|
};
|
|
const terminatedAgent = {
|
|
...baseAgent,
|
|
status: "terminated",
|
|
};
|
|
mockAgentService.getById.mockResolvedValue(pendingAgent);
|
|
mockApprovalService.rejectHire.mockResolvedValue({
|
|
agent: terminatedAgent,
|
|
approval: { id: "approval-1", status: "rejected" },
|
|
applied: true,
|
|
});
|
|
mockHeartbeatService.cancelInvocationsForAgents.mockResolvedValue({
|
|
agentIds: [agentId],
|
|
runsCancelled: 0,
|
|
wakeupsCancelled: 0,
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/terminate`)
|
|
.send({}));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockApprovalService.rejectHire).toHaveBeenCalledWith(agentId, "board-user");
|
|
// The hire decision rejects the pending agent; the route must not terminate again.
|
|
expect(mockAgentService.terminate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("terminates directly when no open hire approval is linked", async () => {
|
|
const idleAgent = { ...baseAgent, status: "idle" };
|
|
const terminatedAgent = { ...baseAgent, status: "terminated" };
|
|
mockAgentService.getById.mockResolvedValue(idleAgent);
|
|
mockAgentService.terminate.mockResolvedValue(terminatedAgent);
|
|
mockHeartbeatService.cancelInvocationsForAgents.mockResolvedValue({
|
|
agentIds: [agentId],
|
|
runsCancelled: 0,
|
|
wakeupsCancelled: 0,
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/terminate`)
|
|
.send({}));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockAgentService.terminate).toHaveBeenCalledWith(agentId);
|
|
expect(mockApprovalService.findOpenHireApprovalForAgent).not.toHaveBeenCalled();
|
|
expect(mockApprovalService.rejectHire).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects direct approval for agents that are not pending approval", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${agentId}/approve`)
|
|
.send({}));
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(mockApprovalService.approveHire).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
action: "agent.approved",
|
|
}));
|
|
});
|
|
|
|
it("allows creating an agent with an instance-scoped environment referenced from another company", async () => {
|
|
const environmentId = "33333333-3333-4333-8333-333333333333";
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId: "other-company",
|
|
driver: "local",
|
|
config: {},
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "Builder",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
defaultEnvironmentId: environmentId,
|
|
}));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
defaultEnvironmentId: environmentId,
|
|
}),
|
|
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
|
);
|
|
});
|
|
|
|
it("rejects creating an agent with an unsupported default environment driver", async () => {
|
|
const environmentId = "33333333-3333-4333-8333-333333333333";
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId,
|
|
driver: "ssh",
|
|
config: {},
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: "Builder",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
defaultEnvironmentId: environmentId,
|
|
}));
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
|
expect(mockAgentService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
const sshCapableAdapterCases = [
|
|
{ adapterType: "codex_local", name: "Codex Builder", adapterConfig: {} },
|
|
{ adapterType: "claude_local", name: "Claude Builder", adapterConfig: {} },
|
|
{ adapterType: "gemini_local", name: "Gemini Builder", adapterConfig: {} },
|
|
{ adapterType: "opencode_local", name: "OpenCode Builder", adapterConfig: { model: "opencode/gpt-5-nano" } },
|
|
{ adapterType: "cursor", name: "Cursor Builder", adapterConfig: {} },
|
|
{ adapterType: "pi_local", name: "Pi Builder", adapterConfig: { model: "openai/gpt-5.4-mini" } },
|
|
];
|
|
|
|
for (const adapterCase of sshCapableAdapterCases) {
|
|
it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => {
|
|
const expectedAdapterType = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType;
|
|
const environmentId = "33333333-3333-4333-8333-333333333333";
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId,
|
|
driver: "ssh",
|
|
config: { host: "qa.example.invalid", username: "qa", remoteWorkspacePath: "/workspace" },
|
|
});
|
|
mockAgentService.create.mockResolvedValue({
|
|
...baseAgent,
|
|
name: adapterCase.name,
|
|
adapterType: expectedAdapterType,
|
|
defaultEnvironmentId: environmentId,
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.post(`/api/companies/${companyId}/agents`)
|
|
.send({
|
|
name: adapterCase.name,
|
|
role: "engineer",
|
|
adapterType: adapterCase.adapterType,
|
|
adapterConfig: adapterCase.adapterConfig,
|
|
defaultEnvironmentId: environmentId,
|
|
}));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
adapterType: expectedAdapterType,
|
|
defaultEnvironmentId: environmentId,
|
|
}),
|
|
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
|
);
|
|
if (["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterCase.adapterType)) {
|
|
if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
|
|
else if (adapterCase.adapterType === "opencode_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "opencode", model: adapterCase.adapterConfig.model });
|
|
else if (adapterCase.adapterType === "pi_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: adapterCase.adapterConfig.model });
|
|
else expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" });
|
|
const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js");
|
|
expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 });
|
|
} else {
|
|
expect(mockRunSshCommand).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
}
|
|
|
|
it("rejects updating an agent with an unsupported default environment driver", async () => {
|
|
const environmentId = "33333333-3333-4333-8333-333333333333";
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId,
|
|
driver: "ssh",
|
|
config: {},
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({
|
|
defaultEnvironmentId: environmentId,
|
|
}));
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
for (const adapterCase of sshCapableAdapterCases) {
|
|
it(`allows updating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => {
|
|
const environmentId = "33333333-3333-4333-8333-333333333333";
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId,
|
|
driver: "ssh",
|
|
config: {},
|
|
});
|
|
mockAgentService.getById.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterType: adapterCase.adapterType,
|
|
adapterConfig: adapterCase.adapterConfig,
|
|
defaultEnvironmentId: null,
|
|
});
|
|
mockAgentService.update.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterType: adapterCase.adapterType,
|
|
adapterConfig: adapterCase.adapterConfig,
|
|
defaultEnvironmentId: environmentId,
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({
|
|
defaultEnvironmentId: environmentId,
|
|
}));
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockAgentService.update).toHaveBeenCalledWith(
|
|
agentId,
|
|
expect.objectContaining({
|
|
defaultEnvironmentId: environmentId,
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
}
|
|
|
|
it("rejects switching an agent away from an SSH-capable runtime without clearing its SSH default", async () => {
|
|
const environmentId = "33333333-3333-4333-8333-333333333333";
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId,
|
|
driver: "ssh",
|
|
config: {},
|
|
});
|
|
mockAgentService.getById.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterType: "codex_local",
|
|
defaultEnvironmentId: environmentId,
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}`)
|
|
.send({
|
|
adapterType: "process",
|
|
}));
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
|
expect(mockAgentService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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 = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.access.canAssignTasks).toBe(true);
|
|
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
|
|
}, 15_000);
|
|
|
|
it("reports simple-mode task assignment as enabled for active company agent members", async () => {
|
|
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.access.canAssignTasks).toBe(true);
|
|
expect(res.body.access.taskAssignSource).toBe("simple_default");
|
|
}, 15_000);
|
|
|
|
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
|
|
mockAgentService.updatePermissions.mockResolvedValue({
|
|
...baseAgent,
|
|
permissions: { canCreateAgents: true },
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.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");
|
|
});
|
|
|
|
it("preserves disabled skill creation when unrelated permission updates omit that field", async () => {
|
|
mockAgentService.updatePermissions.mockResolvedValue({
|
|
...baseAgent,
|
|
permissions: { canCreateAgents: false, canCreateSkills: false },
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}/permissions`)
|
|
.send({ canCreateAgents: false, canAssignTasks: true }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, {
|
|
canCreateAgents: false,
|
|
canAssignTasks: true,
|
|
});
|
|
expect(res.body.permissions.canCreateSkills).toBe(false);
|
|
});
|
|
|
|
it("rejects CEO permission updates outside the caller company scope", async () => {
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "ceo-agent",
|
|
companyId: "33333333-3333-4333-8333-333333333333",
|
|
runId: "run-1",
|
|
source: "agent_key",
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.patch(`/api/agents/${agentId}/permissions`)
|
|
.send({ canCreateAgents: true, canAssignTasks: true }));
|
|
|
|
// Cross-tenant requests return 404 (not 403) so the status code cannot be
|
|
// used as an existence oracle for other tenants' agent ids.
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toBe("Agent not found");
|
|
expect(mockAgentService.updatePermissions).not.toHaveBeenCalled();
|
|
expect(mockAccessService.setPrincipalPermission).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exposes a dedicated agent route for the inbox mine view", async () => {
|
|
mockIssueService.list.mockResolvedValue([
|
|
{
|
|
id: "issue-1",
|
|
identifier: "PAP-910",
|
|
title: "Inbox follow-up",
|
|
status: "todo",
|
|
},
|
|
]);
|
|
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
runId: "run-1",
|
|
source: "agent_key",
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
|
.get("/api/agents/me/inbox/mine")
|
|
.query({ userId: "board-user" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual([
|
|
{
|
|
id: "issue-1",
|
|
identifier: "PAP-910",
|
|
title: "Inbox follow-up",
|
|
status: "todo",
|
|
},
|
|
]);
|
|
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
|
|
touchedByUserId: "board-user",
|
|
inboxArchivedByUserId: "board-user",
|
|
status: "backlog,todo,in_progress,in_review,blocked,done",
|
|
limit: 500,
|
|
});
|
|
});
|
|
|
|
describe("agent configuration read gate", () => {
|
|
it.each(["configuration", "config-revisions", "config-revisions/55555555-5555-4555-8555-555555555555"])("allows self %s reads without peer configuration grants", async (suffix) => {
|
|
const plaintext = "self-config-value-must-not-leak";
|
|
const adapterConfig = { env: { GENERIC_NAME: { type: "plain", value: plaintext } } };
|
|
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterConfig });
|
|
const revision = { id: "55555555-5555-4555-8555-555555555555", beforeConfig: { adapterConfig }, afterConfig: { adapterConfig } };
|
|
mockAgentService.listConfigRevisions.mockResolvedValue([revision]);
|
|
mockAgentService.getConfigRevision.mockResolvedValue(revision);
|
|
mockAccessService.decide.mockImplementation(async (input) => ({
|
|
allowed: input.action === "agent_config:read" && input.resource.type === "agent" && input.resource.agentId === agentId,
|
|
reason: "allow_self",
|
|
explanation: "Self configuration read",
|
|
}));
|
|
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key" });
|
|
|
|
const response = await request(app).get(`/api/agents/${agentId}/${suffix}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(JSON.stringify(response.body)).not.toContain(plaintext);
|
|
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
|
action: "agent_config:read",
|
|
resource: { type: "agent", companyId, agentId },
|
|
scope: { targetAgentId: agentId },
|
|
}));
|
|
});
|
|
|
|
it("allows a board member without agents:create to read agent configuration", async () => {
|
|
// Board (human) users with company membership but no agents:create
|
|
// grant should still be able to view agent configuration — this is
|
|
// the read-only permission loosening introduced by this PR.
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
|
const plaintextValue = "configuration-value-must-not-leak";
|
|
mockAgentService.getById.mockResolvedValue({
|
|
...baseAgent,
|
|
adapterConfig: {
|
|
env: { GENERIC_NAME: { type: "plain", value: plaintextValue } },
|
|
},
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await request(app).get(`/api/agents/${agentId}/configuration`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.adapterConfig.env).toEqual({
|
|
GENERIC_NAME: { type: "plain", value: "***REDACTED***" },
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(plaintextValue);
|
|
});
|
|
|
|
it("redacts plaintext env values in company configuration-list responses", async () => {
|
|
const plaintextValue = "configuration-list-value-must-not-leak";
|
|
mockAgentService.list.mockResolvedValue([
|
|
{
|
|
...baseAgent,
|
|
adapterConfig: {
|
|
env: { GENERIC_NAME: { type: "plain", value: plaintextValue } },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await request(app).get(`/api/companies/${companyId}/agent-configurations`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body[0].adapterConfig.env).toEqual({
|
|
GENERIC_NAME: { type: "plain", value: "***REDACTED***" },
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(plaintextValue);
|
|
});
|
|
|
|
it("redacts plaintext env values in configuration revisions", async () => {
|
|
const plaintextValue = "revision-value-must-not-leak";
|
|
mockAgentService.listConfigRevisions.mockResolvedValue([
|
|
{
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
beforeConfig: {
|
|
adapterConfig: {
|
|
env: { GENERIC_NAME: { type: "plain", value: plaintextValue } },
|
|
},
|
|
},
|
|
afterConfig: {
|
|
adapterConfig: {
|
|
env: { GENERIC_NAME: plaintextValue },
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await request(app).get(`/api/agents/${agentId}/config-revisions`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body[0].beforeConfig.adapterConfig.env.GENERIC_NAME).toEqual({
|
|
type: "plain",
|
|
value: "***REDACTED***",
|
|
});
|
|
expect(res.body[0].afterConfig.adapterConfig.env.GENERIC_NAME).toEqual({
|
|
type: "plain",
|
|
value: "***REDACTED***",
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(plaintextValue);
|
|
});
|
|
|
|
it("denies an agent actor without configure or suggest grants when reading peer config", async () => {
|
|
// Agent actors must pass the agent configuration read ladder. A peer
|
|
// agent in the same company without agents:configure or
|
|
// agents:suggest-changes must not read another agent's configuration.
|
|
const peerAgentId = "33333333-3333-4333-8333-333333333333";
|
|
const peerAgent = { ...baseAgent, id: peerAgentId };
|
|
mockAgentService.getById.mockImplementation(async (id: string) => {
|
|
if (id === peerAgentId) return peerAgent;
|
|
if (id === agentId) {
|
|
return { ...baseAgent, permissions: { canCreateAgents: false } };
|
|
}
|
|
return null;
|
|
});
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: false,
|
|
reason: "deny_no_grant",
|
|
explanation: "Missing permission: agents:configure or agents:suggest-changes.",
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
runId: "run-1",
|
|
source: "agent_key",
|
|
});
|
|
|
|
const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`);
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
|
action: "agent_config:read",
|
|
resource: { type: "agent", companyId, agentId: peerAgentId },
|
|
scope: { targetAgentId: peerAgentId },
|
|
}));
|
|
});
|
|
|
|
it.each(["agents:read", "agents:configure", "agents:suggest-changes"])("allows an agent actor with %s grant to read peer config", async (permissionKey) => {
|
|
// Suggest-tier authority implies read access so the agent can prepare a
|
|
// consented diff without receiving direct change authority.
|
|
const peerAgentId = "44444444-4444-4444-8444-444444444444";
|
|
const peerAgent = { ...baseAgent, id: peerAgentId };
|
|
mockAgentService.getById.mockImplementation(async (id: string) => {
|
|
if (id === peerAgentId) return peerAgent;
|
|
if (id === agentId) {
|
|
return { ...baseAgent, permissions: { canCreateAgents: false } };
|
|
}
|
|
return null;
|
|
});
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: true,
|
|
reason: "allow_explicit_grant",
|
|
explanation: `Allowed by explicit grant ${permissionKey}.`,
|
|
grant: {
|
|
principalType: "agent",
|
|
principalId: agentId,
|
|
permissionKey,
|
|
scope: null,
|
|
},
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
runId: "run-1",
|
|
source: "agent_key",
|
|
});
|
|
|
|
const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
|
action: "agent_config:read",
|
|
resource: { type: "agent", companyId, agentId: peerAgentId },
|
|
scope: { targetAgentId: peerAgentId },
|
|
}));
|
|
});
|
|
});
|
|
|
|
it("rejects heartbeat cancellation outside the caller company scope", async () => {
|
|
mockHeartbeatService.getRun.mockResolvedValue({
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
companyId: "33333333-3333-4333-8333-333333333333",
|
|
agentId,
|
|
status: "running",
|
|
});
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [companyId],
|
|
});
|
|
|
|
const res = await requestApp(app, (baseUrl) => request(baseUrl).post("/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/cancel").send({}));
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toBe("Heartbeat run not found");
|
|
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
|
return { ...actual, createAgentLifecycle: () => ({
|
|
requestHire: (...args: unknown[]) => mockAgentService.create(...args),
|
|
approveHire: (...args: unknown[]) => mockAgentService.activatePendingApproval(...args),
|
|
terminateAgent: (...args: unknown[]) => mockAgentService.terminate(...args),
|
|
}) };
|
|
});
|