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:
DottaandPaperclip authored and GitHub committed 2026-09-19 08:51:18 -05:00
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.
+7
View File
@@ -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
View File
@@ -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
: [];