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) => config), resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ 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; enableManagedSandboxOnly?: 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 = {}) { const [{ agentRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/agents.js"), vi.importActual("../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("node:http"); const server = createServer(app); try { await new Promise((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((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.get.mockResolvedValue({ defaultEnvironmentId: null }); 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) => ({ 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 | undefined) ?? {}, runtimeConfig: (input.runtimeConfig as Record | 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) => ({ ...(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; const adapterConfig = createInput.adapterConfig as Record; const env = (adapterConfig.env as Record | 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; const adapterConfig = patch.adapterConfig as Record; const env = (adapterConfig.env as Record | 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; // 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).env as Record | 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; const adapterConfig = patch.adapterConfig as Record; const env = adapterConfig.env as Record; 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; const adapterConfig = createInput.adapterConfig as Record; const env = adapterConfig.env as Record; 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; const agentId = String(createInput.id); const adapterConfig = createInput.adapterConfig as Record; const env = adapterConfig.env as Record; 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("selects the native Cursor API controller from the ordinary held Cloud setup card without a Computer", async () => { const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } }; mockInstanceSettingsService.get.mockResolvedValue(settings); mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental); const environments = await import("../services/environments.js"); const findManaged = vi.fn(async () => null); const getEnvironment = vi.fn(async () => null); vi.spyOn(environments, "environmentService").mockReturnValue({ findManagedSandboxEnvironment: findManaged, getById: getEnvironment, listBoundCompanyIds: vi.fn(async () => []) } as never); const existing = { ...(await mockAgentService.getById()), adapterType: "process", adapterConfig: {}, status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] }; mockAgentService.getById.mockResolvedValue(existing); const app = await createApp(); const response = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({ name: "Cursor setup", adapterType: "cursor_cloud", runner: "auto", adapterConfig: {}, metadata: { setupAdapterPending: false }, })); expect(response.status, JSON.stringify(response.body)).toBe(200); expect(response.body).toMatchObject({ lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] }); expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({ adapterType: "paperclip_runner", defaultEnvironmentId: null, adapterConfig: expect.objectContaining({ provider: "cursor_cloud", lifecycleMode: "per_turn" }), }), expect.anything()); expect(findManaged).not.toHaveBeenCalled(); expect(getEnvironment).not.toHaveBeenCalled(); mockAgentService.update.mockClear(); mockAgentService.getById.mockResolvedValue({ ...existing, status: "idle", lifecycleState: "ready", pauseReason: null, lifecycleHolds: [] }); const active = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({ adapterType: "cursor_cloud", runner: "auto", adapterConfig: {}, setupOnly: true, })); expect(active.status).toBe(422); expect(active.body.details?.code).toBe("managed_sandbox_unavailable"); expect(mockAgentService.update).not.toHaveBeenCalled(); const incompleteActive = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({ adapterType: "paperclip_runner", runner: "paperclip", adapterConfig: { provider: "cursor_cloud" }, setupOnly: true, })); expect(incompleteActive.status).toBe(422); expect(incompleteActive.body.details?.code).toBe("paperclip_runner_cursor_cloud_model_required"); expect(mockAgentService.update).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("saves and reopens native Cursor API setup under managed-only policy without a Computer", async () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableManagedSandboxOnly: true }); let existing = { ...(await mockAgentService.getById()), adapterType: "cursor_cloud", adapterConfig: {}, defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] }; mockAgentService.getById.mockImplementation(async () => existing); mockAgentService.update.mockImplementation(async (_id, patch) => { existing = { ...existing, ...patch }; return existing; }); const config = { model: "caller-model", repoUrl: "https://github.com/acme/repo", repoStartingRef: "feature", env: { CURSOR_API_KEY: { type: "secret_ref", secretId: "22222222-2222-4222-8222-222222222222", version: "latest" } } }; const app = await createApp(); const saved = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${existing.id}`) .send({ adapterType: "cursor_cloud", runner: "paperclip", adapterConfig: config })); expect(saved.status, JSON.stringify(saved.body)).toBe(200); expect(saved.body).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "cursor_cloud", model: config.model, repoUrl: config.repoUrl, repoRef: "feature", env: config.env, lifecycleMode: "per_turn", } }); expect(saved.body.adapterConfig).not.toHaveProperty("codexPermissionMode"); expect(saved.body.defaultEnvironmentId).toBeNull(); const reopened = await requestApp(app, baseUrl => request(baseUrl).get(`/api/agents/${existing.id}/configuration`)); expect(reopened.status, JSON.stringify(reopened.body)).toBe(200); expect(reopened.body.adapterConfig).toEqual(saved.body.adapterConfig); const resaved = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${existing.id}`) .send({ adapterType: "paperclip_runner", runner: "paperclip", adapterConfig: reopened.body.adapterConfig })); expect(resaved.status, JSON.stringify(resaved.body)).toBe(200); const { resolvePaperclipRunnerNativeProviderInput } = await import("../services/native-runtime/provider-profile.js"); expect(resolvePaperclipRunnerNativeProviderInput({ backend: "cursor_cloud_api", adapterConfig: resaved.body.adapterConfig })) .toMatchObject({ provider: "cursor_cloud", model: config.model, cursorCloud: { repos: [{ url: config.repoUrl, startingRef: "feature" }] } }); }); 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(); return { ...actual, createAgentLifecycle: () => ({ requestHire: (...args: unknown[]) => mockAgentService.create(...args), }) }; });