mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## 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 head656d304783, 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, sourcea433748a12(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>
2378 lines
84 KiB
TypeScript
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),
|
|
}) };
|
|
});
|