From 9335b7db10425277bcb84ba8137d08c498324dba Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:51:18 -0500 Subject: [PATCH] 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 --- .../paperclip-runner-daytona-networking.md | 18 +++ doc/connections/AI-CONNECTIONS.md | 7 + .../runner-core/src/bin/paperclip-runnerd.rs | 5 + .../agent-adapter-validation-routes.test.ts | 1 + .../agent-test-environment-routes.test.ts | 137 ++++++++++++++++++ .../agents-adapter-config-user-secret.test.ts | 1 + server/src/__tests__/ai-connections.test.ts | 54 +++++++ server/src/routes/agents.ts | 73 +++++++--- .../native-session-executor.test.ts | 63 ++++++-- .../native-runtime/native-session-executor.ts | 11 ++ 10 files changed, 340 insertions(+), 30 deletions(-) diff --git a/doc/architecture/paperclip-runner-daytona-networking.md b/doc/architecture/paperclip-runner-daytona-networking.md index 8ce5bcf9e1e..169212b945a 100644 --- a/doc/architecture/paperclip-runner-daytona-networking.md +++ b/doc/architecture/paperclip-runner-daytona-networking.md @@ -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. diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 6f9967f7237..60afa650ade 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -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. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index 30bffcab312..5b1c7231c84 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -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"]) diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 8f1c2e11d3d..558717b6a39 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -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 })), })); diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index f99d7103924..4ee7c7dbb88 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -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) diff --git a/server/src/__tests__/agents-adapter-config-user-secret.test.ts b/server/src/__tests__/agents-adapter-config-user-secret.test.ts index a44a5c73476..57a0f8dc06c 100644 --- a/server/src/__tests__/agents-adapter-config-user-secret.test.ts +++ b/server/src/__tests__/agents-adapter-config-user-secret.test.ts @@ -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 })), })); diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index f9d836f8f4c..1bf7b49c6dd 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -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)); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 159942a1472..7f70ada6a3b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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> | 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; - 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, diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 9b62a06ec8a..fbd53bbfc03 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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", diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 2f456d5b38c..dd36882a8e9 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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 : [];