Files
PaperClipAI/server/src/__tests__/agent-permissions-routes.test.ts
T
DottaandPaperclip 734c265339 Default new Pi agents to Paperclip Runner (#17142)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Users choose a harness during agent setup.
> - Claude, Codex, and OpenCode already receive their qualified Runner
default.
> - Pi has a qualified native integration but new Pi agents still use
the direct adapter.
> - This change gives new Pi agents the native default on supported
targets.
> - Saved agents keep their runner and compatible settings. Legacy
remains available in Advanced.

## Linked Issues or Issue Description

Refs #17120, #14921, #14922, #14923, #14924, and #14956. Related generic
default work: #15422. This PR is the separate Pi follow-up to the merged
OpenCode change.

**Current behavior**

Choosing Pi for a new agent saves the direct Pi adapter. The native Pi
integration remains behind the generic experimental flag.

**Proposed behavior**

Default new Pi agents to ACPX Pi on macOS ARM64/x64 and GNU/Linux x64
with supported glibc. Keep unsupported targets and explicit Legacy
selections on the direct adapter. Require an explicit provider/model
before test or hire. Permit model-free inactive setup drafts.

**Reason and benefit**

Users choose Pi once and receive its qualified runner. Saved agents
retain their identity and settings. Conversions reject unsupported
legacy thinking and launch settings instead of dropping them.

## What Changed

- Add Pi to the shared platform/default contract and server creation
selection.
- Admit native Pi without the generic experimental flag.
- Preserve provider models, credentials, workspace settings, exact
thinking, and saved native policies.
- Add Pi runner controls, branding, setup resume, onboarding, built-in
configuration, and import handling.
- Add managed Pi OpenRouter routing and matching native provider API-key
accounts. Keep subscription and custom gateway exclusions. Clear
alternate credentials and reject custom Pi provider overrides.
- Keep Pi draft models empty instead of applying Claude's default model.
- Verify setup against the pinned Pi runtime. For native Pi on the
official OpenRouter route, reverify the API key live without requiring a
global Legacy Pi CLI.
- Match Pi’s bounded controller close window to its native snapshot
cleanup and lifetime verification, preserving explicit caller deadlines
and the mandatory drain/suspension proof.
- Bind Pi sidecars to the package that owns the pinned native assets,
including source and vendored server layouts. Other providers retain
their existing package authority.
- Keep protocol-owned finish/block tools out of the ACPX forwarded
catalog. The trusted bridge installs these tools itself; the complete
Rust authorization catalog and terminal receipt ledger remain intact.
This fixes actual Pi session startup and applies to other ACPX
consumers.
- Advance Copilot’s shared-source attestation to v58 because it pins the
shared files touched by Pi. Preserve immutable v57 bytes and historical
decoding; Pi’s own release identity is unchanged.
- Add regression coverage and update the paired maps and developer
documentation.

## Verification

- Passed: full repository typecheck and full production build on final
head 656d304783, including the generated attestation repair.
- Passed: focused shared/server/UI suites covering default selection,
platform fallback, conversion preservation, inactive drafts, managed
account compatibility, credential isolation, OpenRouter routing/model
IDs, exact thinking controls, and saved-account selection. Relevant
batches include 375 server/conversion tests, 267 account/routing tests,
and 110 UI setup/account tests.
- Passed: 179 attestation, historical-profile, qualified-profile, and
native-execution contract tests after CI identified stale Copilot source
hashes. The identity generator check passes; the only delta after the
passing Pi browser run is this Copilot attestation/history repair.
- Passed: 145 managed-account tests, including actual service
preparation and API-key adoption/rejection for native Pi without the
Legacy CLI; 6 pinned-installation probe tests.
- Red then green: an actual task failed session.open because its
54-operation catalog included the bridge-owned terminal tools. The
regression with those tools failed before the filter; all 399 Rust
library tests pass after it. The full product retry exposed an
additional package-authority mismatch. Four authority tests failed
before that repair; 11 authority checks and 14 installation/asset checks
pass afterward. Direct real Pi session initialization with the corrected
package root and catalog succeeds. No model prompt was sent by that
diagnostic.
- Red then green: real Pi posted Hello from Pi but its controller closed
before native cleanup could finish. The cleanup-budget test failed with
the old default and passes with the bounded Pi window. All 46 selected
suspension, drain, and package-authority tests pass after building their
native fixture.
- Passed: token gates, paired map checks, Rust formatting, and git diff
--check.
- Passed real browser acceptance on macOS ARM64, generic native runner
flag disabled, source a433748a12 (Pi code unchanged afterward): the UI
selects Paperclip Runner by default, retains Pi and the selected
model/account, and verifies the pinned runtime plus live OpenRouter key.
A fresh UI-created task, assigned to the UI-created native Pi agent,
posted exactly `Hello from Pi` and reached Done without retry or
restart. The native run and workspace finalization succeeded; the
response and Done persisted after Reload.
- Evidence: 8 preserved browser attempts, including the final passing
18-step journey with all screenshots. Prior failures remain recorded.
The earlier failed checkpoint run recovered on server restart; that
recovery is separate from the fresh final task’s clean success.
- Preserved browser failures: missing account control/inherited Claude
draft model; incompatible managed-account/model-prefix handling; generic
npm probe for Pi; Legacy CLI hello check; duplicate terminal tools and
wrong provider-package authority at actual native session.open; a real
Hello from Pi comment followed by premature checkpoint cleanup timeout.
Each failure has retained screenshots/logs and a corresponding focused
repair. The fresh final task proves these repairs end to end.
- The exact Runner Vitest shard was also run locally after the
attestation fix: the stale-hash failure is gone; 1,833 tests passed, 10
skipped, and two Codex retained-control settlement scenarios failed with
`native_cleanup_maintenance_unproven` during terminal ACK/suspension
replay. Both scenarios passed in an isolated rerun (2/2), and the same
19-test file passed in the preceding Linux CI run. The precise
full-shard timing cause is not established. These Codex scenarios are
outside the changed Pi paths; the original local failures are retained;
no complete local Runner-shard pass is claimed. The corresponding Runner
shard now passes on the final head in hosted Linux CI. All 60 final-head
check runs are now terminal and clean (55 successful, 1 neutral, 4
skipped), and the legacy Snyk status is successful.
- Local full repository tests were attempted but not completed:
provisioning briefly confused a workspace symlink, then a later run was
stopped before browser-driven repairs. No full local-suite pass is
claimed. Final-head hosted CI is green and fresh Greptile review is 5/5
with all review threads resolved; earlier jobs canceled by runner
shutdown are not counted as a pass.

## Risks

- Pi cleanup has a finite 45-second default window, accounting for the
native 30-second snapshot cleanup and 5-second lifetime fence. Other
providers and explicit caller deadlines retain their prior behavior.
- Pi requires explicit operator setup with `paperclipai runtime setup
pi` on the execution host. This PR does not add automatic downloads.
- Linux ARM64, Windows, musl, and unqualified targets retain Legacy.
- Legacy thinking levels minimal, medium, and xhigh cannot be translated
exactly. Native max thinking, warm lifecycle, or restricted permissions
cannot be dropped during a return to Legacy.
- Copilot’s bound profile advances to v58 with exact current launch
admission; v57 remains decodable for recovery. No historical identity is
rewritten.
- Existing agents and immutable run descriptors are not migrated. Cloud
Pi availability is unchanged.

## Model Used

OpenAI GPT-6 through Codex with reasoning, code editing, command
execution, and browser use. The exact serving model ID and
context-window size are not exposed in this session.

## 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-10-21 17:10:00 -05:00

2378 lines
84 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("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: "company", companyId },
}));
});
it("allows an agent actor with agents:suggest-changes grant to read peer config", async () => {
// 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 agents:suggest-changes.",
grant: {
principalType: "agent",
principalId: agentId,
permissionKey: "agents:suggest-changes",
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: "company", companyId },
}));
});
});
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),
}) };
});