mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
fix(runner): validate inherited environments and replace stale sandbox binaries (#13677)
Resolve the effective environment for account adoption and adapter tests. Preserve saved-agent overrides when the request omits environmentId, and treat explicit null as inheritance from the instance. Reject sandbox runners that lack unlimited-runtime and connection-lease-renewal capabilities. Stage the bundled runner before launch when the image binary is stale. Add regression coverage for environment precedence, fail-closed validation, adapter switches, API-key reverification, and runner artifact fallback. Document the operational workaround for older controllers. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
86b7ee992c
commit
9335b7db10
10 files changed
+340
-30
No files matched your search
@@ -41,3 +41,21 @@ back to direct outbound WSS or a legacy callback bridge. Same-host native runs
|
||||
keep loopback `ws://`, and other remote providers may use direct outbound
|
||||
`wss://` only when an operator explicitly configures a reachable Paperclip
|
||||
runner URL. Legacy adapters retain their existing transport paths.
|
||||
|
||||
## Runner image compatibility
|
||||
|
||||
A sandbox image can outlive its Paperclip controller version. Before reusing its
|
||||
preinstalled runner, Paperclip checks the binary contract, selected transport,
|
||||
and `durableSessionCapabilities` for `unlimited_runtime` and
|
||||
`connection_lease_renewal`. Some older contract-v2 runners reject the controller's
|
||||
zero lifetime despite reporting a compatible binary contract. Paperclip stages and verifies its bundled
|
||||
runner instead; it can still reuse the image's compatible provider CLI. Explicit
|
||||
remote runner artifacts undergo the same checks. This metadata gate does not
|
||||
change the PRP version or invalidate existing durable session manifests.
|
||||
|
||||
For a controller deployed before this capability check, operators can set
|
||||
`PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` in the **server process environment** to a
|
||||
current runner binary built for the sandbox's OS and architecture. This bypasses
|
||||
image discovery and stages that binary for each run. The agent's environment
|
||||
variables do not configure this controller setting. Remove the override after
|
||||
deploying the capability check so automatic artifact selection resumes.
|
||||
@@ -90,6 +90,13 @@ save a healthy connection. Reconnect preserves the connection ID, bindings,
|
||||
customized name, and access settings. A completed connection remains even if
|
||||
subsequent agent creation fails or is cancelled.
|
||||
|
||||
Account adoption during Save and the agent runtime test use the selected agent
|
||||
environment, or the instance default when no override is set. An unavailable
|
||||
remote environment blocks validation rather than probing the server host. The
|
||||
runtime test accepts the form’s prospective adapter selection before it is saved.
|
||||
For a saved-agent test, omitting `environmentId` uses the agent’s saved override.
|
||||
Sending `environmentId: null` tests a change back to the instance default.
|
||||
|
||||
## Runtime isolation
|
||||
|
||||
`prepareManagedAiRuntime` is shared by runs, environment tests, and adoption.
|
||||
|
||||
@@ -119,6 +119,7 @@ fn build_metadata() -> serde_json::Value {
|
||||
"packageName": "@paperclipai/paperclip-runner",
|
||||
"packageVersion": env!("CARGO_PKG_VERSION"),
|
||||
"binaryContractVersion": 2,
|
||||
"durableSessionCapabilities": ["unlimited_runtime", "connection_lease_renewal"],
|
||||
"nativeExecutionVersion": 1,
|
||||
"harnessDriverVersion": 1,
|
||||
"prp": {
|
||||
@@ -398,6 +399,10 @@ mod tests {
|
||||
let metadata = build_metadata();
|
||||
assert_eq!(metadata["schema"], RUNNERD_BUILD_METADATA_SCHEMA);
|
||||
assert_eq!(metadata["binaryContractVersion"], 2);
|
||||
assert_eq!(
|
||||
metadata["durableSessionCapabilities"],
|
||||
json!(["unlimited_runtime", "connection_lease_renewal"])
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["prpTransportModes"],
|
||||
json!(["dial_ws_loopback", "dial_wss", "listen_ws"])
|
||||
|
||||
@@ -65,6 +65,7 @@ const mockApprovalService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
getExperimental: vi.fn(async () => ({ enableNativeRunner: false })),
|
||||
}));
|
||||
|
||||
@@ -45,6 +45,7 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({
|
||||
|
||||
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })),
|
||||
}));
|
||||
@@ -181,6 +182,9 @@ describe("agent test-environment route", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null });
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
|
||||
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue(null);
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
@@ -239,6 +243,139 @@ describe("agent test-environment route", () => {
|
||||
await unregisterTestAdapter("external_test");
|
||||
});
|
||||
|
||||
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({
|
||||
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("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)
|
||||
|
||||
@@ -53,6 +53,7 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({
|
||||
|
||||
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn(async () => null));
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })),
|
||||
}));
|
||||
|
||||
@@ -668,6 +668,60 @@ describe("managed AI connections", () => {
|
||||
expect((await service.select(selectionInput)).grant.id).toBe(account.grantId);
|
||||
});
|
||||
|
||||
it("validates runner account adoption in the inherited sandbox and refuses an unavailable target", async () => {
|
||||
const { agentRoutes } = await import("../routes/agents.js");
|
||||
const { instanceSettingsService } = await import("../services/instance-settings.js");
|
||||
const targetModule = await import("../services/environment-execution-target.js");
|
||||
const runtimeModule = await import("../services/environment-runtime.js");
|
||||
const { requireServerAdapter } = await import("../adapters/index.js");
|
||||
const settings = instanceSettingsService(db);
|
||||
const previous = await settings.get();
|
||||
const [environment] = await db.insert(environments).values({ name: "Adoption sandbox", driver: "sandbox", config: { provider: "daytona" } }).returning();
|
||||
await settings.update({ defaultEnvironmentId: environment.id });
|
||||
const id = randomUUID();
|
||||
await db.insert(agents).values({ id, companyId, name: "Runner adoption", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } });
|
||||
const account = await service.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Runner adoption account", apiKey: "fixture-adoption-key", agentIds: [id], allAgents: false }, "fixture-adoption-key");
|
||||
await service.setDefault(companyId, "alice", account.grantId);
|
||||
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona", runner: { execute: vi.fn(async () => ({ exitCode: 0, stdout: "", stderr: "", timedOut: false })) } } as const;
|
||||
const resolveTarget = vi.spyOn(targetModule, "resolveEnvironmentExecutionTarget").mockResolvedValue(target);
|
||||
const release = vi.fn(async () => undefined);
|
||||
const acquire = vi.fn(async () => ({ lease: { id: randomUUID(), provider: "daytona", providerLeaseId: "test-sandbox", metadata: {} }, leaseContext: {} }));
|
||||
const runtime = vi.spyOn(runtimeModule, "environmentRuntimeService").mockReturnValue({ acquireRunLease: acquire, realizeWorkspace: vi.fn(async () => ({ cwd: "/workspace" })), getDriver: () => ({ releaseRunLease: release }) } as any);
|
||||
const probe = vi.spyOn(requireServerAdapter("paperclip_runner"), "testEnvironment").mockImplementation(async context => ({ adapterType: "paperclip_runner", status: context.executionTarget ? "pass" : "fail", testedAt: new Date().toISOString(), checks: [{ code: context.executionTarget ? "codex_hello_probe_passed" : "host_probe_failed", level: context.executionTarget ? "info" : "error", message: "fixture" }] }));
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
|
||||
app.use("/api", agentRoutes(db));
|
||||
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
||||
const selected = { provider: "openai", method: "api_key", mode: "responsible_user" } as const;
|
||||
const providerRequest = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 200 }));
|
||||
try {
|
||||
// Target resolution can return only warning checks. Adoption still must
|
||||
// fail closed, rather than quietly running the probe on the server host.
|
||||
resolveTarget.mockResolvedValueOnce(null);
|
||||
const unavailable = await request(app).patch(`/api/agents/${id}`).send({ runtimeConfig: { aiConnection: selected } });
|
||||
expect(unavailable.status, JSON.stringify(unavailable.body)).toBe(422);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(providerRequest).not.toHaveBeenCalled();
|
||||
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined();
|
||||
const saved = await request(app).patch(`/api/agents/${id}`).send({ runtimeConfig: { aiConnection: selected } });
|
||||
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
|
||||
expect(saved.body.defaultEnvironmentId).toBeNull();
|
||||
expect(saved.body.runtimeConfig.aiConnection).toEqual(selected);
|
||||
expect(acquire).toHaveBeenCalledWith(expect.objectContaining({ companyId, environment: expect.objectContaining({ id: environment.id }) }));
|
||||
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target, config: expect.objectContaining({ provider: "codex", model: "gpt-5.6-sol", managedAiConnection: expect.any(Object) }) }));
|
||||
expect(providerRequest).toHaveBeenCalledWith("https://api.openai.com/v1/models", expect.objectContaining({
|
||||
headers: { Authorization: "Bearer fixture-adoption-key" },
|
||||
redirect: "error",
|
||||
}));
|
||||
expect(release).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.stringify(saved.body)).not.toContain("fixture-adoption-key");
|
||||
} finally {
|
||||
providerRequest.mockRestore(); probe.mockRestore(); runtime.mockRestore(); resolveTarget.mockRestore();
|
||||
await settings.update({ defaultEnvironmentId: previous.defaultEnvironmentId });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and hires agents with an authorized restricted shared connection", async () => {
|
||||
const { agentRoutes } = await import("../routes/agents.js");
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
|
||||
|
||||
+56
-17
@@ -1156,6 +1156,25 @@ export function agentRoutes(
|
||||
return rows.filter((_, index) => decisions[index]?.allowed);
|
||||
}
|
||||
|
||||
// A null agent override inherits the instance default, just like dispatch.
|
||||
// Resolve this before secrets or probes so a default remote environment can
|
||||
// never accidentally validate the account on the control-plane host.
|
||||
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined) {
|
||||
if (environmentId) return environmentId;
|
||||
const settings = await instanceSettings.get();
|
||||
if (settings.defaultEnvironmentId) return settings.defaultEnvironmentId;
|
||||
if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly === true) {
|
||||
const managed = await environmentsSvc.findManagedSandboxEnvironment(companyId);
|
||||
if (!managed) {
|
||||
throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", {
|
||||
code: "managed_sandbox_unavailable",
|
||||
});
|
||||
}
|
||||
return managed.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the execution target the adapter should run its test probes against.
|
||||
*
|
||||
@@ -3334,14 +3353,18 @@ export function agentRoutes(
|
||||
});
|
||||
if (!selection) return undefined;
|
||||
if (test) {
|
||||
if (environmentId) await assertAdapterTestEnvironmentForCompany(companyId, environmentId);
|
||||
const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: environmentId ?? null });
|
||||
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId);
|
||||
if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId);
|
||||
const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: testEnvironmentId });
|
||||
let managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
|
||||
try {
|
||||
if (!target.executionTarget && target.fallbackChecks.some(check => check.level === "error")) throw unprocessable("The agent environment is not available for adoption");
|
||||
if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption");
|
||||
managed = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true });
|
||||
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding);
|
||||
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment");
|
||||
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", {
|
||||
code: "ai_connection_validation_failed",
|
||||
checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })),
|
||||
});
|
||||
if (selection.connection.config.aiLegacyAdoption === true) await db.update(toolConnections).set({ healthStatus: "ok", config: { ...selection.connection.config, aiLegacyAdoption: false }, updatedAt: new Date() }).where(eq(toolConnections.id, selection.connection.id));
|
||||
} finally { try { await managed?.cleanup(); } finally { await target.release("released"); } }
|
||||
}
|
||||
@@ -3361,10 +3384,23 @@ export function agentRoutes(
|
||||
const aiBinding = req.body.aiConnection ? aiConnectionBindingSchema.parse(req.body.aiConnection) : undefined;
|
||||
if (aiBinding && req.body.testCredentials && Object.keys(req.body.testCredentials).length) throw unprocessable("A managed connection test cannot override its credentials");
|
||||
const inputAdapterConfig = aiBinding ? { ...req.body.adapterConfig, env: stripAiAuthBindings(req.body.adapterConfig?.env) } : (req.body?.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
const requestedEnvironmentId =
|
||||
typeof req.body?.environmentId === "string" && req.body.environmentId.trim().length > 0
|
||||
? (req.body.environmentId as string)
|
||||
: null;
|
||||
const savedAgentId = typeof req.body.agentId === "string" ? req.body.agentId : null;
|
||||
const savedAgent = savedAgentId
|
||||
? await getAccessibleResource(req, res, svc.getById(savedAgentId), "Agent not found")
|
||||
: null;
|
||||
if (savedAgentId) {
|
||||
if (!savedAgent) return;
|
||||
if (savedAgent.companyId !== companyId) throw notFound("Agent not found");
|
||||
await assertCanUpdateAgent(req, savedAgent);
|
||||
}
|
||||
const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
|
||||
companyId,
|
||||
// Omission tests the saved selection. Explicit null tests a prospective
|
||||
// change back to the instance default.
|
||||
req.body.environmentId === undefined
|
||||
? savedAgent?.defaultEnvironmentId
|
||||
: asNonEmptyString(req.body.environmentId),
|
||||
);
|
||||
// Fail closed on a foreign environment before any secret resolution, env
|
||||
// merge, target resolution, sandbox lease, or adapter test runs.
|
||||
if (requestedEnvironmentId) {
|
||||
@@ -3374,12 +3410,8 @@ export function agentRoutes(
|
||||
// agent test, restore those display-only placeholders from the
|
||||
// server-side config before validating or resolving secrets; otherwise
|
||||
// the probe treats "***REDACTED***" as a value to persist.
|
||||
const savedAgentId = typeof req.body.agentId === "string" ? req.body.agentId : null;
|
||||
let adapterConfigForTest = inputAdapterConfig;
|
||||
if (savedAgentId) {
|
||||
const savedAgent = await getAccessibleResource(req, res, svc.getById(savedAgentId), "Agent not found");
|
||||
if (!savedAgent) return;
|
||||
if (savedAgent.companyId !== companyId) throw notFound("Agent not found");
|
||||
if (savedAgent) {
|
||||
const providerAdapter = savedAgent.adapterType === "paperclip_runner"
|
||||
? inputAdapterConfig.provider === "codex"
|
||||
? "codex_local"
|
||||
@@ -3387,11 +3419,18 @@ export function agentRoutes(
|
||||
? "claude_local"
|
||||
: null
|
||||
: null;
|
||||
if (savedAgent.adapterType !== type && providerAdapter !== type) {
|
||||
throw unprocessable("Saved agent is not compatible with the adapter being tested");
|
||||
const canRestoreEnv = savedAgent.adapterType === type || providerAdapter === type;
|
||||
// Permit testing a prospective adapter switch, but do not transfer
|
||||
// hidden values from the saved adapter into an unrelated harness.
|
||||
if (!canRestoreEnv && Object.values(parseObject(inputAdapterConfig.env)).some(value => {
|
||||
const binding = asRecord(value);
|
||||
return binding?.type === "plain" && binding.value === REDACTED_EVENT_VALUE;
|
||||
})) {
|
||||
throw unprocessable("Re-enter environment values when testing a different adapter");
|
||||
}
|
||||
await assertCanUpdateAgent(req, savedAgent);
|
||||
adapterConfigForTest = restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig);
|
||||
adapterConfigForTest = canRestoreEnv
|
||||
? restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig)
|
||||
: inputAdapterConfig;
|
||||
}
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
companyId,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
NativeSessionCleanupQuarantinedError,
|
||||
NativeProviderTerminalFailure,
|
||||
NativeSessionProtocolIntegrityError,
|
||||
defaultCapabilityRunnerdBinary,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import * as issueServiceModule from "../issues.js";
|
||||
import {
|
||||
@@ -167,18 +168,22 @@ const state = vi.hoisted(() => ({
|
||||
release: null as null | (() => void),
|
||||
}));
|
||||
|
||||
vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => {
|
||||
const original = await importOriginal<
|
||||
typeof import("../../vendor/paperclip-runner/index.js")
|
||||
>()),
|
||||
createNativeSessionBackend: state.createBackend,
|
||||
createRunnerdCodexTransport: state.createTransport,
|
||||
executeNativeSession: state.execute,
|
||||
settleRetainedRunnerdSession: state.cleanup,
|
||||
retainedRunnerdMaintenanceIsIdle: state.maintenanceIdle,
|
||||
completeRetainedNativeSessionCleanup: state.retireCleanup,
|
||||
parsePaperclipQuestionSet: (value: unknown) => value,
|
||||
}));
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
defaultCapabilityRunnerdBinary: vi.fn(original.defaultCapabilityRunnerdBinary),
|
||||
createNativeSessionBackend: state.createBackend,
|
||||
createRunnerdCodexTransport: state.createTransport,
|
||||
executeNativeSession: state.execute,
|
||||
settleRetainedRunnerdSession: state.cleanup,
|
||||
retainedRunnerdMaintenanceIsIdle: state.maintenanceIdle,
|
||||
completeRetainedNativeSessionCleanup: state.retireCleanup,
|
||||
parsePaperclipQuestionSet: (value: unknown) => value,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-codex-local/server", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
@@ -1802,6 +1807,7 @@ describe("remote runner build metadata", () => {
|
||||
binaryName: "paperclip-runnerd",
|
||||
packageName: "@paperclipai/paperclip-runner",
|
||||
binaryContractVersion: 2,
|
||||
durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"],
|
||||
prpTransportModes: ["dial_ws_loopback", "dial_wss", "listen_ws"],
|
||||
};
|
||||
|
||||
@@ -1823,6 +1829,14 @@ describe("remote runner build metadata", () => {
|
||||
).toThrow("runner_remote_artifact_contract_incompatible");
|
||||
});
|
||||
|
||||
it.each([undefined, [], ["unlimited_runtime"], ["connection_lease_renewal"]])(
|
||||
"rejects a contract-v2 image without current durable session capabilities: %j",
|
||||
(durableSessionCapabilities) => {
|
||||
expect(() => assertRemoteRunnerBuildMetadata({ ...current, durableSessionCapabilities }, "listen_ws"))
|
||||
.toThrow("runner_remote_session_capability_missing:");
|
||||
},
|
||||
);
|
||||
|
||||
it("requires the selected transport without falling through", () => {
|
||||
expect(() =>
|
||||
assertRemoteRunnerBuildMetadata(
|
||||
@@ -9569,6 +9583,7 @@ describe("runnerd provider runtime wiring", () => {
|
||||
exitCode: 0, timedOut: false, stdout: JSON.stringify({
|
||||
schema: "paperclip-runner/runnerd-build-metadata/v1", binaryName: "paperclip-runnerd",
|
||||
packageName: "@paperclipai/paperclip-runner", binaryContractVersion: 2,
|
||||
durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"],
|
||||
prpTransportModes: ["listen_ws"],
|
||||
}), stderr: "",
|
||||
};
|
||||
@@ -9620,7 +9635,14 @@ describe("runnerd provider runtime wiring", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the image's shared Codex without uploading or installing artifacts", async () => {
|
||||
it.each([false, true])("uses shared Codex and replaces only a stale runner image (stale=%s)", async (staleRunner) => {
|
||||
// The mocked remote executes metadata probes; artifact staging only needs bytes.
|
||||
// Keep this regression independent of a locally compiled Rust runner binary.
|
||||
const controllerArtifact = join(isolatedStateDirectory, "paperclip-runnerd");
|
||||
if (staleRunner) {
|
||||
await writeFile(controllerArtifact, "fixture runner artifact");
|
||||
vi.mocked(defaultCapabilityRunnerdBinary).mockReturnValueOnce(controllerArtifact);
|
||||
}
|
||||
const syncIn = vi.fn(async () => undefined);
|
||||
const remoteExecute = vi.fn(
|
||||
async (command: { command: string; args?: string[] }) => {
|
||||
@@ -9632,6 +9654,9 @@ describe("runnerd provider runtime wiring", () => {
|
||||
binaryName: "paperclip-runnerd",
|
||||
packageName: "@paperclipai/paperclip-runner",
|
||||
binaryContractVersion: 2,
|
||||
durableSessionCapabilities: staleRunner && command.command === "/usr/local/bin/paperclip-runnerd"
|
||||
? undefined
|
||||
: ["unlimited_runtime", "connection_lease_renewal"],
|
||||
prpTransportModes: ["listen_ws"],
|
||||
});
|
||||
} else if (command.args?.[0] === "--version") {
|
||||
@@ -9643,6 +9668,8 @@ describe("runnerd provider runtime wiring", () => {
|
||||
throw new Error("reached-preinstalled-codex-verification");
|
||||
}
|
||||
stdout = "codex-cli 0.153.4";
|
||||
} else if (script === "uname -s; uname -m") {
|
||||
stdout = `${process.platform === "darwin" ? "Darwin" : "Linux"}\n${process.arch === "arm64" ? "arm64" : "x86_64"}\n`;
|
||||
} else if (script.includes("command -v paperclip-runnerd")) {
|
||||
stdout = "/usr/local/bin/paperclip-runnerd\n";
|
||||
} else if (script.includes("command -v codex")) {
|
||||
@@ -9689,7 +9716,17 @@ describe("runnerd provider runtime wiring", () => {
|
||||
await expect(transport.controlPlaneRegistration({})).rejects.toThrow(
|
||||
"reached-preinstalled-codex-verification",
|
||||
);
|
||||
expect(syncIn).not.toHaveBeenCalled();
|
||||
if (staleRunner) {
|
||||
expect(syncIn).toHaveBeenCalledTimes(1);
|
||||
expect(syncIn).toHaveBeenCalledWith([expect.objectContaining({
|
||||
files: [expect.objectContaining({
|
||||
sourcePath: controllerArtifact,
|
||||
targetPath: "/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd",
|
||||
})],
|
||||
})]);
|
||||
} else {
|
||||
expect(syncIn).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(remoteExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "/opt/paperclip-runner/bin/codex",
|
||||
|
||||
@@ -9059,6 +9059,17 @@ export function assertRemoteRunnerBuildMetadata(
|
||||
) {
|
||||
throw new Error("runner_remote_artifact_contract_incompatible");
|
||||
}
|
||||
// Older sandbox images share binary contract v2, but reject the zero
|
||||
// lifetime now used by the controller and cannot renew connection leases.
|
||||
// Reject them before launch so preparation stages the bundled runner instead.
|
||||
const sessionCapabilities = Array.isArray(metadata.durableSessionCapabilities)
|
||||
? metadata.durableSessionCapabilities
|
||||
: [];
|
||||
for (const capability of ["unlimited_runtime", "connection_lease_renewal"]) {
|
||||
if (!sessionCapabilities.includes(capability)) {
|
||||
throw new Error(`runner_remote_session_capability_missing:${capability}`);
|
||||
}
|
||||
}
|
||||
const modes = Array.isArray(metadata.prpTransportModes)
|
||||
? metadata.prpTransportModes
|
||||
: [];
|
||||
|
||||
Reference in new issue
Block a user