Files
PaperClipAI/server/src/__tests__/agent-permissions-routes.test.ts
T
DottaandPaperclip 86b365f992 feat(auth): add independent agent configuration read permission (#18766)
## 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>
2026-11-03 07:26:27 -06:00

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),
}) };
});