Files
PaperClipAI/server/src/__tests__/agent-adapter-validation-routes.test.ts
T
DottaandPaperclip 59da15066a feat(runner): deliver native Kimi Code (#18391)
Deliver native Kimi Code setup, exact models, scoped credentials, verified packages and runtime packs. Preserve current upstream release attestations and restore verified same-run remote retry dispatch without weakening process ownership.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-30 22:26:15 -05:00

1731 lines
87 KiB
TypeScript

import express from "express";
import os from "node:os";
import path from "node:path";
import { readFile } from "node:fs/promises";
import request from "supertest";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
const mockAgentService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
getConfigRevision: vi.fn(),
rollbackConfigRevision: vi.fn(),
update: vi.fn(),
}));
const mockAdapterPluginStore = vi.hoisted(() => ({
getDisabledAdapterTypes: vi.fn<() => string[]>(() => []),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
syncEnvBindingsForTarget: vi.fn(),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
getBundle: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
exportFiles: vi.fn(),
ensureManagedBundle: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
cancelActiveForAgent: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
}));
const mockManagedAgentProfileService = vi.hoisted(() => ({
requireQualified: vi.fn(),
}));
const mockRemoteAgentProfileService = vi.hoisted(() => ({
requireQualified: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockAntigravityModels = vi.hoisted(() => vi.fn());
vi.mock("../services/antigravity-connection-models.js", () => ({ antigravityConnectionModels: mockAntigravityModels }));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/managed-agent-profiles.js", () => ({
managedAgentProfileService: () => mockManagedAgentProfileService,
}));
vi.mock("../services/remote-agent-profiles.js", () => ({
remoteAgentProfileService: () => mockRemoteAgentProfileService,
}));
function registerModuleMocks() {
vi.doMock("../services/antigravity-connection-models.js", () => ({ antigravityConnectionModels: mockAntigravityModels }));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.doMock("../services/managed-agent-profiles.js", () => ({
managedAgentProfileService: () => mockManagedAgentProfileService,
}));
vi.doMock("../services/remote-agent-profiles.js", () => ({
remoteAgentProfileService: () => mockRemoteAgentProfileService,
}));
// The adapter registry reads the disabled set from this store. Mock it so a
// test can declare an adapter disabled without writing to the real
// ~/.paperclip/adapter-settings.json.
vi.doMock("../services/adapter-plugin-store.js", () => ({
getDisabledAdapterTypes: mockAdapterPluginStore.getDisabledAdapterTypes,
isAdapterDisabled: (type: string) =>
mockAdapterPluginStore.getDisabledAdapterTypes().includes(type),
listAdapterPlugins: () => [],
getAdapterPluginByType: () => undefined,
setAdapterDisabled: vi.fn(),
}));
}
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
};
const missingAdapterType = "missing_adapter_validation_test";
async function createApp(actorOverride: Record<string, unknown> = {}) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
...actorOverride,
};
next();
});
const db = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(async () => [
{
id: "company-1",
requireBoardApprovalForNewAgents: false,
},
]),
})),
})),
};
app.use("/api", agentRoutes(db as any));
app.use(errorHandler);
return app;
}
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();
});
});
}
}
}
async function unregisterTestAdapter(type: string) {
const { unregisterServerAdapter } = await import("../adapters/index.js");
unregisterServerAdapter(type);
}
describe("agent routes adapter validation", () => {
beforeEach(async () => {
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", undefined);
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../routes/agents.js");
registerModuleMocks();
vi.clearAllMocks();
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue([]);
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false });
mockManagedAgentProfileService.requireQualified.mockResolvedValue({
id: "managed-primary",
companyId: "company-1",
enabled: true,
});
mockRemoteAgentProfileService.requireQualified.mockResolvedValue({
id: "agentcore-primary",
companyId: "company-1",
enabled: true,
});
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(async (agent: { adapterConfig: unknown }) => ({
adapterConfig: agent.adapterConfig,
}));
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: String(input.id ?? "11111111-1111-4111-8111-111111111111"),
companyId: "company-1",
name: String(input.name ?? "Agent"),
urlKey: "agent",
role: String(input.role ?? "general"),
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: String(input.adapterType ?? "process"),
adapterConfig: (input.adapterConfig as Record<string, unknown> | undefined) ?? {},
runtimeConfig: (input.runtimeConfig as Record<string, unknown> | undefined) ?? {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
}));
mockAgentService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
name: "Codex",
urlKey: "codex",
role: "engineer",
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
mockAgentService.getConfigRevision.mockResolvedValue(null);
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...(await mockAgentService.getById()),
...patch,
}));
await unregisterTestAdapter("external_test");
await unregisterTestAdapter(missingAdapterType);
});
it("round-trips the protected YAML projection and forwards its revision to the write lock", async () => {
const existing = {
...(await mockAgentService.getById()),
adapterConfig: { model: "old", removeMe: true, env: { DEMO_VALUE: "private-fixture" } },
metadata: { token: "private-metadata-fixture" },
};
mockAgentService.getById.mockResolvedValue(existing);
const app = await createApp();
const loaded = await requestApp(app, base => request(base).get(`/api/agents/${existing.id}/configuration`));
expect(loaded.status).toBe(200);
expect(loaded.body.configurationRevision).toMatch(/^[a-f0-9]{64}$/);
expect(JSON.stringify(loaded.body)).not.toContain("private-fixture");
expect(JSON.stringify(loaded.body)).not.toContain("private-metadata-fixture");
const adapterConfig = { ...loaded.body.adapterConfig, model: "new" };
delete adapterConfig.removeMe;
const saved = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
adapterConfig, replaceAdapterConfig: true, metadata: { ...loaded.body.metadata, label: "new" },
expectedConfigurationRevision: loaded.body.configurationRevision,
}));
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
adapterConfig: expect.objectContaining({ model: "new", env: existing.adapterConfig.env }),
metadata: { ...existing.metadata, label: "new" },
}), expect.objectContaining({ expectedConfigurationRevision: loaded.body.configurationRevision }));
expect(mockAgentService.update.mock.calls[0][1].adapterConfig).not.toHaveProperty("removeMe");
expect(saved.body.configurationRevision).not.toBe(loaded.body.configurationRevision);
expect(JSON.stringify(saved.body)).not.toContain("private-fixture");
expect(JSON.stringify(saved.body)).not.toContain("private-metadata-fixture");
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "agent.updated" }));
});
it("rejects stale and unauthorized YAML saves before calling the update service", async () => {
const existing = await mockAgentService.getById();
const app = await createApp();
const loaded = await requestApp(app, base => request(base).get(`/api/agents/${existing.id}/configuration`));
const stale = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
title: "Stale", expectedConfigurationRevision: "0".repeat(64),
}));
expect(stale.status).toBe(409);
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_missing_grant", explanation: "No configuration permission" });
const denied = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
title: "Denied", expectedConfigurationRevision: loaded.body.configurationRevision,
}));
expect(denied.status).toBe(403);
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("routes narrow roster edits through authorization, config merging and audit history", async () => {
const existing = { ...(await mockAgentService.getById()), adapterConfig: { model: "old", cwd: "/workspace" }, runtimeConfig: { heartbeat: { intervalSec: 900 } } };
mockAgentService.getById.mockResolvedValue(existing);
const app = await createApp();
const response = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({ runtimeSettings: {
harness: "codex_local", model: "new", expected: { model: "old", aiConnection: null },
} }));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.adapterConfig).toMatchObject({ model: "new", cwd: "/workspace" });
expect(response.body.runtimeConfig).toEqual(existing.runtimeConfig);
expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.not.objectContaining({ runtimeSettings: expect.anything() }), expect.objectContaining({ expectedConfiguration: expect.objectContaining({ adapterConfig: existing.adapterConfig }), recordRevision: expect.anything() }));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "agent.updated", entityId: existing.id }));
});
it("refuses a roster edit without configuration permission", async () => {
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_missing_grant", explanation: "No configuration permission" });
const app = await createApp();
const response = await requestApp(app, base => request(base).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ runtimeSettings: {
harness: "codex_local", model: "new", expected: { model: null, aiConnection: null },
} }));
expect(response.status).toBe(403);
expect(mockAgentService.update).not.toHaveBeenCalled();
});
afterEach(async () => {
vi.unstubAllEnvs();
await unregisterTestAdapter("external_test");
await unregisterTestAdapter(missingAdapterType);
});
it("omits the default marker from the native Grok catalog while retaining Legacy defaults", async () => {
const adapters = await import("../adapters/index.js");
const models = [{ id: "grok-build", label: "Default" }, { id: "grok-4.7", label: "Grok 4.7" }];
const list = vi.spyOn(adapters, "listAdapterModels").mockResolvedValue(models);
const refresh = vi.spyOn(adapters, "refreshAdapterModels").mockResolvedValue(models);
try {
const app = await createApp();
for (const suffix of ["", "&refresh=true"]) {
const native = await requestApp(app, baseUrl => request(baseUrl).get(`/api/companies/company-1/adapters/paperclip_runner/models?provider=acpx&acpxAgent=grok${suffix}`));
expect(native.status).toBe(200);
expect(native.body).toEqual([models[1]]);
const legacy = await requestApp(app, baseUrl => request(baseUrl).get(`/api/companies/company-1/adapters/grok_local/models?refresh=${Boolean(suffix)}`));
expect(legacy.status).toBe(200);
expect(legacy.body).toEqual(models);
}
} finally { list.mockRestore(); refresh.mockRestore(); }
});
it("selects and refreshes the runner provider catalog independently", async () => {
const adapters = await import("../adapters/index.js");
const list = vi.spyOn(adapters, "listAdapterModels").mockImplementation(async (type) => [{ id: type, label: type }]);
const refresh = vi.spyOn(adapters, "refreshAdapterModels").mockImplementation(async (type) => [{ id: `${type}-fresh`, label: type }]);
try {
const app = await createApp();
for (const [provider, adapter] of [["acpx", "claude_local"], ["acpx&acpxAgent=grok", "grok_local"], ["codex", "codex_local"], ["opencode", "opencode_local"]]) {
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/companies/company-1/adapters/paperclip_runner/models?provider=${provider}`));
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: adapter, label: adapter }]);
const refreshed = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/companies/company-1/adapters/paperclip_runner/models?provider=${provider}&refresh=true`));
expect(refreshed.status).toBe(200);
expect(refreshed.body).toEqual([{ id: `${adapter}-fresh`, label: adapter }]);
}
const invalid = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/companies/company-1/adapters/paperclip_runner/models?provider=acpx_codex"));
expect(invalid.status).toBe(422);
} finally { list.mockRestore(); refresh.mockRestore(); }
});
it("loads setup-only Antigravity account metadata without consulting another harness", async () => {
const adapters = await import("../adapters/index.js");
const list = vi.spyOn(adapters, "listAdapterModels");
const refresh = vi.spyOn(adapters, "refreshAdapterModels");
const catalog = [{ id: "caller-exact-google-model", label: "Google account model" }];
mockAntigravityModels.mockResolvedValue(catalog);
try {
const app = await createApp();
for (const suffix of ["", "&refresh=true"]) {
const response = await requestApp(app, base => request(base).get(`/api/companies/company-1/adapters/paperclip_runner/models?provider=acpx&acpxAgent=antigravity${suffix}`));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toEqual(catalog);
expect(mockAntigravityModels).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({
companyId: "company-1", binding: { provider: "google", method: "subscription", mode: "responsible_user" },
}));
}
expect(list).not.toHaveBeenCalled();
expect(refresh).not.toHaveBeenCalled();
} finally { list.mockRestore(); refresh.mockRestore(); }
});
it("refuses another provider binding before Antigravity model discovery", async () => {
const app = await createApp();
const binding = encodeURIComponent(JSON.stringify({ provider: "openai", method: "subscription", mode: "responsible_user" }));
const response = await requestApp(app, base => request(base).get(`/api/companies/company-1/adapters/paperclip_runner/models?provider=acpx&acpxAgent=antigravity&aiConnection=${binding}`));
expect(response.status).toBe(422);
expect(response.body.error).toContain("Google runtime connection");
expect(mockAntigravityModels).not.toHaveBeenCalled();
});
it.each(["create", "hire", "edit"])("refuses pending Antigravity %s before persistence", async action => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
const adapterConfig = { provider: "acpx", acpxAgent: "antigravity", model: "exact-antigravity-model" };
const response = action === "edit"
? await requestApp(app, base => request(base).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterType: "paperclip_runner", adapterConfig }))
: await requestApp(app, base => request(base).post(`/api/companies/company-1/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: "Antigravity", adapterType: "paperclip_runner", adapterConfig }));
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_acpx_agent_unavailable" });
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockApprovalService.create).not.toHaveBeenCalled();
});
it.runIf(process.platform === "darwin" && process.arch === "arm64")("saves only the exact company-authorized Antigravity candidate without persisting operator authority", async () => {
const [{ QUALIFIED_ACPX_PROFILES }, distributionJson] = await Promise.all([
import("../vendor/paperclip-runner/index.js"),
readFile(new URL("../../../packages/paperclip-runner/antigravity-distributions.json", import.meta.url), "utf8"),
]);
const distributions = JSON.parse(distributionJson);
const adapterConfig = { provider: "acpx", acpxAgent: "antigravity", model: "exact-antigravity-model" };
const admission = { schema: "paperclip.acpx-candidate-admission.v1", companyId: "company-1", agent: "antigravity",
model: adapterConfig.model, profileDigest: QUALIFIED_ACPX_PROFILES.antigravity.commandDigest,
nativeClosureDigest: `sha256:${distributions.platforms["darwin-arm64"].closureSha256}`,
platform: "darwin", architecture: "arm64", expiresAt: Date.now() + 60_000 };
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", JSON.stringify(admission));
const app = await createApp();
const created = await requestApp(app, base => request(base).post("/api/companies/company-1/agents").send({ name: "Antigravity candidate", adapterType: "paperclip_runner", adapterConfig }));
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(mockAgentService.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterConfig: expect.objectContaining(adapterConfig) }), expect.any(Object));
expect(JSON.stringify(mockAgentService.create.mock.calls)).not.toContain("paperclip.acpx-candidate-admission");
expect(QUALIFIED_ACPX_PROFILES.antigravity.qualificationStatus).toBe("pending");
mockAgentService.create.mockClear();
for (const changed of [{ ...admission, companyId: "another-company" }, { ...admission, model: "another-model" }]) {
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", JSON.stringify(changed));
const denied = await requestApp(app, base => request(base).post("/api/companies/company-1/agents").send({ name: "Antigravity candidate", adapterType: "paperclip_runner", adapterConfig }));
expect(denied.status, JSON.stringify(denied.body)).toBe(422);
}
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("creates agents for dynamically registered external adapter types", async () => {
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["some_other_adapter"]);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "External Agent",
adapterType: "external_test",
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.adapterType).toBe("external_test");
});
it("does not inject CODEX_HOME or OPENAI_API_KEY when creating a keyless codex_local agent", async () => {
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "Codex Agent",
adapterType: "codex_local",
adapterConfig: {},
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(201);
const createInput = mockAgentService.create.mock.calls.at(-1)?.[1] as Record<string, unknown>;
const adapterConfig = createInput.adapterConfig as Record<string, unknown>;
const env = (adapterConfig.env as Record<string, unknown> | undefined) ?? {};
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.CODEX_HOME).toBeUndefined();
});
it("does not re-inject CODEX_HOME or OPENAI_API_KEY when updating a keyless codex_local agent", async () => {
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({
adapterConfig: { model: "gpt-5.4" },
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
const patch = mockAgentService.update.mock.calls.at(-1)?.[1] as Record<string, unknown>;
const adapterConfig = patch.adapterConfig as Record<string, unknown>;
const env = (adapterConfig.env as Record<string, unknown> | undefined) ?? {};
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.CODEX_HOME).toBeUndefined();
});
it("forwards a claude_local→process adapter move that drops the OAuth binding to the service unchanged", async () => {
// The agent has the fixed Claude Code OAuth binding on the claude_local
// adapter. A PATCH moves the agent to the process adapter and sends an empty
// env in the same request. The route must forward the new adapter type and
// the dropped binding to the service without a re-injection, so the
// service-enforced binding invariant sees the removal and rejects it.
const agentId = "11111111-1111-4111-8111-111111111111";
mockAgentService.getById.mockResolvedValue({
id: agentId,
companyId: "company-1",
name: "Claude",
urlKey: "claude",
role: "engineer",
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "claude_local",
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { type: "user_secret_ref", key: "CLAUDE_CODE_OAUTH_TOKEN" } } },
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({
adapterType: "process",
adapterConfig: { env: {} },
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
const patch = mockAgentService.update.mock.calls.at(-1)?.[1] as Record<string, unknown>;
// The route forwards the requested adapter type, so the service can see the
// adapter move.
expect(patch.adapterType).toBe("process");
// The route does not re-inject the fixed binding from the prior config, so
// the service invariant sees the removal.
const env = ((patch.adapterConfig as Record<string, unknown>).env as Record<string, unknown> | undefined) ?? {};
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
});
it("isolates CODEX_HOME when updating a codex_local agent to set its own OPENAI_API_KEY", async () => {
const agentId = "11111111-1111-4111-8111-111111111111";
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({
adapterConfig: {
env: {
OPENAI_API_KEY: "sk-test-key",
},
},
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
const patch = mockAgentService.update.mock.calls.at(-1)?.[1] as Record<string, unknown>;
const adapterConfig = patch.adapterConfig as Record<string, unknown>;
const env = adapterConfig.env as Record<string, unknown>;
expect(env.OPENAI_API_KEY).toBe("sk-test-key");
expect(String(env.CODEX_HOME)).toContain(`/companies/company-1/agents/${agentId}/codex-home`);
});
it("allows codex_local agents to share the host Codex home", async () => {
const app = await createApp();
const sharedHome = path.join(os.homedir(), ".codex");
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "Shared Codex",
adapterType: "codex_local",
adapterConfig: {
env: {
CODEX_HOME: sharedHome,
},
},
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(201);
const createInput = mockAgentService.create.mock.calls.at(-1)?.[1] as Record<string, unknown>;
const adapterConfig = createInput.adapterConfig as Record<string, unknown>;
const env = adapterConfig.env as Record<string, unknown>;
expect(env.CODEX_HOME).toBe(sharedHome);
});
it("isolates CODEX_HOME when a codex_local agent sets its own OPENAI_API_KEY", async () => {
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "Keyed Codex",
adapterType: "codex_local",
adapterConfig: {
env: {
OPENAI_API_KEY: "sk-test-key",
},
},
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(201);
const createInput = mockAgentService.create.mock.calls.at(-1)?.[1] as Record<string, unknown>;
const agentId = String(createInput.id);
const adapterConfig = createInput.adapterConfig as Record<string, unknown>;
const env = adapterConfig.env as Record<string, unknown>;
expect(env.OPENAI_API_KEY).toBe("sk-test-key");
expect(String(env.CODEX_HOME)).toContain(`/companies/company-1/agents/${agentId}/codex-home`);
});
it("restores a saved agent's redacted CODEX_HOME before testing its adapter", async () => {
const agentId = "11111111-1111-4111-8111-111111111111";
const storedHome = "/paperclip/companies/company-1/agents/agent-1/codex-home";
mockAgentService.getById.mockResolvedValue({
...(await mockAgentService.getById()),
id: agentId,
adapterType: "external_test",
adapterConfig: { env: { CODEX_HOME: storedHome } },
});
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
agentId,
adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } } },
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1",
{ env: { CODEX_HOME: storedHome } },
expect.objectContaining({ adapterType: "external_test" }),
);
});
it("rejects redacted-value restoration from an incompatible saved agent", async () => {
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
agentId: "11111111-1111-4111-8111-111111111111",
adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } } },
}),
);
expect(res.status).toBe(422);
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
});
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "Missing Adapter",
adapterType: missingAdapterType,
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(String(res.body.error ?? res.body.message ?? "")).toContain(`Unknown adapter type: ${missingAdapterType}`);
});
it("refuses to create an agent on an adapter the instance has disabled", async () => {
// A disabled adapter is one the instance cannot run (e.g. curated out of
// PAPERCLIP_ADAPTERS). Creating an agent on it "succeeds" and then every
// run of that agent dies at lease time with "not in the configured adapter
// registry", so the refusal belongs here, where it can name the choices.
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["external_test"]);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({ name: "Disabled Harness", adapterType: "external_test" }),
);
expect(res.status, JSON.stringify(res.body)).toBe(422);
const message = String(res.body.error ?? res.body.message ?? "");
expect(message).toContain('Adapter "external_test" is not available on this instance');
// The message must be actionable: it names what CAN be chosen.
expect(message).toMatch(/Available adapters?: .+/);
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("refuses to switch an existing agent onto a disabled adapter", async () => {
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["external_test"]);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterType: "external_test" }),
);
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(String(res.body.error ?? res.body.message ?? "")).toContain(
'Adapter "external_test" is not available on this instance',
);
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("still lets an agent already on a disabled adapter be edited", async () => {
// Disabling an adapter must not make the agents that already use it
// uneditable — only NEW selections of it are refused.
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
});
it.each(["pass", "fail"] as const)("tests saved legacy Codex on a disabled adapter and retains its %s runtime verdict", async (status) => {
const adapterConfig = { model: "gpt-5.4", extraArgs: ["--legacy-setting"], env: { CODEX_HOME: "/saved-codex-home" } };
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterConfig });
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]);
const probe = vi.fn(async () => ({ adapterType: "codex_local", status, testedAt: new Date(0).toISOString(),
checks: status === "fail" ? [{ code: "adapter_auth_missing", level: "error" as const, message: "Sign in to the saved Codex account." }] : [] }));
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({ ...externalAdapter, type: "codex_local", testEnvironment: probe });
try {
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.post("/api/companies/company-1/adapters/codex_local/test-environment")
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: {
...adapterConfig, env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } },
} }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({ adapterType: "codex_local", status });
expect(probe).toHaveBeenCalledOnce();
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ adapterType: "codex_local", config: adapterConfig }));
expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith("company-1", adapterConfig, expect.objectContaining({ adapterType: "codex_local" }));
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockAgentService.update).not.toHaveBeenCalled();
} finally { await unregisterTestAdapter("codex_local"); }
});
it.each(["draft", "explicit legacy", "explicit auto", "explicit native", "changed harness"] as const)("rejects a disabled Codex setup selection for %s", async (mode) => {
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]);
if (mode === "changed harness") mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "external_test" });
const probe = vi.fn();
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({ ...externalAdapter, type: "codex_local", testEnvironment: probe });
try {
const runner = mode === "explicit legacy" ? "legacy" : mode === "explicit auto" ? "auto" : mode === "explicit native" ? "paperclip" : undefined;
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.post("/api/companies/company-1/adapters/codex_local/test-environment")
.send({ adapterConfig: {}, ...(mode === "draft" ? {} : { agentId: "11111111-1111-4111-8111-111111111111" }), ...(runner ? { runner } : {}) }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "agent_runner_unavailable" });
expect(probe).not.toHaveBeenCalled();
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
} finally { await unregisterTestAdapter("codex_local"); }
});
it("still rejects new Codex creation when its adapter is disabled", async () => {
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]);
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.post("/api/companies/company-1/agents").send({ name: "New Codex", adapterType: "codex_local" }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "agent_runner_unavailable" });
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("rechecks selection policy when a saved native adapter switches to an experimentally gated harness", async () => {
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const probe = vi.fn();
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({ ...externalAdapter, type: "paperclip_runner", testEnvironment: probe });
try {
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "cursor" } }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
expect(probe).not.toHaveBeenCalled();
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
} finally { await unregisterTestAdapter("paperclip_runner"); }
});
it("defaults new Codex on a qualified Linux target while the experimental flag is off", async () => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({ name: "Native Codex", adapterType: "codex_local" }),
);
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ runnerResolved: true });
});
it("retains explicit legacy Codex and rejects incompatible automatic settings before persistence", async () => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
const app = await createApp();
const legacy = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Legacy", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }));
expect(legacy.status, JSON.stringify(legacy.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "codex_local", adapterConfig: { extraArgs: ["--search"] } });
mockAgentService.create.mockClear();
const incompatible = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Custom", adapterType: "codex_local", adapterConfig: { extraArgs: ["--search"] } }));
expect(incompatible.status).toBe(422);
expect(incompatible.body.details).toMatchObject({ code: "agent_runner_config_incompatible", fields: ["extraArgs"] });
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it.each(["auto", "legacy"] as const)("creates OpenCode through the public route with %s selection and the experimental flag off", async runner => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
const app = await createApp();
const model = "openrouter/anthropic/claude-sonnet-4.6";
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "OpenCode", adapterType: "opencode_local", runner, adapterConfig: { model } }));
expect(response.status, JSON.stringify(response.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: runner === "legacy" ? "opencode_local" : "paperclip_runner", adapterConfig: runner === "legacy" ? { model } : { provider: "opencode", model, opencodePermissionMode: "allow" } });
});
it("allows model-free OpenCode only as an inactive setup draft", async () => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
const originalCreate = mockAgentService.create.getMockImplementation()!;
mockAgentService.create.mockImplementation(async (...args) => ({ ...await originalCreate(...args), lifecycleHolds: ["setup"] }));
const app = await createApp();
const input = { name: "OpenCode draft", adapterType: "opencode_local", adapterConfig: {} };
const draft = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents")
.send({ ...input, setupId: "00000000-0000-4000-8000-000000000092" }));
expect(draft.status, JSON.stringify(draft.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "" } });
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ setupOnly: true, runnerResolved: true });
mockAgentService.create.mockClear();
const active = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents")
.send({ ...input, setupOnly: true }));
expect(active.status).toBe(422);
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it.each(["auto", "legacy"] as const)("creates Pi through the public route with %s selection and the experimental flag off", async runner => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
const app = await createApp();
const model = "openrouter/anthropic/claude-sonnet-4.6";
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Pi", adapterType: "pi_local", runner, adapterConfig: { model } }));
expect(response.status, JSON.stringify(response.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: runner === "legacy" ? "pi_local" : "paperclip_runner", adapterConfig: runner === "legacy" ? { model } : { provider: "acpx", acpxAgent: "pi", model, piThinkingLevel: "low", acpxPermissionMode: "approve-all" } });
});
it("allows model-free Pi only as an inactive setup draft", async () => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
const originalCreate = mockAgentService.create.getMockImplementation()!;
mockAgentService.create.mockImplementation(async (...args) => ({ ...await originalCreate(...args), lifecycleHolds: ["setup"] }));
const app = await createApp();
const input = { name: "Pi draft", adapterType: "pi_local", adapterConfig: {} };
const draft = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents")
.send({ ...input, setupId: "00000000-0000-4000-8000-000000000092" }));
expect(draft.status, JSON.stringify(draft.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "" } });
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ setupOnly: true, runnerResolved: true });
mockAgentService.create.mockClear();
const active = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents")
.send({ ...input, setupOnly: true }));
expect(active.status).toBe(422);
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("keeps Pi model selection empty when a provisional setup record changes harness", async () => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "process", adapterConfig: {}, status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] });
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterType: "pi_local", runner: "auto", adapterConfig: {} }));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "" } });
});
it("keeps Kimi draft models unset through the public create route", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const originalCreate = mockAgentService.create.getMockImplementation()!;
mockAgentService.create.mockImplementation(async (...args) => ({ ...await originalCreate(...args), lifecycleHolds: ["setup"] }));
const app = await createApp();
const input = { name: "Kimi draft", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "kimi" } };
const draft = await requestApp(app, base => request(base).post("/api/companies/company-1/agents")
.send({ ...input, setupId: "00000000-0000-4000-8000-000000000092" }));
expect(draft.status, JSON.stringify(draft.body)).toBe(201);
const saved = mockAgentService.create.mock.calls.at(-1)?.[1];
expect(saved).toMatchObject(input);
expect(saved.adapterConfig.model).toBeUndefined();
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ setupOnly: true, runnerResolved: true });
mockAgentService.create.mockClear();
const active = await requestApp(app, base => request(base).post("/api/companies/company-1/agents").send(input));
expect(active.status).toBe(422);
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it.each([
["process", undefined, ""],
["kimi_local", "kimi-for-coding", "kimi-for-coding"],
["pi_local", "openrouter/anthropic/claude-sonnet-4.6", ""],
] as const)("preserves Kimi model selection only from the same harness: %s", async (previousAdapterType, previousModel, expectedModel) => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: previousAdapterType,
adapterConfig: previousModel === undefined ? {} : { model: previousModel },
status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] });
const app = await createApp();
const response = await requestApp(app, base => request(base).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "kimi" } }));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner",
adapterConfig: { provider: "acpx", acpxAgent: "kimi", model: expectedModel } });
});
it("persists an explicit Kimi model through the public create route", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
const input = { name: "Kimi", adapterType: "paperclip_runner",
adapterConfig: { provider: "acpx", acpxAgent: "kimi", model: "kimi-for-coding" } };
const response = await requestApp(app, base => request(base).post("/api/companies/company-1/agents").send(input));
expect(response.status, JSON.stringify(response.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject(input);
});
it.each([{ thinking: "high", status: 200 }, { thinking: "medium", status: 422 }, { command: "custom-pi", status: 422 }])("validates saved Pi settings during direct adapter conversion: %j", async ({ status, ...settings }) => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "pi_local", adapterConfig: { model: "openrouter/anthropic/claude-sonnet-4.5", ...settings } });
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterType: "paperclip_runner", adapterConfig: {} }));
expect(response.status, JSON.stringify(response.body)).toBe(status);
if (status === 200) expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.5", piThinkingLevel: "high" } });
else expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("keeps other native provider selection behind its existing flag", async () => {
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Cursor", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "cursor", model: "provider-model" } }));
expect(response.status).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
});
it("allows a new paperclip_runner selection while the rollout flag is on", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "Native Codex",
adapterType: "paperclip_runner",
adapterConfig: {
provider: "codex",
paperclipSkillSync: {
desiredSkills: ["paperclipai/paperclip/paperclip", "company-1/reviewer"],
},
},
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
adapterConfig: expect.objectContaining({
provider: "codex",
model: "gpt-5.6-sol",
codexPermissionMode: "never",
lifecycleMode: "per_turn",
paperclipSkillSync: { desiredSkills: ["company-1/reviewer"] },
}),
}),
expect.any(Object),
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({ adapterType: "paperclip_runner" }),
expect.any(Object),
expect.objectContaining({ entryFile: "AGENTS.md", replaceExisting: false }),
);
});
it.each(["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId", "homeMode"])("refuses an agent granting itself the operator-owned setting %s", async key => {
const app = await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" });
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { [key]: key === "homeMode" ? "preconfigured" : key === "dotBindingId" ? "another-binding" : true } }));
expect(response.status, JSON.stringify(response.body)).toBe(403);
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it.each(["create", "hire"])("refuses agent-authenticated homeMode on %s even with agent-management permission", async mode => {
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), permissions: { canCreateAgents: true } });
const app = await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" });
const response = await requestApp(app, baseUrl => request(baseUrl).post(mode === "create" ? "/api/companies/company-1/agents" : "/api/companies/company-1/agent-hires")
.send({ name: "Runner", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { homeMode: "preconfigured" } }));
expect(response.status, JSON.stringify(response.body)).toBe(403);
expect(response.body.error).toContain("Only an operator can configure Runner homeMode");
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it.each(["replace", "adapter change", "rollback"])("refuses agent removal of operator homeMode through %s", async mode => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-6.1-sol", homeMode: "preconfigured" }, permissions: { canCreateAgents: true } });
mockAgentService.getConfigRevision.mockResolvedValue({ afterConfig: {
adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-6.1-sol" },
} });
const app = await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" });
const response = await requestApp(app, baseUrl => mode === "rollback"
? request(baseUrl).post("/api/agents/11111111-1111-4111-8111-111111111111/config-revisions/33333333-3333-4333-8333-333333333333/rollback").send({})
: request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send(mode === "replace"
? { replaceAdapterConfig: true, adapterConfig: { provider: "codex", model: "gpt-6.1-sol" } }
: { adapterType: "claude_local" }));
expect(response.status, JSON.stringify(response.body)).toBe(403);
expect(response.body.error).toContain("Only an operator can configure Runner homeMode");
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
});
it.each(["agent", "board"] as const)("allows %s ordinary config updates while keeping homeMode operator-owned", async actor => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-6.1-sol", homeMode: "preconfigured" }, permissions: { canCreateAgents: true } });
const app = actor === "agent"
? await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" })
: await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: { provider: "codex", model: "gpt-6.1-sol" }, replaceAdapterConfig: actor === "board" }));
expect(response.status, JSON.stringify(response.body)).toBe(200);
const saved = mockAgentService.update.mock.calls.at(-1)?.[1].adapterConfig;
expect(saved.homeMode).toBe(actor === "agent" ? "preconfigured" : undefined);
});
it("allows an operator to configure Runner homeMode", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-6.1-sol" } });
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: { homeMode: "preconfigured" } }));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ homeMode: "preconfigured" }) }), expect.anything());
});
it("allows an operator to configure Dot attachment access separately", async () => {
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { dotAttachmentAccess: true, dotWorkspaceAccess: false } }));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ dotAttachmentAccess: true, dotWorkspaceAccess: false }) }), expect.anything());
});
it.each(["create", "hire", "convert"])("saves an unpaired Dot configuration independently of the Runner rollout for %s while refusing task admission", async mode => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
const app = await createApp();
const config = { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true };
const response = await requestApp(app, baseUrl => mode === "create"
? request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Dot", adapterType: "paperclip_runner", adapterConfig: config })
: mode === "hire" ? request(baseUrl).post("/api/companies/company-1/agent-hires").send({ name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: config })
: request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterType: "paperclip_runner", replaceAdapterConfig: true, adapterConfig: config }));
expect(response.status, JSON.stringify(response.body)).toBe(mode === "convert" ? 200 : 201);
const saved = mode === "hire" ? response.body.agent : response.body;
expect(saved.adapterConfig).toMatchObject(config);
expect(saved.adapterConfig.model).toBeUndefined();
expect(saved.adapterConfig.codexPermissionMode).toBeUndefined();
const { resolvePaperclipRunnerProviderProfile } = await import("../services/native-runtime/provider-profile.js");
expect(() => resolvePaperclipRunnerProviderProfile(saved.adapterConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" }));
});
it.each(["create", "hire", "convert"])("rejects a new Dot selection when only the general Runner rollout is enabled for %s", async mode => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
const app = await createApp();
const body = { name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } };
const response = await requestApp(app, baseUrl => mode === "convert"
? request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send(body)
: request(baseUrl).post(`/api/companies/company-1/${mode === "hire" ? "agent-hires" : "agents"}`).send(body));
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_dot_disabled" });
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("allows an explicit Dot to Codex change without enabling other native providers", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } });
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: { provider: "codex" }, replaceAdapterConfig: true }));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterConfig: { provider: "codex" } });
});
it.each([{ maxTurns: 5 }, { disableWebSearch: "invalid" }])("rejects a direct Grok conversion with incompatible saved settings %j", async unsupported => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({ ...existing, adapterType: "grok_local",
adapterConfig: { model: "grok-4.7", reasoningEffort: "high", ...unsupported } });
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${existing.id}`)
.send({ adapterType: "paperclip_runner" }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("translates compatible settings during a direct Grok conversion", async () => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({ ...existing, adapterType: "grok_local",
adapterConfig: { model: "grok-4.7", reasoningEffort: "high", command: "grok", disableWebSearch: true } });
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${existing.id}`)
.send({ adapterType: "paperclip_runner" }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
const saved = mockAgentService.update.mock.calls.at(-1)?.[1];
expect(saved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-4.7", reasoningEffort: "high", disableWebSearch: true } });
expect(saved.adapterConfig).not.toHaveProperty("command");
});
it.each([undefined, "acpx"])("preserves an explicitly selected ACPX agent when converting Grok (provider: %s)", async provider => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({ ...existing, adapterType: "grok_local", adapterConfig: { model: "grok-4.7" } });
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${existing.id}`)
.send({ adapterType: "paperclip_runner", replaceAdapterConfig: true,
adapterConfig: { ...(provider ? { provider } : {}), acpxAgent: "claude", model: "claude-opus-5-5" } }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({
adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" },
});
});
it.each(["codex_local", "claude_local"])("upgrades an existing %s through runner selection with its saved settings", async adapterType => {
const existing = await mockAgentService.getById();
const env = { MIGRATION_MARKER: { type: "plain", value: "keep" } };
const model = adapterType === "claude_local" ? "claude-sonnet-4-6" : "gpt-5.4";
mockAgentService.getById.mockResolvedValue({ ...existing, adapterType,
adapterConfig: { model, effort: "medium", env, promptTemplate: "Keep these instructions", timeoutSec: 180 },
});
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ runner: "paperclip" }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
adapterType: "paperclip_runner",
adapterConfig: expect.objectContaining({ model, modelReasoningEffort: "medium", env,
promptTemplate: "Keep these instructions", timeoutSec: 180,
...(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude" } : { provider: "codex" }),
}),
}), expect.objectContaining({ recordRevision: expect.objectContaining({ source: "patch" }) }));
const patch = mockAgentService.update.mock.calls[0][1];
expect(patch).not.toHaveProperty("runner");
expect(patch.adapterConfig).not.toHaveProperty("effort");
expect(patch).not.toHaveProperty("runtimeConfig");
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "agent.updated", entityId: existing.id }));
});
it("leaves a legacy agent unchanged when upgrade settings are incompatible", async () => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({ ...existing, adapterConfig: { model: "gpt-5.4", extraArgs: ["--search"] } });
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ runner: "paperclip" }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "agent_runner_config_incompatible", fields: ["extraArgs"] });
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("upgrades a legacy Claude agent with a turn limit, warning instead of rejecting it", async () => {
const existing = await mockAgentService.getById();
const env = { ANTHROPIC_API_KEY: { type: "plain", value: "synthetic-fixture" } };
mockAgentService.getById.mockResolvedValue({ ...existing, adapterType: "claude_local",
adapterConfig: { model: "claude-opus-5-5", effort: "high", maxTurnsPerRun: 1000,
dangerouslySkipPermissions: true, timeoutSec: 0, graceSec: 15, env } });
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.patch(`/api/agents/${existing.id}`).send({ runner: "paperclip" }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
const patch = mockAgentService.update.mock.calls[0][1];
expect(patch.adapterType).toBe("paperclip_runner");
expect(patch.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5",
modelReasoningEffort: "high", acpxPermissionMode: "approve-all", timeoutSec: 0, graceSec: 15, env });
expect(patch.adapterConfig).not.toHaveProperty("maxTurnsPerRun");
expect(res.body.configurationWarnings).toEqual([expect.objectContaining({ field: "maxTurnsPerRun", code: "runner_option_ignored" })]);
});
it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({
...existing,
adapterType: "codex_local",
adapterConfig: {
model: "gpt-5.5",
paperclipSkillSync: {
desiredSkills: ["paperclipai/paperclip/paperclip", "company-1/reviewer"],
},
},
});
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterType: "paperclip_runner", replaceAdapterConfig: true, adapterConfig: {} }),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterType: "paperclip_runner",
adapterConfig: expect.objectContaining({
provider: "codex",
model: "gpt-5.5",
codexPermissionMode: "never",
lifecycleMode: "per_turn",
paperclipSkillSync: { desiredSkills: ["company-1/reviewer"] },
}),
}),
expect.any(Object),
);
});
it("uses the Codex default when a codex_local conversion has no model", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({
adapterType: "paperclip_runner",
replaceAdapterConfig: true,
adapterConfig: { model: "" },
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({ model: "gpt-5.6-sol" }),
}),
expect.any(Object),
);
});
it("converts Claude to ACPX Claude while retaining its model", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({
...existing,
adapterType: "claude_local",
adapterConfig: { model: "claude-sonnet-4-6" },
});
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({
adapterType: "paperclip_runner",
replaceAdapterConfig: true,
adapterConfig: {},
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" });
});
it("accepts qualified local and managed providers on fresh runner agents and hires", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
const createResponse = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "Native OpenCode",
adapterType: "paperclip_runner",
adapterConfig: {
provider: "opencode",
model: "openrouter/deepseek/deepseek-v4-flash-0731",
},
}),
);
const hireResponse = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agent-hires")
.send({
name: "Native ACPX",
adapterType: "paperclip_runner",
adapterConfig: {
provider: "acpx",
acpxAgent: "claude",
model: "claude-sonnet-5",
},
}),
);
const managedResponse = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "Native Claude Managed",
adapterType: "paperclip_runner",
adapterConfig: {
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
},
}),
);
const agentCoreResponse = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agent-hires")
.send({
name: "Native AgentCore",
adapterType: "paperclip_runner",
adapterConfig: {
provider: "aws_agentcore",
agentCoreProfileId: "agentcore-primary",
agentCoreRetentionAcknowledged: true,
},
}),
);
expect(createResponse.status, JSON.stringify(createResponse.body)).toBe(201);
expect(hireResponse.status, JSON.stringify(hireResponse.body)).toBe(201);
expect(managedResponse.status, JSON.stringify(managedResponse.body)).toBe(201);
expect(agentCoreResponse.status, JSON.stringify(agentCoreResponse.body)).toBe(201);
expect(mockAgentService.create).toHaveBeenCalledTimes(4);
expect(mockManagedAgentProfileService.requireQualified).toHaveBeenCalledWith(
"company-1",
"managed-primary",
);
expect(mockRemoteAgentProfileService.requireQualified).toHaveBeenCalledWith(
"company-1",
"agentcore-primary",
"aws_bedrock_agentcore_harness",
);
});
it.each([
[
"nonexistent Claude profile",
"managed" as const,
{
provider: "claude_managed",
managedProfileId: "managed-missing",
managedAgentsRetentionAcknowledged: true,
},
"not_found" as const,
404,
],
[
"disabled Claude profile",
"managed" as const,
{
provider: "claude_managed",
managedProfileId: "managed-disabled",
managedAgentsRetentionAcknowledged: true,
},
"disabled" as const,
409,
],
[
"drifted AgentCore profile",
"remote" as const,
{
provider: "aws_agentcore",
agentCoreProfileId: "agentcore-drifted",
agentCoreRetentionAcknowledged: true,
},
"drifted" as const,
409,
],
[
"cross-company AgentCore profile",
"remote" as const,
{
provider: "aws_agentcore",
agentCoreProfileId: "agentcore-other-company",
agentCoreRetentionAcknowledged: true,
},
"not_found" as const,
404,
],
])(
"rejects a managed-provider selection with a %s",
async (_label, service, adapterConfig, failure, expectedStatus) => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
const { conflict, notFound } = await import("../errors.js");
const error = failure === "not_found"
? notFound("Managed provider profile not found")
: conflict(
failure === "disabled"
? "Managed provider profile is not enabled and qualified"
: "Managed provider profile configuration does not match its qualified revision",
);
const profileService = service === "managed"
? mockManagedAgentProfileService
: mockRemoteAgentProfileService;
profileService.requireQualified.mockRejectedValueOnce(error);
const response = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "Invalid Managed Selection",
adapterType: "paperclip_runner",
adapterConfig,
}),
);
expect(response.status, JSON.stringify(response.body)).toBe(expectedStatus);
expect(profileService.requireQualified).toHaveBeenCalledWith(
"company-1",
service === "managed"
? adapterConfig.managedProfileId
: adapterConfig.agentCoreProfileId,
...(service === "remote" ? ["aws_bedrock_agentcore_harness"] : []),
);
expect(mockAgentService.create).not.toHaveBeenCalled();
},
);
it("defaults ACPX provider changes to Claude and preserves ordinary historical edits", async () => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({
...existing,
adapterType: "paperclip_runner",
adapterConfig: { provider: "opencode", model: "historical" },
});
const app = await createApp();
const ordinaryEdit = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ name: "Historical Runner" }),
);
const providerChange = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: { provider: "acpx" } }),
);
expect(ordinaryEdit.status, JSON.stringify(ordinaryEdit.body)).toBe(200);
expect(providerChange.status, JSON.stringify(providerChange.body)).toBe(200);
expect(providerChange.body.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "historical" });
});
it.each([
[
"cleared managed profile",
{
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
},
{ managedProfileId: "" },
"paperclip_runner_claude_managed_profile_required",
],
[
"withdrawn managed retention",
{
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
},
{ managedAgentsRetentionAcknowledged: false },
"paperclip_runner_claude_managed_retention_required",
],
[
"unqualified managed model",
{
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
},
{ model: "claude-opus-5" },
"paperclip_runner_claude_managed_model_unqualified",
],
[
"invalid managed spend cap",
{
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
},
{ maxSessionListCostUsd: 0 },
"paperclip_runner_claude_managed_spend_cap_invalid",
],
[
"invalid Codex permission",
{ provider: "codex", codexPermissionMode: "never" },
{ codexPermissionMode: "unrestricted" },
"paperclip_runner_codex_permission_mode_unqualified",
],
])(
"rejects a same-provider Paperclip Runner edit with %s",
async (_label, existingAdapterConfig, adapterConfigPatch, expectedCode) => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({
...existing,
adapterType: "paperclip_runner",
adapterConfig: existingAdapterConfig,
});
const app = await createApp();
const response = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: adapterConfigPatch }),
);
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: expectedCode });
expect(mockAgentService.update).not.toHaveBeenCalled();
},
);
it("validates a merged same-provider runner config and preserves omitted fields", async () => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({
...existing,
adapterType: "paperclip_runner",
adapterConfig: {
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
maxSessionListCostUsd: 1,
},
});
const app = await createApp();
const response = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: { maxSessionListCostUsd: 2 } }),
);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledOnce();
expect(mockAgentService.update.mock.calls[0]?.[1]).toMatchObject({
adapterConfig: {
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
maxSessionListCostUsd: 2,
},
});
});
it("rejects a same-provider rollback to an invalid runner config", async () => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({
...existing,
adapterType: "paperclip_runner",
adapterConfig: {
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
},
});
mockAgentService.getConfigRevision.mockResolvedValue({
afterConfig: {
adapterType: "paperclip_runner",
adapterConfig: {
provider: "claude_managed",
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: false,
},
runtimeConfig: {},
},
});
const app = await createApp();
const response = await requestApp(app, (baseUrl) =>
request(baseUrl).post(
"/api/agents/11111111-1111-4111-8111-111111111111/config-revisions/33333333-3333-4333-8333-333333333333/rollback",
),
);
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({
code: "paperclip_runner_claude_managed_retention_required",
});
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
});
it("keeps an existing paperclip_runner agent editable after the flag is disabled", async () => {
const existing = await mockAgentService.getById();
mockAgentService.getById.mockResolvedValue({
...existing,
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" },
});
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ name: "Native Codex (recorded)" }),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledOnce();
});
});
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),
}) };
});