mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A plugin can provide Runner execution through an environment driver that declares `supportsTasks: true`. Agent computers use this path. > - Core let an agent without a managed AI connection run on that path. The run then used the account that was installed on the plugin's execution host. > - That is not a supported credential source. When the host Runner did not match, the run failed three times, 30 seconds apart, and then blocked the task with a generic error. The error did not name the fix. > - The plugin cannot refuse these runs itself. `describe` has no connection data, and Core replaces plugin error text before the user sees it. > - This pull request makes a managed AI connection a requirement for every plugin Runner-task environment. Core refuses a run without one before it acquires a lease or calls the plugin. > - The benefit is one clear notice that tells the operator to choose an AI connection, and no wasted retries or plugin work. ## Linked Issues or Issue Description Refs #19326. That pull request makes a Runner revision mismatch retry. It noted that runs without a managed AI connection reach the Runner check on a different path, which still blocks after 3 attempts. This pull request removes that path for plugin Runner-task environments. **Problem or motivation** A `paperclip_runner` agent with no managed AI connection can run in a plugin environment that provides Runner tasks. The run uses the account installed on the execution host. If the host Runner does not match, the native session retries 3 times and then blocks the task with "Plugin-provided Runner execution operation unavailable". The operator cannot tell what to fix. **Proposed solution** Refuse these runs in Core before any lease, plugin call, secret resolution, or provider work. Use one stable reason, `ai_connection_required`, for run admission, the setup probe, and agent verification. Show a task notice that names the fix. **Alternatives considered** A refusal in the plugin. It cannot see the connection at `describe`, which runs first. Its error text is also replaced twice before the user sees it. A new structured plugin error code would still not cover `describe`. ## What Changed - Add `server/src/services/plugin-runner-ai-connection.ts`. It holds the `ai_connection_required` code and message, the probe result, and the manifest lookup for a Runner-task driver. The lookup moves here from `agent-runner-selection.ts`, so run admission and runner selection use the same rule. - `heartbeat/execution-configuration.ts`: refuse a new `paperclip_runner` run in a Runner-task environment when it has no managed AI connection. This also applies when `homeMode: "preconfigured"` drops the connection. The run fails as `configuration_incomplete` with reason `ai_connection_required` and an action URL to the agent's runtime settings. The check runs before observability, secrets, the managed runtime, the workspace, and the lease. A resumed native run keeps its admitted contract. - `recovery/stranded-notice.ts`: add an "AI connection required" task notice. - `run-failure-report.ts`: treat this pre-dispatch refusal as an owner action, not an unexpected failure. - `adapters/registry.ts`: the unmanaged probe for a Runner-task target now returns a failed `ai_connection_required` check. It calls no task operation. Managed probes still go through `testManagedEnvironment`. - `agent-harness-verification.ts` and `agent-lifecycle/application/failure.ts`: classify the check as `ai_connection_required` with a fixed message. Verification waits for the configuration to change instead of retrying. - Docs: `PLUGIN_RUNNER_EXECUTION.md` states the requirement. `PLUGIN_ENVIRONMENT_TASKS.md` no longer mentions installed accounts. `agent-config-ui.md` notes the requirement. - Tests: the "plugin-owned account" heartbeat test described the behavior that this change removes. It now checks the assembled heartbeat refusal: no plugin call, no native boundary, no adapter, no workspace files. ## Verification - `npx vitest run` in `server/` for these files. All tests passed: - `src/__tests__/heartbeat-execution-configuration.test.ts`. New cases: refused without a connection, refused for a preconfigured home, managed passes, resumed native input passes, non-task plugin driver passes. The refused cases also check that observability, secrets, and the managed runtime did not start. - `src/__tests__/heartbeat-plugin-environment.test.ts`. One test, "inherits the instance default environment across companies…", also fails on unmodified `master` in my environment. This change does not touch it, because its driver does not declare `supportsTasks`. - `src/adapters/registry.test.ts`, `src/__tests__/agent-harness-verification.test.ts`, `src/modules/agent-lifecycle/__tests__/diagnostics.test.ts`, `src/services/recovery/stranded-notice.test.ts`, and `src/services/__tests__/run-failure-report.test.ts`. - 17 more suites that use plugin Runner-task environments, with 705 tests: `plugin-runner-task.integration`, `plugin-warm-tasks.integration`, `task-creation-recovery.integration`, `native-startup-cleanup-recovery`, `environment-task-authority.integration`, `plugin-task-credential-writeback`, agent-lifecycle `lifecycle`, `setup`, and `environment-plugins`, `workspace-preparation`, `agent-runner-selection`, `agent-harness-verification-provider-api`, `agent-harness-verification-remote`, `plugin-environment-driver-seam`, `environment-task-runtime`, `agent-test-environment-routes`, and `adapter-registry`. - `pnpm typecheck` in `server/` reported no errors in the changed files. Its only errors came from sandbox-provider packages that my local install did not link. - Not run: `plugin-runner-probe.test.ts`, because it needs a Cargo build. This change does not modify `probePluginRunnerEnvironment`. Also not run: the full `pnpm test:run` and `pnpm build`. CI runs them. ## Risks - Behavior change: an agent in a plugin Runner-task environment with no managed AI connection now stops before dispatch. Before, it ran with the host's installed account. The agent's owner must choose an AI connection. This includes agents with `homeMode: "preconfigured"` in these environments. - Runs that were already admitted and resume from saved native input are not refused. They keep the earlier behavior until they finish. - The check reads the plugin manifest on each new run admission for plugin environments. This is the same lookup that runner selection already does. - This pull request and #19326 both edit `stranded-notice.ts` and `failure.ts`. The changes are additive, but whichever merges second needs a small rebase. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (file edits, shell, test runs). Default context window, extended thinking on. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
1538 lines
79 KiB
TypeScript
1538 lines
79 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AdapterEnvironmentTestContext } from "@paperclipai/adapter-utils";
|
|
import type { ServerAdapterModule } from "../adapters/index.js";
|
|
|
|
const mockAgentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
getChainOfCommand: vi.fn(async () => []),
|
|
}));
|
|
|
|
const mockAccessService = vi.hoisted(() => ({
|
|
canUser: vi.fn(),
|
|
decide: vi.fn(),
|
|
hasPermission: vi.fn(),
|
|
getMembership: vi.fn(async () => null),
|
|
listPrincipalGrants: vi.fn(async () => []),
|
|
}));
|
|
|
|
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 })),
|
|
collectMissingRuntimeBindings: vi.fn(async () => [] as Array<Record<string, unknown>>),
|
|
resolveEnvBindings: vi.fn(async () => ({
|
|
env: {} as Record<string, string>,
|
|
secretKeys: new Set<string>(),
|
|
manifest: [],
|
|
})),
|
|
}));
|
|
|
|
const mockEnvironmentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
releaseLease: vi.fn(),
|
|
listBoundCompanyIds: vi.fn(async () => [] as string[]),
|
|
findManagedSandboxEnvironment: vi.fn(async () => null as Record<string, unknown> | null),
|
|
findKubernetesEnvironment: vi.fn(async () => null as Record<string, unknown> | null),
|
|
}));
|
|
|
|
const mockReleaseRunLease = vi.hoisted(() => vi.fn(async () => undefined));
|
|
const mockEnvironmentRuntime = vi.hoisted(() => ({
|
|
acquireRunLease: vi.fn(),
|
|
realizeWorkspace: vi.fn(),
|
|
supportsTasks: vi.fn(async () => false),
|
|
getDriver: vi.fn(() => ({
|
|
releaseRunLease: mockReleaseRunLease,
|
|
})),
|
|
}));
|
|
|
|
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
|
|
const mockPluginEnvironmentRequiresAgent = vi.hoisted(() => vi.fn(async () => false));
|
|
const mockPluginRunnerTaskDriver = vi.hoisted(() => vi.fn(async (): Promise<{ driverKey: string } | undefined> => undefined));
|
|
vi.mock("../services/plugin-runner-ai-connection.js", async importOriginal => ({
|
|
...await importOriginal<typeof import("../services/plugin-runner-ai-connection.js")>(),
|
|
pluginEnvironmentRunnerTaskDriver: mockPluginRunnerTaskDriver,
|
|
}));
|
|
vi.mock("../services/plugin-environment-driver.js", async importOriginal => ({
|
|
...(await importOriginal<typeof import("../services/plugin-environment-driver.js")>()),
|
|
pluginEnvironmentRequiresAgent: mockPluginEnvironmentRequiresAgent,
|
|
}));
|
|
const mockInstanceSettingsService = vi.hoisted(() => ({
|
|
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
|
|
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false, executionMode: "local" })),
|
|
getExperimental: vi.fn(async (): Promise<{
|
|
enableManagedSandboxOnly: boolean;
|
|
enableOpenAiDot?: boolean;
|
|
}> => ({ enableManagedSandboxOnly: false })),
|
|
}));
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
agentService: () => mockAgentService,
|
|
agentInstructionsService: () => ({}),
|
|
accessService: () => mockAccessService,
|
|
approvalService: () => ({}),
|
|
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
|
companySkillService: () => ({
|
|
listRuntimeSkillEntries: vi.fn(async () => []),
|
|
resolveRequestedSkillKeys: vi.fn(async () => []),
|
|
}),
|
|
budgetService: () => ({}),
|
|
heartbeatService: () => ({
|
|
wakeup: vi.fn(),
|
|
cancelActiveForAgent: vi.fn(),
|
|
}),
|
|
ISSUE_LIST_DEFAULT_LIMIT: 50,
|
|
issueApprovalService: () => ({}),
|
|
issueService: () => ({}),
|
|
logActivity: vi.fn(),
|
|
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
|
workspaceOperationService: () => ({}),
|
|
}));
|
|
|
|
vi.mock("../services/environments.js", () => ({
|
|
environmentService: () => mockEnvironmentService,
|
|
}));
|
|
|
|
vi.mock("../services/secrets.js", () => ({
|
|
secretService: () => mockSecretService,
|
|
}));
|
|
|
|
vi.mock("../services/environment-runtime.js", () => ({
|
|
environmentRuntimeService: () => mockEnvironmentRuntime,
|
|
}));
|
|
|
|
vi.mock("../services/environment-execution-target.js", () => ({
|
|
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
|
|
}));
|
|
|
|
vi.mock("../services/instance-settings.js", () => ({
|
|
instanceSettingsService: () => mockInstanceSettingsService,
|
|
}));
|
|
|
|
vi.mock("../services/adapter-settings.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../services/adapter-settings.js")>()),
|
|
adapterSettingsService: () => ({ getDisabledTypes: async () => [] }),
|
|
}));
|
|
|
|
vi.mock("../services/dot-runner-broker.js", () => ({
|
|
dotRunnerBroker: () => ({ enabled: async () => true, bindingForAgent: async () => null }),
|
|
}));
|
|
|
|
const testEnvironmentSpy = vi.fn();
|
|
|
|
const externalAdapter: ServerAdapterModule = {
|
|
type: "external_test",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: testEnvironmentSpy,
|
|
};
|
|
|
|
// Only the runtime preparation is replaced: it needs a database and a stored
|
|
// grant, and these tests exercise the route's verdict, not credential
|
|
// resolution. Everything else in the module stays real. The same goes for
|
|
// validateAiApiKey — it calls the provider's real endpoint, and these tests
|
|
// direct its verdict instead of the network.
|
|
const mockPrepareManagedAiRuntime = vi.hoisted(() => vi.fn());
|
|
vi.mock("../services/ai-connection-runtime.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../services/ai-connection-runtime.js")>()),
|
|
prepareManagedAiRuntime: mockPrepareManagedAiRuntime,
|
|
withManagedAiProbe: async (_db: unknown, input: unknown, probe: (runtime: unknown) => Promise<unknown>) => {
|
|
const runtime = await mockPrepareManagedAiRuntime(_db, input);
|
|
try { return await probe(runtime); } finally { await runtime.cleanup(); }
|
|
},
|
|
}));
|
|
// Runner selection stays real unless a test overrides the resolved adapter.
|
|
const mockResolveNewAgentRunnerForCompany = vi.hoisted(() => vi.fn());
|
|
vi.mock("../services/agent-runner-selection.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../services/agent-runner-selection.js")>();
|
|
mockResolveNewAgentRunnerForCompany.mockImplementation(actual.resolveNewAgentRunnerForCompany);
|
|
return { ...actual, resolveNewAgentRunnerForCompany: mockResolveNewAgentRunnerForCompany };
|
|
});
|
|
const mockValidateAiApiKey = vi.hoisted(() => vi.fn(async () => undefined));
|
|
vi.mock("../services/ai-api-key-test.js", () => ({ validateAiApiKey: mockValidateAiApiKey }));
|
|
const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined));
|
|
const mockSelectAiConnection = vi.hoisted(() => vi.fn(async (_input: unknown) => ({ connectionId: "copilot-connection", grantId: "copilot-grant" })));
|
|
const mockAiCredential = vi.hoisted(() => vi.fn(async (_selection: unknown) => "synthetic-copilot-token"));
|
|
const mockCopilotModels = vi.hoisted(() => vi.fn(async () => ({ models: [{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }], promptSent: false })));
|
|
const mockCopilotProbe = vi.hoisted(() => vi.fn(async () => ({ status: "verified", promptSent: false })));
|
|
vi.mock("../services/copilot-connection-probe.js", async importOriginal => ({
|
|
...(await importOriginal<object>()), probeCopilotExecutionTarget: mockCopilotProbe, probeCopilotConnection: mockCopilotModels,
|
|
}));
|
|
vi.mock("../services/ai-connections.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../services/ai-connections.js")>()),
|
|
aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed, select: mockSelectAiConnection, credential: mockAiCredential }),
|
|
}));
|
|
|
|
function mockManagedRuntime(method: "api_key" | "subscription") {
|
|
mockPrepareManagedAiRuntime.mockImplementation(
|
|
async (_db: unknown, input: { config: Record<string, unknown> }) => ({
|
|
config: {
|
|
...input.config,
|
|
// The real preparation injects the credential under the provider's
|
|
// env key; the adoption re-verification reads it from there.
|
|
env: { ...(method === "api_key" ? { ANTHROPIC_API_KEY: "sk-ant-test-key" } : {}) },
|
|
managedAiConnection: {
|
|
connectionId: "conn-1",
|
|
grantId: "grant-1",
|
|
provider: "anthropic",
|
|
method,
|
|
mode: "responsible_user",
|
|
responsibleUserId: "local-board",
|
|
identity: "grant-1:local-board:0000000000000000",
|
|
},
|
|
},
|
|
attribution: {
|
|
connectionId: "conn-1",
|
|
grantId: "grant-1",
|
|
provider: "anthropic",
|
|
method,
|
|
mode: "responsible_user",
|
|
responsibleUserId: "local-board",
|
|
},
|
|
accountName: "My Claude Account",
|
|
accountOwnerUserId: "local-board",
|
|
identity: "grant-1:local-board:0000000000000000",
|
|
cleanup: vi.fn(async () => {}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function loadAppModules() {
|
|
return Promise.all([
|
|
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
|
]);
|
|
}
|
|
|
|
async function createApp(actor: Record<string, unknown> = {}) {
|
|
const [{ agentRoutes }, { errorHandler }] = await loadAppModules();
|
|
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,
|
|
...actor,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", agentRoutes({} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
async function unregisterTestAdapter(type: string) {
|
|
const { unregisterServerAdapter } = await import("../adapters/index.js");
|
|
unregisterServerAdapter(type);
|
|
}
|
|
|
|
describe("agent test-environment route", () => {
|
|
beforeAll(async () => {
|
|
vi.resetModules();
|
|
await loadAppModules();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null });
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "local" });
|
|
mockEnvironmentService.findKubernetesEnvironment.mockResolvedValue(null);
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
|
|
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue(null);
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: true,
|
|
reason: "allow_explicit_grant",
|
|
explanation: "Allowed by test grant",
|
|
});
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-1",
|
|
name: "Sandbox QA",
|
|
driver: "sandbox",
|
|
config: { provider: "fake-plugin" },
|
|
});
|
|
// Default to an instance-global environment with no company binding, so the
|
|
// tenant-binding guard passes unless a test overrides it.
|
|
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue([]);
|
|
mockEnvironmentRuntime.acquireRunLease.mockResolvedValue({
|
|
lease: {
|
|
id: "lease-1",
|
|
provider: "daytona",
|
|
providerLeaseId: "provider-lease-1",
|
|
metadata: {
|
|
remoteCwd: "/home/user/paperclip-workspace",
|
|
sandboxId: "sandbox-1",
|
|
sandboxName: "paperclip-probe",
|
|
templateKind: "snapshot",
|
|
templateRef: "snapshot-1",
|
|
},
|
|
},
|
|
leaseContext: {
|
|
executionWorkspaceId: null,
|
|
executionWorkspaceMode: null,
|
|
},
|
|
});
|
|
mockEnvironmentRuntime.realizeWorkspace.mockResolvedValue({
|
|
cwd: "/home/user/paperclip-workspace",
|
|
});
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValue(null);
|
|
testEnvironmentSpy.mockResolvedValue({
|
|
adapterType: "external_test",
|
|
status: "pass",
|
|
checks: [
|
|
{
|
|
code: "host_probe_ran",
|
|
level: "info",
|
|
message: "host probe should not run",
|
|
},
|
|
],
|
|
testedAt: new Date(0).toISOString(),
|
|
});
|
|
await unregisterTestAdapter("external_test");
|
|
const { registerServerAdapter } = await import("../adapters/index.js");
|
|
registerServerAdapter(externalAdapter);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await unregisterTestAdapter("external_test");
|
|
});
|
|
|
|
it("tests the saved Cursor API controller without allocating a Computer under managed-only policy", async () => {
|
|
const { registerServerAdapter, unregisterServerAdapter, getServerAdapter } = await import("../adapters/index.js");
|
|
const previous = getServerAdapter("paperclip_runner");
|
|
const probe = vi.fn(async () => ({ adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
|
|
checks: [{ code: "cursor_cloud_account_ready", level: "info" as const, message: "Synthetic account probe; no prompt." }] }));
|
|
registerServerAdapter({ ...externalAdapter, type: "paperclip_runner", testEnvironment: probe });
|
|
try {
|
|
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const adapterConfig = { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", env: { CURSOR_API_KEY: "synthetic" } };
|
|
mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "paperclip_runner", adapterConfig, defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
|
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
const app = await createApp();
|
|
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({ agentId: id, adapterConfig });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(response.body).toMatchObject({ status: "warn", checks: [{ code: "cursor_cloud_account_ready" }] });
|
|
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null, config: adapterConfig }));
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.realizeWorkspace).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
|
|
const denied = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({ agentId: id, adapterConfig });
|
|
expect(denied.status).toBe(422);
|
|
expect(denied.body.details?.code ?? denied.body.code).toBe("provider_api_kubernetes_execution_unsupported");
|
|
expect(probe).toHaveBeenCalledOnce();
|
|
} finally { unregisterServerAdapter("paperclip_runner"); if (previous) registerServerAdapter(previous); }
|
|
});
|
|
|
|
async function mockCursorSwitchProbes() {
|
|
const { getServerAdapter } = await import("../adapters/index.js");
|
|
const legacy = vi.spyOn(getServerAdapter("cursor_cloud"), "testEnvironment").mockImplementation(async () => ({
|
|
adapterType: "cursor_cloud", status: "pass", checks: [], testedAt: new Date().toISOString(),
|
|
}));
|
|
const native = vi.spyOn(getServerAdapter("paperclip_runner"), "testEnvironment").mockImplementation(async () => ({
|
|
adapterType: "paperclip_runner", status: "warn", testedAt: new Date().toISOString(),
|
|
checks: [{ code: "cursor_cloud_account_ready", level: "info", message: "Synthetic no-prompt probe" }],
|
|
}));
|
|
return { legacy, native, restore: () => { legacy.mockRestore(); native.mockRestore(); } };
|
|
}
|
|
|
|
function savedCursorSwitchFixture() {
|
|
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const environmentId = "22222222-2222-4222-8222-222222222222";
|
|
const adapterConfig = { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", env: {} };
|
|
mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "paperclip_runner",
|
|
adapterConfig, runtimeConfig: {}, defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
const envVars = { CURSOR_API_KEY: { type: "secret_ref", secretId: "computer-key" } };
|
|
mockEnvironmentService.getById.mockResolvedValue({ id: environmentId, name: "Chosen Computer",
|
|
driver: "local", config: {}, envVars });
|
|
mockSecretService.resolveEnvBindings.mockResolvedValue({ env: { CURSOR_API_KEY: "chosen-computer-key" },
|
|
secretKeys: new Set(["CURSOR_API_KEY"]), manifest: [] });
|
|
return { id, environmentId, adapterConfig, envVars };
|
|
}
|
|
|
|
it.each([false, true])("tests a saved native Cursor's explicit Legacy switch on the selected Computer (legacyPrompt=%s)", async legacyPrompt => {
|
|
const probes = await mockCursorSwitchProbes();
|
|
const probe = probes.legacy;
|
|
try {
|
|
const f = savedCursorSwitchFixture();
|
|
const config = { ...f.adapterConfig, ...(legacyPrompt ? { promptTemplate: "Legacy prompt" } : {}) };
|
|
const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
|
.send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId, adapterConfig: config });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ environmentName: "Chosen Computer", executionTarget: null,
|
|
config: expect.objectContaining({ model: "caller-model", env: { CURSOR_API_KEY: "chosen-computer-key" },
|
|
...(legacyPrompt ? { promptTemplate: "Legacy prompt" } : {}) }) }));
|
|
expect(probe.mock.calls[0][0].config).not.toHaveProperty("provider");
|
|
expect(mockSecretService.resolveEnvBindings).toHaveBeenCalledWith("company-1", f.envVars,
|
|
expect.objectContaining({ consumerType: "environment", consumerId: f.environmentId }));
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(probes.native).not.toHaveBeenCalled();
|
|
} finally { probes.restore(); }
|
|
});
|
|
|
|
it("fails closed on a foreign Computer before credentials for an explicit Legacy switch", async () => {
|
|
const probes = await mockCursorSwitchProbes();
|
|
try {
|
|
const f = savedCursorSwitchFixture();
|
|
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
|
|
const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
|
.send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId,
|
|
adapterConfig: { ...f.adapterConfig, promptTemplate: "Legacy prompt" } });
|
|
expect(response.status).toBe(403);
|
|
expect(mockSecretService.resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
|
|
expect(mockSecretService.resolveEnvBindings).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(probes.legacy).not.toHaveBeenCalled();
|
|
expect(probes.native).not.toHaveBeenCalled();
|
|
} finally { probes.restore(); }
|
|
});
|
|
|
|
it("keeps managed-only policy for a Legacy switch rather than testing its API controller", async () => {
|
|
const probes = await mockCursorSwitchProbes();
|
|
const probe = probes.legacy;
|
|
try {
|
|
const f = savedCursorSwitchFixture();
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
|
const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
|
.send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId, adapterConfig: f.adapterConfig });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(response.body).toMatchObject({ status: "fail", checks: expect.arrayContaining([
|
|
expect.objectContaining({ code: "managed_sandbox_unavailable", level: "error" }),
|
|
]) });
|
|
expect(probe).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(probes.native).not.toHaveBeenCalled();
|
|
} finally { probes.restore(); }
|
|
});
|
|
|
|
it("keeps an explicit Paperclip Cursor test API-only despite a selected Computer", async () => {
|
|
const probes = await mockCursorSwitchProbes();
|
|
const probe = probes.native;
|
|
try {
|
|
const f = savedCursorSwitchFixture();
|
|
const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
|
|
mockInstanceSettingsService.get.mockResolvedValue(settings);
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental);
|
|
const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
|
.send({ agentId: f.id, runner: "paperclip", environmentId: f.environmentId, adapterConfig: f.adapterConfig });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null,
|
|
config: expect.objectContaining({ provider: "cursor_cloud" }) }));
|
|
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
|
|
expect(mockSecretService.resolveEnvBindings).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(probes.legacy).not.toHaveBeenCalled();
|
|
} finally { probes.restore(); }
|
|
});
|
|
|
|
it.each([
|
|
{ patch: { model: "" }, code: "paperclip_runner_cursor_cloud_model_required" },
|
|
{ patch: { command: "untrusted" }, code: "paperclip_runner_cursor_cloud_config_invalid" },
|
|
])("returns a useful validation error before Computer or credential work for invalid native Cursor input ($code)", async ({ patch, code }) => {
|
|
const app = await createApp();
|
|
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
|
|
adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", ...patch },
|
|
});
|
|
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
|
expect(response.body.details?.code).toBe(code);
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.realizeWorkspace).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([undefined, "auto"] as const)("tests the prospective managed Cursor API default without converting saved Legacy agents (%s)", async runner => {
|
|
const { getServerAdapter } = await import("../adapters/index.js");
|
|
const native = getServerAdapter("paperclip_runner");
|
|
const probe = vi.spyOn(native, "testEnvironment").mockImplementation(async () => ({ adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
|
|
checks: [{ code: "cursor_cloud_account_ready", level: "info" as const, message: "Synthetic account probe; no prompt." }] }));
|
|
try {
|
|
const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
|
|
mockInstanceSettingsService.get.mockResolvedValue(settings);
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental);
|
|
const adapterConfig = { model: "caller-model", repoUrl: "https://github.com/example/qualification", env: { CURSOR_API_KEY: "synthetic" } };
|
|
const app = await createApp();
|
|
const response = await request(app).post("/api/companies/company-1/adapters/cursor_cloud/test-environment").send({ runner, adapterConfig });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(response.body).toMatchObject({ adapterType: "paperclip_runner", checks: [{ code: "cursor_cloud_account_ready" }] });
|
|
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null,
|
|
config: expect.objectContaining({ provider: "cursor_cloud", model: "caller-model" }) }));
|
|
expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "cursor_cloud", adapterConfig, defaultEnvironmentId: null });
|
|
const legacy = await request(app).post("/api/companies/company-1/adapters/cursor_cloud/test-environment").send({ agentId: id, adapterConfig });
|
|
expect(legacy.status).toBe(422);
|
|
expect(legacy.body.details?.code).toBe("managed_sandbox_unavailable");
|
|
expect(probe).toHaveBeenCalledOnce();
|
|
} finally { probe.mockRestore(); }
|
|
});
|
|
|
|
it("accepts a managed Dot environment without allocating an idle sandbox", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
|
enableManagedSandboxOnly: false,
|
|
enableOpenAiDot: true,
|
|
});
|
|
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
const app = await createApp();
|
|
try {
|
|
const res = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
|
.send({ adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body.checks).toContainEqual(expect.objectContaining({ code: "dot_controller", level: "info" }));
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it("fails the Cloud Dot check without a managed sandbox instead of probing the host", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
|
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud");
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
|
enableManagedSandboxOnly: false,
|
|
enableOpenAiDot: true,
|
|
});
|
|
const app = await createApp();
|
|
try {
|
|
const res = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
|
.send({ adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body.status).toBe("fail");
|
|
expect(res.body.checks).toContainEqual(expect.objectContaining({ code: "dot_controller", level: "error" }));
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it("lets an authorized agent editor discover Copilot models", async () => {
|
|
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" });
|
|
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
|
|
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1" });
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }]);
|
|
expect(response.headers["cache-control"]).toBe("no-store");
|
|
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent_config:update" }));
|
|
expect(mockSelectAiConnection).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
|
companyId: "company-1", userId: "local-board", agentId: "agent-1", runnerProvider: "acpx", acpxAgent: "copilot",
|
|
binding: { provider: "github", method: "api_key", mode: "responsible_user" }, allowUninstalledShared: false,
|
|
}));
|
|
expect(mockCopilotModels).toHaveBeenCalledExactlyOnceWith(expect.anything(), "company-1", "synthetic-copilot-token", null, undefined, expect.anything());
|
|
});
|
|
|
|
it.each([
|
|
["board viewer", { type: "board" }],
|
|
["agent reader", { type: "agent", agentId: "reader-agent", companyId: "company-1", onBehalfOfUserId: "reader-user",
|
|
onBehalfOfMemberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }] }],
|
|
])("refuses a %s discovery probe before resolving a shared token", async (_label, actor) => {
|
|
mockAccessService.decide.mockImplementation(async ({ action }) => ({
|
|
allowed: action === "agent_config:read", explanation: "No configuration update grant",
|
|
}));
|
|
mockAccessService.canUser.mockResolvedValue(false);
|
|
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" });
|
|
const binding = { provider: "github", method: "api_key", mode: "shared",
|
|
connectionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", grantId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" };
|
|
const response = await request(await createApp(actor)).get("/api/companies/company-1/adapters/paperclip_runner/models")
|
|
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1",
|
|
environmentId: "33333333-3333-4333-8333-333333333333", aiConnection: JSON.stringify(binding) });
|
|
expect(response.status).toBe(403);
|
|
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent_config:update" }));
|
|
expect(mockSelectAiConnection).not.toHaveBeenCalled();
|
|
expect(mockAiCredential).not.toHaveBeenCalled();
|
|
expect(mockCopilotModels).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([undefined, "33333333-3333-4333-8333-333333333333"])("uses the saved or editor-selected Copilot environment (%s)", async environmentId => {
|
|
const savedEnvironmentId = "22222222-2222-4222-8222-222222222222";
|
|
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", defaultEnvironmentId: savedEnvironmentId });
|
|
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
|
|
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1", ...(environmentId ? { environmentId } : {}) });
|
|
expect(response.status).toBe(200);
|
|
expect(mockCopilotModels).toHaveBeenCalledExactlyOnceWith(expect.anything(), "company-1", "synthetic-copilot-token",
|
|
environmentId ?? savedEnvironmentId, undefined, expect.objectContaining({ targetAgentId: "agent-1" }));
|
|
});
|
|
|
|
it("refuses model discovery for a foreign agent before selecting credentials", async () => {
|
|
mockAgentService.getById.mockResolvedValue({ id: "agent-foreign", companyId: "company-2" });
|
|
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
|
|
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-foreign" });
|
|
expect(response.status).toBe(404);
|
|
expect(mockSelectAiConnection).not.toHaveBeenCalled();
|
|
expect(mockAiCredential).not.toHaveBeenCalled();
|
|
expect(mockCopilotModels).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps saved connection access enforced before Copilot model probing", async () => {
|
|
const { forbidden } = await import("../errors.js");
|
|
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" });
|
|
mockSelectAiConnection.mockRejectedValueOnce(forbidden("Saved connection access denied"));
|
|
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
|
|
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1" });
|
|
expect(response.status).toBe(403);
|
|
expect(mockAiCredential).not.toHaveBeenCalled();
|
|
expect(mockCopilotModels).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tests the instance default sandbox when the agent inherits its environment", async () => {
|
|
const environmentId = "11111111-1111-4111-8111-111111111111";
|
|
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: environmentId });
|
|
const target = {
|
|
kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
|
|
providerKey: "daytona", runner: { execute: vi.fn() },
|
|
};
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValue(target);
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {}, environmentId: null });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target }));
|
|
expect(mockReleaseRunLease).toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{ selection: "omitted", environmentId: undefined, expectedId: "22222222-2222-4222-8222-222222222222" },
|
|
{ selection: "cleared", environmentId: null, expectedId: "11111111-1111-4111-8111-111111111111" },
|
|
{ selection: "replaced", environmentId: "33333333-3333-4333-8333-333333333333", expectedId: "33333333-3333-4333-8333-333333333333" },
|
|
])("resolves the saved agent environment when the request selection is $selection", async ({ environmentId, expectedId }) => {
|
|
const agentId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: agentId, companyId: "company-1", adapterType: "external_test",
|
|
adapterConfig: {}, runtimeConfig: {}, defaultEnvironmentId: "22222222-2222-4222-8222-222222222222",
|
|
});
|
|
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
mockEnvironmentService.getById.mockImplementation(async (id) => ({
|
|
id, name: "Selected sandbox", driver: "sandbox", status: "active", config: { provider: "fake-plugin" },
|
|
}));
|
|
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona", runner: { execute: vi.fn() } };
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValue(target);
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ agentId, adapterConfig: {}, ...(environmentId === undefined ? {} : { environmentId }) });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({
|
|
agentId, environment: expect.objectContaining({ id: expectedId }),
|
|
}));
|
|
expect(mockSecretService.resolveAdapterConfigForRuntime).toHaveBeenCalledWith(
|
|
"company-1", {}, expect.objectContaining({ consumerType: "environment", consumerId: expectedId }),
|
|
expect.any(Object),
|
|
);
|
|
expect(testEnvironmentSpy).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target }));
|
|
});
|
|
|
|
it("keeps an explicit local override ahead of the instance sandbox default", async () => {
|
|
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
mockEnvironmentService.getById.mockResolvedValue({ id: "33333333-3333-4333-8333-333333333333", name: "Local", driver: "local", status: "active", config: {} });
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ environmentId: "33333333-3333-4333-8333-333333333333" });
|
|
expect(res.status).toBe(200);
|
|
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null }));
|
|
});
|
|
|
|
it("uses the managed sandbox when managed-only execution has no configured default", async () => {
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
|
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue({ id: "11111111-1111-4111-8111-111111111111" });
|
|
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona", runner: { execute: vi.fn() } };
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValue(target);
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ environmentId: null });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(testEnvironmentSpy).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target }));
|
|
});
|
|
|
|
it.each([null, "33333333-3333-4333-8333-333333333333"])("verifies Copilot on forced Kubernetes before the host selection (%s)", async environmentId => {
|
|
const kubernetesId = "11111111-1111-4111-8111-111111111111";
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
|
|
mockEnvironmentService.findKubernetesEnvironment.mockResolvedValue({ id: kubernetesId });
|
|
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "kubernetes", runner: { execute: vi.fn() } };
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValue(target);
|
|
mockPrepareManagedAiRuntime.mockImplementation(async (_db, input) => ({
|
|
config: { ...input.config, env: { COPILOT_GITHUB_TOKEN: "private-fixture" } },
|
|
attribution: { provider: "github", method: "api_key" }, cleanup: vi.fn(),
|
|
}));
|
|
const app = await createApp();
|
|
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
|
|
environmentId, adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
|
|
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" },
|
|
});
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(response.body.status).toBe("pass");
|
|
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: kubernetesId }) }));
|
|
expect(mockCopilotProbe).toHaveBeenCalledExactlyOnceWith("private-fixture", target, "gpt-5.6-luna");
|
|
expect(mockReleaseRunLease).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("refuses a Copilot test before credentials when forced Kubernetes is unavailable", async () => {
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
|
|
const app = await createApp();
|
|
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
|
|
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
|
|
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" },
|
|
});
|
|
expect(response.status).toBe(422);
|
|
expect(response.body.details.code).toBe("copilot_environment_unavailable");
|
|
expect(mockPrepareManagedAiRuntime).not.toHaveBeenCalled();
|
|
expect(mockCopilotProbe).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("checks the inherited environment's company before resolving credentials", async () => {
|
|
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {} });
|
|
expect(res.status).toBe(403);
|
|
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never probes the host when an inherited environment is missing", async () => {
|
|
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
mockEnvironmentService.getById.mockResolvedValue(null);
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {} });
|
|
expect(res.body.checks).toEqual([expect.objectContaining({ code: "environment_not_found" })]);
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not probe locally when managed-only execution has no default or sandbox", async () => {
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {} });
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.details.code).toBe("managed_sandbox_unavailable");
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tests a prospective adapter switch before saving the agent", async () => {
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1",
|
|
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
|
});
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", adapterConfig: { model: "prospective-model" } });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(testEnvironmentSpy).toHaveBeenCalledWith(expect.objectContaining({ config: { model: "prospective-model" } }));
|
|
});
|
|
|
|
it("requires agent update permission to test a prospective adapter switch", async () => {
|
|
mockAgentService.getById.mockResolvedValue({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", adapterType: "codex_local", adapterConfig: {} });
|
|
mockAccessService.decide.mockImplementation(async ({ action }) => ({ allowed: action !== "agent_config:update", reason: "test", explanation: "Update denied" }));
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", adapterConfig: {} });
|
|
expect(res.status).toBe(403);
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["CURSOR_API_KEY", "KIMI_MODEL_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", "MINIMAX_API_KEY"])("accepts %s as a probe-only credential", async (key) => {
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ testCredentials: { [key]: "probe-only-key" } });
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({});
|
|
expect(testEnvironmentSpy.mock.calls[0]?.[0].config.env).toEqual({ [key]: "probe-only-key" });
|
|
});
|
|
|
|
it("maps the Hermes gateway probe key without passing it to persistence", async () => {
|
|
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
|
const previous = getServerAdapter("hermes_gateway");
|
|
unregisterServerAdapter("hermes_gateway");
|
|
registerServerAdapter({ ...externalAdapter, type: "hermes_gateway" });
|
|
try {
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/hermes_gateway/test-environment")
|
|
.send({ adapterConfig: { apiBaseUrl: "https://hermes.example.com" }, testCredentials: { API_SERVER_KEY: "gateway-probe-key" } });
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({ apiBaseUrl: "https://hermes.example.com" });
|
|
expect(testEnvironmentSpy.mock.calls[0]?.[0].config.apiKey).toBe("gateway-probe-key");
|
|
expect(JSON.stringify(res.body)).not.toContain("gateway-probe-key");
|
|
} finally {
|
|
unregisterServerAdapter("hermes_gateway");
|
|
if (previous) registerServerAdapter(previous);
|
|
}
|
|
});
|
|
|
|
// The managed-adoption verdict, all three ways. An api_key account is
|
|
// re-verified against the provider's endpoint at adoption — the same check
|
|
// its save performed, catching a key revoked since — but never through the
|
|
// CLI-lane hello probe, which a clean machine without a provider CLI can
|
|
// never pass (the regression that walled off onboarding's API-key path in
|
|
// the nightly release smoke). A stored subscription login still needs the
|
|
// hello probe: only a real turn proves the runtime lane can consume it.
|
|
it("adopts an api_key connection on the engine's verdict plus a live key check, without a CLI hello probe", async () => {
|
|
mockManagedRuntime("api_key");
|
|
// A sentinel in claude_local's slot: the forced CLI-lane fallback would
|
|
// land here, so the fix is proven by this never being consulted — not by
|
|
// whatever verdict the host machine's real adapter would return.
|
|
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
|
const previous = getServerAdapter("claude_local");
|
|
unregisterServerAdapter("claude_local");
|
|
const cliProbeSpy = vi.fn(async () => ({
|
|
adapterType: "claude_local",
|
|
status: "fail" as const,
|
|
checks: [{ code: "adapter_command_missing", level: "error" as const, message: 'Command not found in PATH: "claude"' }],
|
|
testedAt: new Date(0).toISOString(),
|
|
}));
|
|
registerServerAdapter({ ...externalAdapter, type: "claude_local", testEnvironment: cliProbeSpy });
|
|
try {
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: { cwd: "/" },
|
|
aiConnection: { provider: "anthropic", method: "api_key", mode: "responsible_user" },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe("pass");
|
|
expect(JSON.stringify(res.body)).not.toContain("ai_connection_validation_incomplete");
|
|
expect(res.body.checks.map((check: { code: string }) => check.code)).toContain("ai_connection_api_key_reverified");
|
|
expect(mockValidateAiApiKey).toHaveBeenCalledWith("anthropic", "sk-ant-test-key");
|
|
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
|
|
expect(cliProbeSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
unregisterServerAdapter("claude_local");
|
|
if (previous) registerServerAdapter(previous);
|
|
}
|
|
});
|
|
|
|
it("fails adoption of an api_key connection the provider no longer accepts", async () => {
|
|
mockManagedRuntime("api_key");
|
|
const { unprocessable } = await import("../errors.js");
|
|
mockValidateAiApiKey.mockRejectedValueOnce(unprocessable("The provider rejected this API key.", {
|
|
code: "ai_connection_api_key_rejected",
|
|
}));
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: { cwd: "/" },
|
|
aiConnection: { provider: "anthropic", method: "api_key", mode: "responsible_user" },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe("fail");
|
|
expect(res.body.checks.map((check: { code: string }) => check.code)).toContain("ai_connection_api_key_rejected");
|
|
expect(mockMarkAuthenticationFailed).toHaveBeenCalledWith(expect.objectContaining({
|
|
companyId: "company-1",
|
|
attribution: expect.objectContaining({ connectionId: "conn-1", grantId: "grant-1" }),
|
|
}));
|
|
});
|
|
|
|
it("still fails subscription adoption when no hello probe can run", async () => {
|
|
mockManagedRuntime("subscription");
|
|
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
|
const previous = getServerAdapter("claude_local");
|
|
unregisterServerAdapter("claude_local");
|
|
const cliProbeSpy = vi.fn(async (_context: AdapterEnvironmentTestContext) => ({
|
|
adapterType: "claude_local",
|
|
status: "fail" as const,
|
|
checks: [{ code: "adapter_command_missing", level: "error" as const, message: 'Command not found in PATH: "claude"' }],
|
|
testedAt: new Date(0).toISOString(),
|
|
}));
|
|
registerServerAdapter({ ...externalAdapter, type: "claude_local", testEnvironment: cliProbeSpy });
|
|
try {
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: { cwd: "/" },
|
|
aiConnection: { provider: "anthropic", method: "subscription", mode: "responsible_user" },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe("fail");
|
|
expect(res.body.checks.map((check: { code: string }) => check.code)).toContain("ai_connection_validation_incomplete");
|
|
expect(cliProbeSpy).toHaveBeenCalledTimes(1);
|
|
expect(cliProbeSpy.mock.calls[0]?.[0]?.config.engine).toBe("cli");
|
|
} finally {
|
|
unregisterServerAdapter("claude_local");
|
|
if (previous) registerServerAdapter(previous);
|
|
}
|
|
});
|
|
|
|
it("passes the selected Antigravity managed home through ordinary Test and accepts its native admission receipt", async () => {
|
|
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
|
const previous = getServerAdapter("paperclip_runner");
|
|
const adapterConfig = { provider: "acpx", acpxAgent: "antigravity", model: "exact-google-model" };
|
|
const binding = { provider: "google", method: "subscription", mode: "responsible_user" };
|
|
const agentId = "11111111-1111-4111-8111-111111111111";
|
|
mockAgentService.getById.mockResolvedValue({ id: agentId, companyId: "company-1", adapterType: "paperclip_runner",
|
|
adapterConfig, runtimeConfig: { aiConnection: binding }, createdByUserId: "local-board", pauseReason: "setup" });
|
|
mockPrepareManagedAiRuntime.mockImplementation(async (_db: unknown, input: { config: Record<string, unknown> }) => ({
|
|
config: { ...input.config, env: { GEMINI_HOME: "/selected-private-home/provider" }, managedAiConnection: { method: "subscription" } },
|
|
home: "/selected-private-home", cleanup: vi.fn(), accountName: "My Google subscription", attribution: { provider: "google" },
|
|
}));
|
|
const nativeProbe = vi.fn(async () => ({ adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date(0).toISOString(),
|
|
checks: [{ code: "antigravity_metadata_verified", level: "info" as const, message: "Native account and model accepted without a prompt." }] }));
|
|
unregisterServerAdapter("paperclip_runner");
|
|
registerServerAdapter({ ...externalAdapter, type: "paperclip_runner", testEnvironment: nativeProbe });
|
|
try {
|
|
const app = await createApp();
|
|
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
|
.send({ adapterConfig, aiConnection: binding, agentId });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(response.body.status).toBe("pass");
|
|
expect(nativeProbe).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ companyId: "company-1", managedAiCredentialHome: "/selected-private-home/provider" }));
|
|
expect(response.body.checks).toContainEqual(expect.objectContaining({ code: "antigravity_metadata_verified" }));
|
|
expect(response.body.checks.some((check: { code: string }) => check.code.includes("hello_probe"))).toBe(false);
|
|
} finally {
|
|
unregisterServerAdapter("paperclip_runner");
|
|
if (previous) registerServerAdapter(previous);
|
|
}
|
|
});
|
|
|
|
it("passes one-shot provider credentials only to the probe, never persistence normalization", async () => {
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: { env: { KEEP: "value" } }, testCredentials: { OPENROUTER_API_KEY: "probe-only-key" } });
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({ env: { KEEP: "value" } });
|
|
expect(testEnvironmentSpy.mock.calls[0]?.[0].config.env).toEqual({ KEEP: "value", OPENROUTER_API_KEY: "probe-only-key" });
|
|
expect(JSON.stringify(res.body)).not.toContain("probe-only-key");
|
|
});
|
|
|
|
it("rejects non-provider variables in one-shot credentials", async () => {
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ testCredentials: { NODE_OPTIONS: "--require unsafe" } });
|
|
expect(res.status).toBe(400);
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not fall back to a host probe when a requested environment cannot produce an execution target", async () => {
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: {},
|
|
environmentId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
expect(res.body).toMatchObject({
|
|
adapterType: "external_test",
|
|
status: "warn",
|
|
checks: [
|
|
{
|
|
code: "environment_target_unsupported",
|
|
level: "warn",
|
|
message: 'Adapter "external_test" is not allowed in "Sandbox QA" environments.',
|
|
},
|
|
],
|
|
});
|
|
expect(mockReleaseRunLease).toHaveBeenCalledWith({
|
|
environment: expect.objectContaining({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
name: "Sandbox QA",
|
|
driver: "sandbox",
|
|
}),
|
|
lease: expect.objectContaining({
|
|
id: "lease-1",
|
|
}),
|
|
status: "failed",
|
|
});
|
|
});
|
|
|
|
it("returns a diagnostic result instead of probing the host when the requested environment is missing", async () => {
|
|
// The route reads the environment more than once: the tenant-binding guard
|
|
// loads it, then the execution-context resolver loads it. Return null for
|
|
// every read so the missing-environment path is stable.
|
|
mockEnvironmentService.getById.mockResolvedValue(null);
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: {},
|
|
environmentId: "22222222-2222-4222-8222-222222222222",
|
|
});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(res.body).toMatchObject({
|
|
adapterType: "external_test",
|
|
status: "warn",
|
|
checks: [
|
|
{
|
|
code: "environment_not_found",
|
|
level: "warn",
|
|
message: "Selected environment was not found. The test did not run.",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("runs the adapter probe against the resolved sandbox target on the happy path and releases the lease on success", async () => {
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValueOnce({
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/home/user/paperclip-workspace",
|
|
providerKey: "fake-plugin",
|
|
runner: { execute: vi.fn() },
|
|
});
|
|
testEnvironmentSpy.mockResolvedValueOnce({
|
|
adapterType: "external_test",
|
|
status: "pass",
|
|
checks: [
|
|
{
|
|
code: "external_test_hello_probe_passed",
|
|
level: "info",
|
|
message: "OK",
|
|
},
|
|
],
|
|
testedAt: new Date(0).toISOString(),
|
|
});
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: {},
|
|
environmentId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
|
|
// Test leases boot fresh and stay debuggable: never resume a retained
|
|
// agent lease, archive (not delete) the sandbox on release.
|
|
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
applyCustomImageTemplate: true,
|
|
configuredEnvironment: expect.objectContaining({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
config: { provider: "fake-plugin" },
|
|
}),
|
|
// The Test lease re-checks the company binding, so a binding change
|
|
// between the route guard and the lease cannot open a foreign sandbox.
|
|
assertCompanyBinding: true,
|
|
environment: expect.objectContaining({
|
|
config: expect.objectContaining({
|
|
reuseLease: false,
|
|
archiveOnRelease: true,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({
|
|
executionTarget: expect.objectContaining({
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
}),
|
|
environmentName: "Sandbox QA",
|
|
});
|
|
expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" });
|
|
expect(res.body.checks).toEqual([
|
|
expect.objectContaining({
|
|
code: "sandbox_test_identity",
|
|
level: "info",
|
|
message: 'Environment test identity for "Sandbox QA".',
|
|
detail: expect.stringContaining("paperclipLeaseId=lease-1"),
|
|
}),
|
|
expect.objectContaining({
|
|
code: "external_test_hello_probe_passed",
|
|
level: "info",
|
|
message: "OK",
|
|
}),
|
|
]);
|
|
expect(res.body.checks[0].detail).toContain("providerLeaseId=provider-lease-1");
|
|
expect(res.body.checks[0].detail).toContain("provider=daytona");
|
|
expect(res.body.checks[0].detail).toContain("sandboxId=sandbox-1");
|
|
expect(res.body.checks[0].detail).toContain("sandboxName=paperclip-probe");
|
|
expect(res.body.checks[0].detail).toContain("snapshotRef=snapshot-1");
|
|
expect(mockReleaseRunLease).toHaveBeenCalledWith({
|
|
environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
|
lease: expect.objectContaining({ id: "lease-1" }),
|
|
status: "released",
|
|
});
|
|
});
|
|
|
|
it("releases the lease as failed and returns a diagnostic when realizeWorkspace throws", async () => {
|
|
mockEnvironmentRuntime.realizeWorkspace.mockRejectedValueOnce(
|
|
new Error("workspace realization failed"),
|
|
);
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: {},
|
|
environmentId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
expect(res.body).toMatchObject({
|
|
adapterType: "external_test",
|
|
status: "fail",
|
|
checks: [
|
|
expect.objectContaining({
|
|
code: "environment_workspace_realize_failed",
|
|
level: "error",
|
|
}),
|
|
],
|
|
});
|
|
expect(mockReleaseRunLease).toHaveBeenCalledWith({
|
|
environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
|
lease: expect.objectContaining({ id: "lease-1" }),
|
|
status: "failed",
|
|
});
|
|
});
|
|
|
|
describe("environment envVars merge", () => {
|
|
const environmentId = "11111111-1111-4111-8111-111111111111";
|
|
const sandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/home/user/paperclip-workspace",
|
|
providerKey: "fake-plugin",
|
|
runner: { execute: vi.fn() },
|
|
};
|
|
|
|
it("merges resolved environment envVars under the agent adapterConfig env", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId: "company-1",
|
|
name: "Sandbox QA",
|
|
driver: "sandbox",
|
|
config: { provider: "fake-plugin" },
|
|
envVars: {
|
|
CLAUDE_CODE_OAUTH_TOKEN: { type: "secret_ref", secretId: "secret-1" },
|
|
FOO: { type: "plain", value: "env-foo" },
|
|
PAPERCLIP_API_KEY: { type: "plain", value: "must-not-flow" },
|
|
},
|
|
});
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValueOnce(sandboxExecutionTarget);
|
|
mockSecretService.resolveEnvBindings.mockResolvedValueOnce({
|
|
env: { CLAUDE_CODE_OAUTH_TOKEN: "resolved-token", FOO: "env-foo" },
|
|
secretKeys: new Set(["CLAUDE_CODE_OAUTH_TOKEN"]),
|
|
manifest: [],
|
|
});
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: { env: { FOO: "agent-foo" } },
|
|
environmentId,
|
|
});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockSecretService.resolveEnvBindings).toHaveBeenCalledWith(
|
|
"company-1",
|
|
{
|
|
CLAUDE_CODE_OAUTH_TOKEN: { type: "secret_ref", secretId: "secret-1" },
|
|
FOO: { type: "plain", value: "env-foo" },
|
|
},
|
|
expect.objectContaining({
|
|
consumerType: "environment",
|
|
consumerId: environmentId,
|
|
}),
|
|
);
|
|
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
|
|
// Environment env is the base layer; the agent's own env wins on conflicts.
|
|
expect(testEnvironmentSpy.mock.calls[0]?.[0]?.config?.env).toEqual({
|
|
CLAUDE_CODE_OAUTH_TOKEN: "resolved-token",
|
|
FOO: "agent-foo",
|
|
});
|
|
expect(res.body.status).toBe("pass");
|
|
});
|
|
|
|
it("skips env vars with missing secret bindings and fails the test", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId: "company-1",
|
|
name: "Sandbox QA",
|
|
driver: "sandbox",
|
|
config: { provider: "fake-plugin" },
|
|
envVars: {
|
|
MISSING_TOKEN: { type: "secret_ref", secretId: "secret-gone" },
|
|
GOOD: { type: "plain", value: "ok" },
|
|
},
|
|
});
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValueOnce(sandboxExecutionTarget);
|
|
mockSecretService.collectMissingRuntimeBindings.mockResolvedValueOnce([
|
|
{
|
|
consumerType: "environment",
|
|
consumerId: environmentId,
|
|
configPath: "env.MISSING_TOKEN",
|
|
envKey: "MISSING_TOKEN",
|
|
secretId: "secret-gone",
|
|
secretName: "Gone",
|
|
},
|
|
]);
|
|
mockSecretService.resolveEnvBindings.mockResolvedValueOnce({
|
|
env: { GOOD: "ok" },
|
|
secretKeys: new Set<string>(),
|
|
manifest: [],
|
|
});
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {}, environmentId });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
// The unresolved key is excluded from resolution; the rest still flows.
|
|
expect(mockSecretService.resolveEnvBindings).toHaveBeenCalledWith(
|
|
"company-1",
|
|
{ GOOD: { type: "plain", value: "ok" } },
|
|
expect.objectContaining({ consumerType: "environment" }),
|
|
);
|
|
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
|
|
expect(testEnvironmentSpy.mock.calls[0]?.[0]?.config?.env).toEqual({ GOOD: "ok" });
|
|
// A missing binding blocks real dispatch, so the test reports fail even
|
|
// though the adapter probe itself passed.
|
|
expect(res.body.status).toBe("fail");
|
|
expect(res.body.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "environment_env_binding_missing",
|
|
level: "error",
|
|
message: expect.stringContaining("MISSING_TOKEN"),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("includes environment env checks when the environment cannot produce an execution target", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: environmentId,
|
|
companyId: "company-1",
|
|
name: "Sandbox QA",
|
|
driver: "sandbox",
|
|
config: { provider: "fake-plugin" },
|
|
envVars: {
|
|
MISSING_TOKEN: { type: "secret_ref", secretId: "secret-gone" },
|
|
},
|
|
});
|
|
mockSecretService.collectMissingRuntimeBindings.mockResolvedValueOnce([
|
|
{
|
|
consumerType: "environment",
|
|
consumerId: environmentId,
|
|
configPath: "env.MISSING_TOKEN",
|
|
envKey: "MISSING_TOKEN",
|
|
secretId: "secret-gone",
|
|
secretName: "Gone",
|
|
},
|
|
]);
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {}, environmentId });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
expect(res.body.status).toBe("fail");
|
|
expect(res.body.checks).toEqual([
|
|
expect.objectContaining({ code: "environment_target_unsupported", level: "warn" }),
|
|
expect.objectContaining({ code: "environment_env_binding_missing", level: "error" }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("driver guard after runner selection", () => {
|
|
const pluginEnvironment = {
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
companyId: "company-1",
|
|
name: "Agent computer",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: { pluginKey: "acme.computers", driverKey: "tasks", driverConfig: {} },
|
|
};
|
|
const savedAgentId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
|
|
beforeEach(() => {
|
|
mockEnvironmentService.getById.mockResolvedValue(pluginEnvironment);
|
|
mockResolveNewAgentRunnerForCompany.mockClear();
|
|
});
|
|
|
|
it("reports the missing agent prerequisite without acquiring a lease or probing the host", async () => {
|
|
mockPluginEnvironmentRequiresAgent.mockResolvedValueOnce(true);
|
|
mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/codex_local/test-environment")
|
|
.send({ adapterConfig: {}, environmentId: pluginEnvironment.id });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body).toMatchObject({ status: "warn", checks: [{ code: "environment_agent_required", level: "warn" }] });
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("checks the plugin driver against the resolved Runner adapter, not the requested harness", async () => {
|
|
mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" } });
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/claude_local/test-environment")
|
|
.send({ adapterConfig: {}, environmentId: pluginEnvironment.id });
|
|
expect(mockResolveNewAgentRunnerForCompany).toHaveBeenCalledWith(expect.anything(), "company-1",
|
|
expect.objectContaining({ adapterType: "claude_local", defaultEnvironmentId: pluginEnvironment.id }));
|
|
// The driver check passes, and the resolved Runner leases the plugin
|
|
// environment. The probe stops at the controlled task-capability check.
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({
|
|
adapterType: "paperclip_runner",
|
|
environment: expect.objectContaining({ id: pluginEnvironment.id, driver: "plugin" }),
|
|
}));
|
|
expect(mockEnvironmentRuntime.supportsTasks).toHaveBeenCalledWith({ lease: expect.objectContaining({ id: "lease-1" }) });
|
|
expect(res.body).toMatchObject({
|
|
adapterType: "paperclip_runner",
|
|
status: "fail",
|
|
checks: [expect.objectContaining({ detail: "Plugin environment does not provide native Runner task execution" })],
|
|
});
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{ name: "without a managed AI connection", adapterConfig: { provider: "codex" }, message: "Choose an AI connection" },
|
|
{ name: "with a preconfigured Runner home", adapterConfig: { provider: "codex", homeMode: "preconfigured" }, message: "isolated Runner home" },
|
|
])("refuses a Runner-task environment $name before secrets or a lease", async ({ adapterConfig, message }) => {
|
|
mockPluginRunnerTaskDriver.mockResolvedValueOnce({ driverKey: "tasks" });
|
|
mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig });
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/codex_local/test-environment")
|
|
.send({ adapterConfig: {}, environmentId: pluginEnvironment.id });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body).toMatchObject({ adapterType: "paperclip_runner", status: "fail",
|
|
checks: [{ code: "ai_connection_required", level: "error", message: expect.stringContaining(message) }] });
|
|
expect(mockPluginRunnerTaskDriver).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: pluginEnvironment.id }));
|
|
expect(mockSecretService.resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still rejects a plugin environment when selection keeps a legacy adapter, before resolving secrets", async () => {
|
|
mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "external_test", adapterConfig: {} });
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: { env: { FOO: "bar" } }, environmentId: pluginEnvironment.id });
|
|
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
|
expect(res.body.error).toContain('Environment driver "plugin" is not allowed here');
|
|
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
|
|
expect(mockSecretService.resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("re-resolves a saved adapter that cannot run on the requested environment", async () => {
|
|
mockAgentService.getById.mockResolvedValue({ id: savedAgentId, companyId: "company-1", adapterType: "external_test", adapterConfig: {}, runtimeConfig: {} });
|
|
mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "external_test", adapterConfig: {} });
|
|
const app = await createApp();
|
|
await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ agentId: savedAgentId, adapterConfig: {}, environmentId: pluginEnvironment.id });
|
|
expect(mockResolveNewAgentRunnerForCompany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps a saved adapter's execution when it can run on the requested environment", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue({ ...pluginEnvironment, driver: "local", config: {} });
|
|
mockAgentService.getById.mockResolvedValue({ id: savedAgentId, companyId: "company-1", adapterType: "external_test", adapterConfig: {}, runtimeConfig: {} });
|
|
const app = await createApp();
|
|
await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ agentId: savedAgentId, adapterConfig: {}, environmentId: pluginEnvironment.id });
|
|
expect(mockResolveNewAgentRunnerForCompany).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("tenant-binding guard", () => {
|
|
async function postForeignEnvironmentTest() {
|
|
const app = await createApp();
|
|
return request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({
|
|
adapterConfig: { env: { FOO: "bar" } },
|
|
environmentId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
}
|
|
|
|
function expectCompanyMismatch(res: request.Response) {
|
|
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
|
expect(JSON.stringify(res.body)).toContain("environment_company_mismatch");
|
|
// The guard rejects before any secret resolution, target resolution,
|
|
// sandbox lease, or adapter test runs.
|
|
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
|
|
expect(mockSecretService.resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
}
|
|
|
|
it("rejects an active environment bound to another company", async () => {
|
|
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
|
|
expectCompanyMismatch(await postForeignEnvironmentTest());
|
|
});
|
|
|
|
it("rejects an archived environment bound to another company without revealing its status", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-2",
|
|
name: "Sandbox QA",
|
|
driver: "sandbox",
|
|
status: "archived",
|
|
config: { provider: "fake-plugin" },
|
|
});
|
|
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
|
|
expectCompanyMismatch(await postForeignEnvironmentTest());
|
|
});
|
|
|
|
it("rejects a disallowed-driver environment bound to another company without revealing its driver", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-2",
|
|
name: "Plugin Env",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {},
|
|
});
|
|
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
|
|
expectCompanyMismatch(await postForeignEnvironmentTest());
|
|
});
|
|
|
|
it("allows an instance-global environment with no company binding", async () => {
|
|
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue([]);
|
|
const res = await postForeignEnvironmentTest();
|
|
// The guard passes and the route proceeds to secret resolution.
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows an environment bound to the caller company", async () => {
|
|
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-1"]);
|
|
const res = await postForeignEnvironmentTest();
|
|
// The guard passes and the route proceeds to secret resolution.
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("managed-sandbox-only redirect", () => {
|
|
const localEnvironmentId = "33333333-3333-4333-8333-333333333333";
|
|
const managedSandboxEnvironment = {
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
companyId: null,
|
|
name: "Managed sandbox",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "fake-plugin" },
|
|
};
|
|
const localEnvironment = {
|
|
id: localEnvironmentId,
|
|
companyId: null,
|
|
name: "Local host",
|
|
driver: "local",
|
|
status: "active",
|
|
config: {},
|
|
};
|
|
|
|
it("redirects a local-environment Test onto the managed sandbox and never probes the host", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(localEnvironment);
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
|
enableManagedSandboxOnly: true,
|
|
});
|
|
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue(
|
|
managedSandboxEnvironment,
|
|
);
|
|
mockResolveEnvironmentExecutionTarget.mockResolvedValueOnce({
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/home/user/paperclip-workspace",
|
|
providerKey: "fake-plugin",
|
|
runner: { execute: vi.fn() },
|
|
});
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {}, environmentId: localEnvironmentId });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
// The Test leases and probes the managed sandbox the real run uses, not
|
|
// the local host that the agent default still names.
|
|
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
environment: expect.objectContaining({ id: managedSandboxEnvironment.id }),
|
|
}),
|
|
);
|
|
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
|
|
expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({
|
|
executionTarget: expect.objectContaining({ kind: "remote", transport: "sandbox" }),
|
|
environmentName: "Managed sandbox",
|
|
});
|
|
});
|
|
|
|
it("fails closed when the policy is on and no managed sandbox environment exists", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(localEnvironment);
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
|
enableManagedSandboxOnly: true,
|
|
});
|
|
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue(null);
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {}, environmentId: localEnvironmentId });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
// No fall back to a host probe: the Test reports fail-closed.
|
|
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(res.body.status).toBe("fail");
|
|
expect(res.body.checks).toEqual([
|
|
expect.objectContaining({ code: "managed_sandbox_unavailable", level: "error" }),
|
|
]);
|
|
});
|
|
|
|
it("probes the local host when the managed-sandbox-only policy is off", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(localEnvironment);
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
|
enableManagedSandboxOnly: false,
|
|
});
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
|
.send({ adapterConfig: {}, environmentId: localEnvironmentId });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
// Legacy behavior: a local environment probes the host with no redirect
|
|
// and no sandbox lease.
|
|
expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
|
|
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
|
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
|
|
expect(testEnvironmentSpy.mock.calls[0]?.[0]?.executionTarget ?? null).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
|
return { ...actual, createAgentLifecycle: () => ({
|
|
}) };
|
|
});
|