feat: default new Codex agents to the qualified Paperclip Runner

Resolve creation intent across onboarding, hires, imports, and the UI. Preserve existing execution and qualify automatic defaults for shipped Linux x64 artifacts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 10:12:15 -05:00
1 parent 835a022936
commit aa879604ae
91 files changed
+2718 -884

No files matched your search

@@ -15,8 +15,14 @@ import {
import {
resolveExternalAdapterRegistration,
setOverridePaused,
hasActiveAdapterOverride,
} from "../adapters/registry.js";
const nativeSetup = vi.hoisted(() => ({ authenticate: vi.fn() }));
vi.mock("../services/native-runtime/setup-readiness.js", () => ({
testNativeRunnerAuthentication: nativeSetup.authenticate,
}));
vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
probeAcpxGrokInstallation: vi.fn(async () => undefined),
@@ -267,6 +273,26 @@ describe("server adapter registry", () => {
});
});
it.each(["pass", "fail"] as const)("uses the selected native Codex runtime for setup (%s)", async status => {
nativeSetup.authenticate.mockReset().mockResolvedValue({ adapterType: "paperclip_runner", status, testedAt: "", checks: [{ code: `codex_hello_probe_${status === "pass" ? "passed" : "auth_required"}`, level: status === "pass" ? "info" : "error", message: "Selected native account" }] });
const context = { companyId: "company-1", adapterType: "paperclip_runner", config: { provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: "selected-key" } } };
const result = await requireServerAdapter("paperclip_runner").testEnvironment(context);
expect(result.status).toBe(status);
expect(nativeSetup.authenticate).toHaveBeenCalledWith(context, "codex", "gpt-5.6-sol");
});
it("identifies only active external overrides for automatic runner selection", () => {
try {
expect(hasActiveAdapterOverride("codex_local")).toBe(false);
registerServerAdapter({ ...externalAdapter, type: "codex_local" });
expect(hasActiveAdapterOverride("codex_local")).toBe(true);
setOverridePaused("codex_local", true);
expect(hasActiveAdapterOverride("codex_local")).toBe(false);
setOverridePaused("codex_local", false);
expect(hasActiveAdapterOverride("codex_local")).toBe(true);
} finally { unregisterServerAdapter("codex_local"); }
});
it.each([
["claude_managed", {
managedProfileId: "managed-primary",
@@ -672,20 +672,47 @@ describe("agent routes adapter validation", () => {
expect(res.status, JSON.stringify(res.body)).toBe(201);
});
it("rejects a new paperclip_runner selection while the rollout flag is off", async () => {
it("defaults new Codex on a qualified Linux target while the experimental flag is off", async () => {
// Exercise route ordering and the real translator with an explicitly qualified target.
// Environment/platform discovery has its own company-boundary and platform tests.
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({ name: "Native Codex", adapterType: "paperclip_runner" }),
.send({ name: "Native Codex", adapterType: "codex_local" }),
);
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ runnerResolved: true });
});
it("retains explicit legacy Codex and rejects incompatible automatic settings before persistence", async () => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
const app = await createApp();
const legacy = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Legacy", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }));
expect(legacy.status, JSON.stringify(legacy.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "codex_local", adapterConfig: { extraArgs: ["--search"] } });
mockAgentService.create.mockClear();
const incompatible = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Custom", adapterType: "codex_local", adapterConfig: { extraArgs: ["--search"] } }));
expect(incompatible.status).toBe(422);
expect(incompatible.body.details).toMatchObject({ code: "agent_runner_config_incompatible", fields: ["extraArgs"] });
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("keeps non-Codex native provider selection behind its existing flag", async () => {
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Claude", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } }));
expect(response.status).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
});
it("allows a new paperclip_runner selection while the rollout flag is on", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
@@ -768,15 +795,14 @@ describe("agent routes adapter validation", () => {
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("requires the general Runner rollout when changing an existing Dot to Codex", async () => {
it("allows an explicit Dot to Codex change without enabling other native providers", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } });
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: { provider: "codex" }, replaceAdapterConfig: true }));
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterConfig: { provider: "codex" } });
});
it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => {
@@ -63,6 +63,31 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
return companyId;
}
it("resolves internal Codex creation once and preserves legacy agents through ordinary edits", async () => {
const companyId = await seedCompany();
const service = agentService(db);
const native = await service.create(companyId, { name: "New Codex", adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" }, status: "pending_approval" });
const legacy = await service.create(companyId, { name: "Legacy Codex", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] }, status: "pending_approval" });
const reviewedLegacy = await service.create(companyId, { name: "Historical approved hire", adapterType: "codex_local", adapterConfig: {}, status: "pending_approval" }, { runnerResolved: true });
// Only public Linux x64 artifacts qualify an automatic native default;
// source-built macOS/Windows agents keep legacy until their packaging is qualified.
if (process.platform === "linux" && process.arch === "x64") {
expect(native).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", modelReasoningEffort: "high" } });
} else {
expect(native).toMatchObject({ adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" } });
}
expect(legacy.adapterType).toBe("codex_local");
expect(reviewedLegacy.adapterType).toBe("codex_local");
for (const agent of [native, legacy, reviewedLegacy]) {
// Ordinary title changes on an activated agent must not revisit creation defaults.
await service.activatePendingApproval(agent.id);
await service.update(agent.id, { title: "Updated title" });
const saved = await service.getById(agent.id);
expect(saved?.adapterType).toBe(agent.adapterType);
expect(saved?.adapterConfig).toEqual(agent.adapterConfig);
}
});
it("freezes generic pending hire config and reapplies the approval snapshot on activation", async () => {
const companyId = await seedCompany();
const agentSvc = agentService(db);
@@ -22,6 +22,8 @@ import { syncConnectionCredentialBindings } from "../services/connection-credent
import * as codexAdapter from "@paperclipai/adapter-codex-local/server";
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
import * as runnerProbe from "../vendor/paperclip-runner/index.js";
import { testNativeRunnerAuthentication } from "../services/native-runtime/setup-readiness.js";
import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth, isAiConnectionBusy } from "../services/ai-connection-runtime.js";
import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server";
import { toolAccessService } from "../services/tool-access.js";
@@ -171,6 +173,39 @@ describe("managed AI connections", () => {
} finally { release.resolve(); await Promise.allSettled([probing, refreshing]); lock?.mockRestore(); request.mockRestore(); }
});
it("persists native Codex setup's refreshed selected login without changing existing agents", async () => {
const owner = "native-codex-setup-refresh";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
const auth = (hour: string) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("10"));
const [row] = await service.quotaAccounts(companyId, owner);
const [before] = await db.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.id, agentId));
const probe = vi.spyOn(runnerProbe, "probeNativeRunnerEnvironment").mockImplementation(async input => {
expect(input.transportOptions?.sourceCodexHome).toContain("/provider");
expect(await readFile(path.join(input.transportOptions!.sourceCodexHome!, "auth.json"), "utf8")).toBe(auth("10"));
const providerHome = path.join(input.runtimeDirectory, "codex-home");
await fs.mkdir(providerHome, { mode: 0o700 });
const refreshedFile = path.join(providerHome, "auth.json");
await writeFile(refreshedFile, auth("11"), { mode: 0o600 });
await input.onCodexCredentialRefresh!(refreshedFile);
await input.onCleanupConfirmed!();
return { provider: "codex", providerDriver: "codex_app_server", effectiveModel: "gpt-5.6-sol", helloProbePassed: true };
});
let managedHome: string | undefined;
try {
const result = await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner",
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: { provider: "codex" } }, async runtime => {
managedHome = runtime.home;
return testNativeRunnerAuthentication({ companyId, adapterType: "paperclip_runner", config: runtime.config, managedAiCredentialHome: path.join(runtime.home!, "provider") }, "codex", "gpt-5.6-sol");
});
expect(result.status).toBe("pass"); expect(probe).toHaveBeenCalledOnce();
expect(await service.credential(row)).toBe(auth("11"));
await expect(access(managedHome!)).rejects.toMatchObject({ code: "ENOENT" });
const [after] = await db.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.id, agentId));
expect(after).toEqual(before);
} finally { probe.mockRestore(); }
});
it("rejects preparation when reconnect replaces the selected credential reference", async () => {
const owner = "replaced-ref-owner";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
@@ -139,6 +139,7 @@ describe("approvalService resolution idempotency", () => {
requestedByUserId,
payload: {
name: "New Agent",
adapterType: "codex_local",
adapterConfig: {
env: {
API_KEY: {
@@ -159,9 +160,10 @@ describe("approvalService resolution idempotency", () => {
expect(mockAgentService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
adapterType: "codex_local",
adapterConfig: approved.payload.adapterConfig,
}),
{ createdByUserId: expectedCreator },
{ runnerResolved: true, createdByUserId: expectedCreator },
);
});
});
+41 -11
View File
@@ -257,21 +257,51 @@ describeEmbeddedPostgres("built-in agents", () => {
expect(configured.status).toBe("ready");
expect(configured.agentId).toBe(created.agentId);
expect(configured.agent).toMatchObject({
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
});
const reconciled = await svc.ensure(companyId, "briefs");
expect(reconciled.status).toBe("ready");
expect(reconciled.agent).toMatchObject({
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
});
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
expect(rows).toHaveLength(1);
});
it("keeps existing legacy and native built-in runners through unrelated edits and startup", async () => {
const companyId = await seedCompany({ requireApproval: false });
const svc = builtInAgentService(db);
const created = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" }, runner: "legacy" });
expect(created.agent?.adapterType).toBe("codex_local");
await svc.ensure(companyId, "briefs", { budgetMonthlyCents: 100 });
await reconcileBuiltInAgentsOnStartup(db);
const legacy = await svc.get(companyId, "briefs");
expect(legacy.agent?.adapterType).toBe("codex_local");
expect(legacy.agent?.adapterConfig).toEqual(created.agent?.adapterConfig);
const switched = await svc.ensure(companyId, "briefs", { runner: "paperclip" });
expect(switched.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
await svc.ensure(companyId, "briefs", { budgetMonthlyCents: 200 });
await reconcileBuiltInAgentsOnStartup(db);
expect((await svc.get(companyId, "briefs")).agent?.adapterConfig).toEqual(switched.agent?.adapterConfig);
const edited = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } });
expect(edited.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } });
});
it("freezes Codex built-in execution in the reviewed hire through service restart", async () => {
const companyId = await seedCompany({ requireApproval: true });
const result = await builtInAgentService(db).provision(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, { requestedByUserId: "board-user" });
expect(result.approval?.payload).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } });
await reconcileBuiltInAgentsOnStartup(db);
await approvalService(db).approve(result.approval!.id, "board-user");
const approved = await builtInAgentService(db).get(companyId, "briefs");
expect(approved.agent?.adapterType).toBe(result.approval!.payload.adapterType);
expect(approved.agent?.adapterConfig).toEqual(result.approval!.payload.adapterConfig);
});
it("routes policy-gated built-in provisioning through a pending hire approval", async () => {
const companyId = await seedCompany();
const builtIns = builtInAgentService(db);
@@ -383,7 +413,7 @@ describeEmbeddedPostgres("built-in agents", () => {
await expect(builtIns.get(companyId, "briefs")).resolves.toMatchObject({
status: "ready",
agentId: ready.agentId,
agent: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } },
agent: { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } },
});
});
@@ -410,8 +440,8 @@ describeEmbeddedPostgres("built-in agents", () => {
agentId: seeded.agentId,
agent: {
status: "idle",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
budgetMonthlyCents: 2500,
},
});
@@ -577,8 +607,8 @@ describeEmbeddedPostgres("built-in agents", () => {
role: "general",
title: null,
capabilities: "Prepares concise operational briefs for the board and agent company.",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
},
});
});
@@ -624,7 +654,7 @@ describeEmbeddedPostgres("built-in agents", () => {
role: "general",
title: "Reflection Coach",
icon: "eye",
adapterType: "codex_local",
adapterType: "paperclip_runner",
permissions: {
canCreateAgents: false,
canCreateSkills: false,
@@ -1240,7 +1270,7 @@ describeEmbeddedPostgres("built-in agents", () => {
title: "Reflection Coach",
icon: "eye",
reportsTo: root.id,
adapterType: "codex_local",
adapterType: "paperclip_runner",
budgetMonthlyCents: 0,
});
expect(state.status).toBe("paused");
@@ -19,6 +19,7 @@ const companySvc = {
const agentSvc = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
@@ -103,6 +104,7 @@ const agentInstructionsSvc = {
};
const instanceSettingsSvc = {
get: vi.fn(async () => ({ general: {}, experimental: {}, defaultEnvironmentId: null })),
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
};
@@ -199,6 +201,7 @@ describe("company portability", () => {
beforeEach(() => {
vi.clearAllMocks();
agentSvc.getById.mockImplementation(async (id: string) => (await agentSvc.list()).find((agent: { id: string }) => agent.id === id) ?? null);
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false });
managedAgentProfileSvc.requireQualified.mockResolvedValue({
id: "managed-primary",
@@ -1895,7 +1898,7 @@ describe("company portability", () => {
},
},
}),
{ strictMode: false, adapterType: "codex_local" },
{ strictMode: false, adapterType: "paperclip_runner" },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterConfig: expect.objectContaining({
@@ -3745,6 +3748,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
runner: "legacy",
adapterConfig: {
dangerouslyBypassApprovalsAndSandbox: true,
instructionsFilePath: "/tmp/should-not-survive.md",
@@ -3852,6 +3856,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
runner: "legacy",
adapterConfig: {
extraArgs: [],
args: ["--legacy-arg"],
@@ -5852,6 +5857,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
runner: "legacy",
adapterConfig: {
model: "gpt-5.4",
},
@@ -6040,6 +6046,62 @@ describe("company portability", () => {
expect(preview.plan.issuePlans).toHaveLength(0);
});
it.each(["legacy", "paperclip"] as const)("exports the resolved %s Codex runner and preserves it on import", async (runner) => {
const [fixture] = await agentSvc.list();
agentSvc.list.mockResolvedValue([{ ...fixture, adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", adapterConfig: { ...(runner === "paperclip" ? { provider: "codex" } : {}), model: "gpt-5.4" } }]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
expect(exported.manifest.agents[0]?.runner).toBe(runner);
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain(`runner: "${runner}"`);
agentSvc.list.mockResolvedValue([]);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ id: "agent-created", ...input }));
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename" }, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", runner, adapterConfig: expect.objectContaining(runner === "paperclip" ? { provider: "codex", model: "gpt-5.4" } : { model: "gpt-5.4" }) }), { createdByUserId: "user-1" });
// The import UI sends the underlying harness even for config-only edits.
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename", adapterOverrides: { claudecoder: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.5" } } } }, "user-1");
expect(agentSvc.create).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", runner, adapterConfig: expect.objectContaining({ model: "gpt-5.5" }) }), { createdByUserId: "user-1" });
});
it("rejects an invalid package runner without selecting a default or creating an agent", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace('runner: "legacy"', 'runner: "typo"') };
agentSvc.list.mockResolvedValue([]);
await expect(portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename" }, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_choice_invalid", field: "agents.claudecoder.adapter.runner" } });
expect(agentSvc.create).not.toHaveBeenCalled();
});
it.each(["create", "update"] as const)("applies Codex defaults only to new imports without a runner (%s)", async (action) => {
const [fixture] = await agentSvc.list();
agentSvc.list.mockResolvedValue([{ ...fixture, adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace(/\n\s+runner: "legacy"/, "") };
if (action === "create") agentSvc.list.mockResolvedValue([]);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ id: "agent-created", ...input }));
agentSvc.update.mockImplementation(async (id: string, input: Record<string, unknown>) => ({ id, ...fixture, ...input }));
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: action === "update" ? "replace" : "rename" }, "user-1");
if (action === "create") expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "codex" }) }), { createdByUserId: "user-1" });
else expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ adapterType: "codex_local", adapterConfig: expect.objectContaining({ model: "gpt-5.4" }) }));
});
it.each([
{ existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local" },
{ existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner" },
{ existingRunner: "legacy", existingHarness: "claude_local", expectedType: "paperclip_runner" },
])("keeps execution on same-harness replace-import edits and defaults genuine changes ($existingRunner/$existingHarness)", async ({ existingRunner, existingHarness, expectedType }) => {
const [fixture] = await agentSvc.list();
const existing = { ...fixture, adapterType: existingRunner === "paperclip" ? "paperclip_runner" : existingHarness, adapterConfig: { ...(existingRunner === "paperclip" ? { provider: "codex" } : {}), model: existingHarness === "codex_local" ? "gpt-5.4" : "claude-sonnet-4-6" } };
agentSvc.list.mockResolvedValue([existing]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace(/\n\s+runner: "(?:legacy|paperclip)"/, "") };
agentSvc.update.mockImplementation(async (id: string, input: Record<string, unknown>) => ({ id, ...existing, ...input }));
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "replace", adapterOverrides: { claudecoder: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.5" } } } }, "user-1");
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ adapterType: expectedType, adapterConfig: expect.objectContaining({ model: "gpt-5.5", ...(expectedType === "paperclip_runner" ? { provider: "codex" } : {}) }) }));
expect(agentSvc.create).not.toHaveBeenCalled();
});
it("imports an unpaired Dot using its own option without enabling other Runner providers", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
@@ -6056,7 +6118,7 @@ describe("company portability", () => {
await expect(portability.importBundle(input, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_dot_disabled" } });
expect(agentSvc.create).not.toHaveBeenCalled();
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } } } }, "user-1"))
await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" } } } }, "user-1"))
.rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } });
expect(agentSvc.create).not.toHaveBeenCalled();
await portability.importBundle(input, "user-1");
@@ -6090,7 +6152,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" },
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" },
},
},
};
@@ -6122,7 +6184,7 @@ describe("company portability", () => {
await portability.importBundle(request, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterType: "paperclip_runner",
adapterConfig: expect.objectContaining({ provider: "codex" }),
adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }),
}), { createdByUserId: "user-1" });
await portability.importBundle({
+1 -1
View File
@@ -87,7 +87,7 @@ describe("durable Dot Runner integration", () => {
expect(() => resolveNativeRuntimeMode(input)).toThrow("experimental settings");
expect(resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } });
expect(() => resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true,
adapterConfig: { provider: "codex" },
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
})).toThrow("Paperclip Runner is experimental and disabled");
expect(resolveHeartbeatNativeRuntimeMode({ ...input, enabled: false, dotEnabled: false,
persisted: { runtimeMode: "native", runtimeModeReason: null, runtimeModeResolvedAt: new Date(), driverKind: "openai_dot_mcp" },
@@ -143,6 +143,25 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () =>
}
});
it("uses the creation default for server-seeded Codex without changing the Claude fallback", async () => {
const previous = process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE;
process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = "codex_local";
try {
const { companyId, app } = await seedCompany();
const response = await post(app, companyId, SEED);
expect(response.status).toBe(200);
const [agent] = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
expect(agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
expect(agent?.adapterConfig).toMatchObject({ paperclipSkillSync: { desiredSkills: expect.arrayContaining(["paperclipai/paperclip/paperclip-create-agent"]) } });
expect((await post(app, companyId, SEED)).body.changed).toBe(false);
const [replayed] = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
expect(replayed?.adapterConfig).toEqual(agent?.adapterConfig);
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE;
else process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = previous;
}
});
it("is idempotent per revision — a replay creates no second agent or task", async () => {
const { companyId, app } = await seedCompany();
@@ -283,8 +283,8 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
name: "Codex One",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
runtimeConfig: {},
permissions: {},
},
@@ -315,7 +315,9 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.status).toBe("created");
expect(created.agent?.adapterType).toBe("codex_local");
expect(created.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(reconciled.agent?.adapterConfig).toEqual(created.agent?.adapterConfig);
});
it("materializes declared managed agent instructions with local folder paths", async () => {
+11 -3
View File
@@ -483,9 +483,11 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
}],
};
}
const result = profile.provider === "opencode"
? await openCodeTestEnvironment(context)
: await codexTestEnvironment(context);
if (profile.provider === "codex") {
const { testNativeRunnerAuthentication } = await import("../services/native-runtime/setup-readiness.js");
return testNativeRunnerAuthentication(context, "codex", profile.model);
}
const result = await openCodeTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
},
listSkills: listCodexSkills,
@@ -1177,6 +1179,12 @@ export function isOverridePaused(type: string): boolean {
return pausedOverrides.has(type);
}
export function hasActiveAdapterOverride(type: string): boolean {
const registered = adaptersByType.get(type);
return builtinFallbacks.has(type) && registered !== undefined
&& registered !== builtinFallbacks.get(type) && !pausedOverrides.has(type);
}
/** Get the set of types whose overrides are currently paused. */
export function getPausedOverrides(): Set<string> {
return pausedOverrides;
+7 -2
View File
@@ -6,6 +6,7 @@ import {
nativeRunFinalizations,
} from "@paperclipai/db";
import { resolveMigrationConnection } from "@paperclipai/db/migration-runtime";
import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared";
import { instanceSettingsService } from "./services/instance-settings.js";
@@ -48,8 +49,12 @@ async function main(): Promise<void> {
console.log(
JSON.stringify({
nativeRunnerRequired:
experimental.enableNativeRunner === true || experimental.enableOpenAiDot === true || persistedNativeRun,
// New Codex agents can use runnerd immediately, including on instances
// that have disabled the experimental providers.
nativeRunnerRequired: paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch)
|| experimental.enableNativeRunner === true
|| experimental.enableOpenAiDot === true
|| persistedNativeRun,
rolloutEnabled: experimental.enableNativeRunner === true,
dotRolloutEnabled: experimental.enableOpenAiDot === true,
persistedNativeRun,
+6 -4
View File
@@ -52,6 +52,8 @@ import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
import { agentRunnerAvailability } from "../services/agent-runner-selection.js";
import type { AgentRunnerAvailability } from "@paperclipai/shared";
const execFileAsync = promisify(execFile);
@@ -123,7 +125,7 @@ interface AdapterCapabilities {
login?: AdapterLoginProjection;
}
interface AdapterInfo {
interface AdapterInfo extends AgentRunnerAvailability {
type: string;
label: string;
source: "builtin" | "external";
@@ -203,6 +205,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP
loaded: true, // If it's in the registry, it's loaded
disabled: disabledSet.has(adapter.type),
capabilities: buildAdapterCapabilities(adapter),
...agentRunnerAvailability(adapter.type),
...(adapter.acp ? { acp: adapter.acp } : {}),
overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined,
overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined,
@@ -281,11 +284,10 @@ export function adapterRoutes(options: {
listAdapterPlugins().map((r) => [r.type, r]),
);
const disabledSet = new Set(getDisabledAdapterTypes());
const nativeRunnerEnabled = await options.getNativeRunnerEnabled?.().catch(() => false) ?? false;
const openAiDotEnabled = await options.getOpenAiDotEnabled?.().catch(() => false) ?? false;
// One shared implementation, with independent provider rollouts. Explicit
// adapter-admin disabling still applies to both choices.
if (!nativeRunnerEnabled && !openAiDotEnabled) disabledSet.add("paperclip_runner");
// Codex is generally available; the other native providers retain their
// provider-specific selection gates. Explicit adapter disabling still applies.
const result: AdapterInfo[] = registeredAdapters.map((adapter) =>
buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet),
+43 -13
View File
@@ -1,5 +1,7 @@
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
import { aiRoutingHarness } from "@paperclipai/shared";
import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared";
import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js";
import { agentIdentityService } from "../services/agent-identity.js";
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
import { connectionIntentService } from "../services/connection-intents.js";
@@ -2243,7 +2245,7 @@ export function agentRoutes(
"OpenAI Dot is experimental and disabled on this instance.",
{ code: "paperclip_runner_dot_disabled" },
);
} else if (experimental.enableNativeRunner !== true) {
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && experimental.enableNativeRunner !== true) {
throw unprocessable(
"Paperclip Runner is experimental and disabled on this instance.",
{ code: "paperclip_runner_rollout_disabled" },
@@ -2594,7 +2596,7 @@ export function agentRoutes(
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
): Record<string, unknown> {
if (adapterType !== "codex_local") return adapterConfig;
if (agentHarnessType(adapterType ?? "", adapterConfig) !== "codex_local") return adapterConfig;
const existingEnv = asRecord(adapterConfig.env);
if (!existingEnv) return adapterConfig;
if (!codexLocalEnvKeyConfigured(existingEnv.OPENAI_API_KEY)) return adapterConfig;
@@ -2645,11 +2647,12 @@ export function agentRoutes(
const noInheritance = { adapterConfig, inheritedFixedClaudeOAuthBinding: false };
if (asRecord(runtimeConfig)?.aiConnection) return noInheritance;
if (req.actor.type !== "agent" || !req.actor.agentId) return noInheritance;
const credentialKeys = adapterType ? INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[adapterType] : undefined;
const harness = agentHarnessType(adapterType ?? "", adapterConfig) === "codex_local" ? "codex_local" : adapterType ?? "";
const credentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[harness];
if (!credentialKeys) return noInheritance;
const parent = await svc.getById(req.actor.agentId);
if (!parent || parent.companyId !== companyId || parent.adapterType !== adapterType) return noInheritance;
if (!parent || parent.companyId !== companyId || (harness === "codex_local" ? agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness : parent.adapterType !== adapterType)) return noInheritance;
// Managed parents must not pass stale legacy credential references to hires.
if (aiRuntimeConnectionBindingSchema.safeParse(parent.runtimeConfig.aiConnection).success) return noInheritance;
const parentEnv = asRecord(asRecord(parent.adapterConfig)?.env);
@@ -3451,7 +3454,9 @@ export function agentRoutes(
async function testManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>, agentId?: string) {
const startedAt = new Date();
const result = await probeManagedEnvironment(adapterType, context, binding);
const managedAiCredentialHome = adapterType === "paperclip_runner" && context.config.provider === "codex" && managed.home
? path.join(managed.home, "provider") : undefined;
const result = await probeManagedEnvironment(adapterType, { ...context, ...(managedAiCredentialHome ? { managedAiCredentialHome } : {}) }, binding);
// A provider rejection invalidates the tested credential generation. A
// missing CLI, unavailable environment, or other runtime error does not.
if (result.status === "fail" && result.checks.some(check =>
@@ -3577,11 +3582,9 @@ export function agentRoutes(
validate(testAdapterEnvironmentSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const type = assertKnownAdapterType(req.params.type as string);
let type = assertKnownAdapterType(req.params.type as string);
await assertCanCreateAgentsForCompany(req, companyId);
const adapter = requireServerAdapter(type);
const requestedBinding = req.body.aiConnection ? aiRuntimeConnectionBindingSchema.parse(req.body.aiConnection) : undefined;
const aiBinding = requestedBinding?.mode === "router" ? undefined : requestedBinding;
if (aiBinding && req.body.testCredentials && Object.keys(req.body.testCredentials).length) throw unprocessable("A managed connection test cannot override its credentials");
@@ -3596,11 +3599,6 @@ export function agentRoutes(
await assertCanUpdateAgent(req, savedAgent);
}
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
if (poolBinding?.mode === "router") {
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, req.body.adapterConfig ?? {}, poolBinding, req.body.environmentId, false, !savedAgentId);
res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] });
return;
}
const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
companyId,
// Omission tests the saved selection. Explicit null tests a prospective
@@ -3640,6 +3638,20 @@ export function agentRoutes(
? restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig)
: inputAdapterConfig;
}
const selection = await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType: type, adapterConfig: adapterConfigForTest,
runner: req.body.runner ?? (savedAgent && savedAgent.adapterType === type ? (type === "paperclip_runner" ? undefined : "legacy") : undefined),
defaultEnvironmentId: requestedEnvironmentId,
});
type = selection.adapterType;
adapterConfigForTest = selection.adapterConfig;
const adapter = requireServerAdapter(type);
if (!savedAgent || req.body.runner !== undefined || type !== savedAgent.adapterType) await assertSelectableAdapterType(type, adapterConfigForTest);
if (poolBinding?.mode === "router") {
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, false, !savedAgentId);
res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] });
return;
}
if (type === "paperclip_runner" && inputAdapterConfig.provider === "openai_dot") {
const dotEnabled = await dotRunnerBroker(db).enabled();
const binding = savedAgentId ? await dotRunnerBroker(db).bindingForAgent(companyId, savedAgentId) : null;
@@ -4690,6 +4702,7 @@ export function agentRoutes(
onboardingFirstAgent: hireOnboardingFirstAgent,
// This intent flag is consumed below and must never reach agent create.
inheritRuntimeFrom,
runner: hireRunner,
...hireInput
} = req.body;
@@ -4715,6 +4728,8 @@ export function agentRoutes(
hireInput.adapterConfig = inheritNativeRunnerAdapterConfig(caller.adapterConfig);
hireInput.defaultEnvironmentId = caller.defaultEnvironmentId ?? null;
}
if (inheritRuntimeFrom === "caller" && hireRunner !== undefined) throw unprocessable("inheritRuntimeFrom=caller cannot be combined with runner");
Object.assign(hireInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...hireInput, runner: hireRunner }));
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType, hireInput.adapterConfig);
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, hireInput.runtimeConfig);
@@ -4845,6 +4860,7 @@ export function agentRoutes(
},
{
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
runnerResolved: true,
aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
claudeLogin: {
storedSessionId: hireStoredSessionId ?? null,
@@ -5026,8 +5042,10 @@ export function agentRoutes(
// The onboarding marker is not an agent column. The server consumes it to
// seed the chief-of-staff persona; it never reaches the insert values.
onboardingFirstAgent: createOnboardingFirstAgent,
runner: createRunner,
...createInput
} = req.body;
Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner }));
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig);
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
@@ -5101,6 +5119,7 @@ export function agentRoutes(
},
{
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
runnerResolved: true,
aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
claudeLogin: {
storedSessionId: createStoredSessionId ?? null,
@@ -5643,6 +5662,8 @@ export function agentRoutes(
}
const patchData = { ...(req.body as Record<string, unknown>) };
const runner = patchData.runner as AgentRunnerChoice | undefined;
delete patchData.runner;
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
delete patchData.replaceAdapterConfig;
// The apply-existing flag is not an agent column. The server binds the fixed
@@ -5668,6 +5689,15 @@ export function agentRoutes(
// it gets the selectable check; keeping the agent's current adapter (even
// one since disabled) stays allowed, so a disabled harness does not make an
// existing agent uneditable.
if (runner !== undefined) {
await assertCanUpdateAgent(req, existing);
const config = hasOwn(patchData, "adapterConfig") ? { ...(replaceAdapterConfig ? {} : existing.adapterConfig), ...asRecord(patchData.adapterConfig) } : existing.adapterConfig;
Object.assign(patchData, await resolveNewAgentRunnerForCompany(db, existing.companyId, {
adapterType: typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType,
adapterConfig: config, runner,
defaultEnvironmentId: hasOwn(patchData, "defaultEnvironmentId") ? patchData.defaultEnvironmentId as string | null : existing.defaultEnvironmentId,
}));
}
const nextAdapterType = hasOwn(patchData, "adapterType")
? assertKnownAdapterType(patchData.adapterType as string | null | undefined)
: existing.adapterType;
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set<string>(), settings: {} as Record<string, unknown>, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn() }));
vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} }));
vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled }));
vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) }));
vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.managedEnvironment }) }));
vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment }));
vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh }));
import { agentRunnerAvailability, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
const target = { driver: "local", platform: "linux", architecture: "x64" };
describe("server-owned Codex runner selection", () => {
beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); });
it("uses native Codex automatically with compatible configuration and is idempotent", () => {
const input = { adapterType: "codex_local", target, adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md", workspaceStrategy: { type: "git_worktree" }, timeoutSec: 60, search: false, fastMode: false, dangerouslyBypassApprovalsAndSandbox: true } };
const resolved = resolveNewAgentRunner(input);
expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", codexPermissionMode: "never", modelReasoningEffort: "high", env: input.adapterConfig.env, workspaceStrategy: input.adapterConfig.workspaceStrategy, timeoutSec: 60 } });
expect(resolved.adapterConfig).not.toHaveProperty("search");
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
expect(input.adapterConfig).toHaveProperty("search", false);
});
it.each(["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => {
expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } });
});
it("allows an explicit legacy runner and native to legacy round trip", () => {
expect(resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }).adapterType).toBe("codex_local");
expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn", codexPermissionMode: "never", modelReasoningEffort: "high" } })).toEqual({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", dangerouslyBypassApprovalsAndSandbox: true } });
});
it.each(["darwin", "win32"])("keeps unqualified %s targets legacy while preserving explicit native profiles", platform => {
const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture: "x64" } };
expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local");
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target: input.target };
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
});
it("automatically keeps unsupported targets and active external overrides legacy", () => {
const input = { adapterType: "codex_local", target: { driver: "local", platform: "win32", architecture: "x64" } };
expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local");
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
state.overridden.add("codex_local");
expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe("legacy");
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local");
});
it("does not select a disabled runner", () => {
state.disabled = ["paperclip_runner"];
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local");
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "paperclip" })).toThrow("unavailable");
state.disabled = ["codex_local"];
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target })).toThrow("Codex is disabled");
});
it.each(["extraArgs", "agentCommand", "networkScope", "filesystemScope", "search"])("rejects incompatible %s without changing runner", field => {
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target, adapterConfig: { [field]: true } })).toThrow("Legacy runner");
try { resolveNewAgentRunner({ adapterType: "codex_local", target, adapterConfig: { [field]: true } }); }
catch (error) { expect(error).toMatchObject({ status: 422, details: { fields: [field] } }); }
});
it("preserves explicit historical native profiles, including managed configurations", () => {
for (const config of [{ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, { provider: "claude_managed", managedProfileId: "profile", managedAgentsRetentionAcknowledged: true }, { provider: "aws_agentcore", agentCoreProfileId: "profile" }]) expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", adapterConfig: config })).toEqual({ adapterType: "paperclip_runner", adapterConfig: config });
});
it("rejects a foreign environment before resolving credentials or probing its platform", async () => {
state.getEnvironment.mockResolvedValue({ id: "foreign", driver: "ssh", status: "active", config: {} });
state.bindings.mockResolvedValue(["another-company"]);
await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "foreign" })).rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } });
expect(state.resolveEnvironment).not.toHaveBeenCalled(); expect(state.ssh).not.toHaveBeenCalled();
});
it("uses dispatch's instance default for a null agent override and checks the selected SSH platform", async () => {
state.settings = { defaultEnvironmentId: "default" };
state.getEnvironment.mockResolvedValue({ id: "default", driver: "ssh", status: "active", config: {} });
state.ssh.mockResolvedValue({ stdout: "Linux\naarch64\n" });
const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: null });
expect(state.getEnvironment).toHaveBeenCalledWith("default"); expect(state.bindings).toHaveBeenCalledWith("default");
expect(result.adapterType).toBe("codex_local");
});
it("does not infer a sandbox image architecture from its driver", async () => {
state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } });
const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" });
expect(result.adapterType).toBe("paperclip_runner"); expect(state.ssh).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,118 @@
import type { Db } from "@paperclipai/db";
import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunnerAvailability } from "@paperclipai/shared";
import { normalizeLegacyRunnerProvider, PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils";
import { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js";
import { getDisabledAdapterTypes } from "./adapter-plugin-store.js";
import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
import { forbidden, unprocessable } from "../errors.js";
import { runSshCommand } from "@paperclipai/adapter-utils/ssh";
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
type RunnerTarget = { driver: string; platform?: string; architecture?: string };
export interface AgentRunnerSelection {
adapterType?: string | null;
adapterConfig?: Record<string, unknown>;
runner?: AgentRunnerChoice;
target?: RunnerTarget;
}
export function agentRunnerAvailability(harness: string, target: RunnerTarget = { driver: "local", platform: process.platform, architecture: process.arch }): AgentRunnerAvailability {
const disabled = new Set(getDisabledAdapterTypes());
const supported = harness === "codex_local"
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
&& !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner")
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", target.driver)
&& (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? ""));
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" };
}
function incompatible(fields: string[]): never {
throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields });
}
/** Idempotent translation. Missing dependencies or credentials never select another runner. */
export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record<string, unknown> } {
const adapterType = input.adapterType ?? "process";
let config = { ...input.adapterConfig };
const harness = agentHarnessType(adapterType, config);
const native = adapterType === "paperclip_runner";
if (adapterType === "codex_local" && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable("Codex is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" });
// Explicit saved native profiles (including historical provider omission) retain execution.
if (native && (input.runner === undefined || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
const availability = agentRunnerAvailability(harness, input.target);
const choice = input.runner ?? "auto";
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
if (!useNative) {
if (!native && harness !== "codex_local") return { adapterType, adapterConfig: config };
if (!native && !["codexPermissionMode", "lifecycleMode", "idleTimeoutMs"].some(key => config[key] !== undefined)) return { adapterType, adapterConfig: config };
if (harness !== "codex_local") throw unprocessable("This native harness does not have a runner override in this release.", { code: "agent_runner_unavailable" });
if (config.codexPermissionMode !== undefined && config.codexPermissionMode !== "never") incompatible(["codexPermissionMode"]);
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
for (const key of ["provider", "codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs"]) delete config[key];
return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: true } };
}
if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
if (availability.defaultRunner !== "paperclip") throw unprocessable("Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
const unsupported = ["agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "search", "fastMode", "variant", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "permissionMode", "maxTurns", "disableWebSearch", "outputInactivityTimeoutMs", "terminalResultCleanupGraceMs"]
.filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0));
if (config.command && config.command !== "codex") unsupported.push("command");
if (config.mode && config.mode !== "persistent") unsupported.push("mode");
if (config.engine && config.engine !== "cli" && config.engine !== "acp") unsupported.push("engine");
if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions");
if (config.dangerouslyBypassApprovalsAndSandbox === false) unsupported.push("dangerouslyBypassApprovalsAndSandbox");
if (config.dangerouslyBypassSandbox === false) unsupported.push("dangerouslyBypassSandbox");
if (unsupported.length) incompatible(unsupported);
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
for (const key of ["engine", "command", "extraArgs", "args", "agentCommand", "mode", "nonInteractivePermissions", "stateDir", "warmHandleIdleMs", "dangerouslyBypassApprovalsAndSandbox", "dangerouslyBypassSandbox", "search", "fastMode", "reasoningEffort", "effort"]) delete config[key];
if (config.model !== undefined && typeof config.model !== "string") incompatible(["model"]);
config = { ...config, provider: "codex", model: typeof config.model === "string" && config.model.trim() ? config.model.trim() : PAPERCLIP_RUNNER_DEFAULT_MODELS.codex, codexPermissionMode: config.codexPermissionMode ?? "never", lifecycleMode: config.lifecycleMode ?? "per_turn", ...(effort === undefined ? {} : { modelReasoningEffort: effort }) };
try { resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: config }); }
catch (error) {
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
throw error;
}
return { adapterType: "paperclip_runner", adapterConfig: config };
}
/** Use the execution environment selected by dispatch, without starting a provider. */
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
if (agentHarnessType(input.adapterType ?? "process", input.adapterConfig) !== "codex_local"
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && input.runner === undefined)) return resolveNewAgentRunner(input);
await waitForExternalAdapters();
const { instanceSettingsService } = await import("./instance-settings.js");
const { environmentService } = await import("./environments.js");
const settings = await instanceSettingsService(db).get();
const envs = environmentService(db);
const environmentId = input.defaultEnvironmentId ?? settings.defaultEnvironmentId;
let environment = environmentId ? await envs.getById(environmentId) : null;
if (settings.experimental?.enableManagedSandboxOnly && (!environment || environment.driver === "local")) {
environment = await envs.findManagedSandboxEnvironment(companyId);
if (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", { code: "managed_sandbox_unavailable" });
}
if (settings.general?.executionMode === "kubernetes") {
environment = await envs.findKubernetesEnvironment(companyId);
if (!environment) throw unprocessable("The required Kubernetes environment is unavailable.", { code: "kubernetes_environment_unavailable" });
}
if (environmentId && !environment) throw unprocessable("Environment not found.");
if (environment) {
const boundCompanyIds = await envs.listBoundCompanyIds(environment.id);
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
if (environment.status === "archived") throw unprocessable("Environment is archived.");
if (environment.driver === "sandbox" && environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs.");
}
const driver = environment?.driver ?? "local";
let target: RunnerTarget = driver === "local" ? { driver, platform: process.platform, architecture: process.arch }
: { driver }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture.
if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") {
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
let output: string;
try { output = (await runSshCommand(parsed.config, "uname -s; uname -m", { timeoutMs: 10_000 })).stdout; }
catch { throw unprocessable("Could not check the SSH host's platform. Check its connection and retry, or select Legacy runner in Advanced.", { code: "agent_runner_platform_probe_failed" }); }
const [os = "", arch = ""] = output.trim().split(/\r?\n/);
target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch };
}
return resolveNewAgentRunner({ ...input, target });
}
+10 -1
View File
@@ -1,4 +1,6 @@
import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import type { AgentRunnerChoice } from "@paperclipai/shared";
import { withAccountingTransaction } from "./accounting-transaction.js";
import type { ActivityPublication } from "./activity-log.js";
import { agentAppearanceSchema, randomAgentAppearance, resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
@@ -136,6 +138,8 @@ interface UpdateAgentOptions {
}
interface CreateAgentOptions {
/** Reviewed/historical execution already resolved by a governed internal caller. */
runnerResolved?: boolean;
createdByUserId?: string | null;
aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null };
allowBuiltInAgentMetadata?: boolean;
@@ -922,7 +926,12 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
getById,
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">, options?: CreateAgentOptions) => {
create: async (companyId: string, input: Omit<typeof agents.$inferInsert, "companyId"> & { runner?: AgentRunnerChoice }, options?: CreateAgentOptions) => {
const { runner, ...data } = input;
const selection = options?.runnerResolved ? { adapterType: data.adapterType ?? "process", adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {} }
: await resolveNewAgentRunnerForCompany(db, companyId, { ...data, adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {}, runner });
data.adapterType = selection.adapterType;
data.adapterConfig = selection.adapterConfig;
if (data.appearance?.customAvatarAssetId) throw unprocessable("Create the agent before uploading its avatar");
assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options);
if (data.reportsTo) {
+1 -1
View File
@@ -189,7 +189,7 @@ export function approvalService(db: Db) {
spentMonthlyCents: 0,
permissions: undefined,
lastHeartbeatAt: null,
}, { createdByUserId: updated.requestedByAgentId ? null : updated.requestedByUserId });
}, { runnerResolved: true, createdByUserId: updated.requestedByAgentId ? null : updated.requestedByUserId });
hireApprovedAgentId = created?.id ?? null;
}
if (hireApprovedAgentId) {
+47 -19
View File
@@ -7,7 +7,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } f
import { and, desc, eq, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db";
import { syncRoutineVariablesWithTemplate } from "@paperclipai/shared";
import { agentHarnessType, agentRunner, syncRoutineVariablesWithTemplate, type AgentRunnerChoice } from "@paperclipai/shared";
import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
@@ -17,6 +17,7 @@ import { agentInstructionRevisionService } from "./agent-instruction-revisions.j
import type { AuthorizationActor } from "./authorization.js";
import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js";
import { agentService } from "./agents.js";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import { approvalService } from "./approvals.js";
import {
readBuiltInAgentMarker,
@@ -66,6 +67,7 @@ export interface BuiltInAgentState {
}
export interface BuiltInAgentProvisionInput {
runner?: AgentRunnerChoice;
adapterType?: string;
adapterConfig?: Record<string, unknown>;
budgetMonthlyCents?: number;
@@ -689,8 +691,8 @@ function selectPreferredAdapterType(
return selected?.adapterType ?? fallback;
}
function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string) {
if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(adapterType)) {
function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string, adapterConfig: Record<string, unknown> = {}) {
if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(agentHarnessType(adapterType, adapterConfig))) {
throw unprocessable(`Adapter type ${adapterType} is not allowed for built-in agent ${definition.key}`, {
code: "built_in_agent_adapter_not_allowed",
key: definition.key,
@@ -713,11 +715,11 @@ function hasCompleteAdapterConfig(adapterType: string, adapterConfig: unknown) {
return nonEmptyString(adapterConfig.model);
}
export function deriveBuiltInAgentStatus(agent: Pick<Agent, "adapterType" | "adapterConfig" | "status" | "pausedAt"> | null): BuiltInAgentStatus {
export function deriveBuiltInAgentStatus(agent: Pick<Agent, "adapterType" | "adapterConfig" | "status" | "pausedAt"> & { metadata?: Agent["metadata"] } | null): BuiltInAgentStatus {
if (!agent) return "not_provisioned";
if (agent.status === "pending_approval") return "pending_approval";
if (agent.status === "paused" || agent.pausedAt) return "paused";
return hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup";
return agent.metadata?.paperclipBuiltInSetupRequired !== true && hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup";
}
function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record<string, unknown> | null) {
@@ -729,8 +731,9 @@ function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record<s
function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput = {}) {
const adapterType = input.adapterType ?? defaultAdapterType(definition);
assertAdapterAllowed(definition, adapterType);
assertAdapterAllowed(definition, adapterType, input.adapterConfig);
return {
runner: input.runner,
name: definition.displayName,
role: definition.defaultRole,
title: definition.defaultTitle ?? null,
@@ -752,7 +755,7 @@ async function assertKnownBuiltInAgentModel(
const model = typeof adapterConfig.model === "string" ? adapterConfig.model.trim() : "";
if (!model || !hasCompleteAdapterConfig(adapterType, adapterConfig)) return;
const models = await listAdapterModels(adapterType);
const models = await listAdapterModels(agentHarnessType(adapterType, adapterConfig));
if (models.length === 0 || models.some((candidate) => candidate.id === model)) return;
throw unprocessable(`Model "${model}" is not available for adapter ${adapterType}.`, {
@@ -775,7 +778,7 @@ function builtInAgentNotConfiguredError(state: BuiltInAgentState) {
}
function hasProvisionSetupInput(input: BuiltInAgentProvisionInput) {
return input.adapterType !== undefined || input.adapterConfig !== undefined || input.budgetMonthlyCents !== undefined;
return input.adapterType !== undefined || input.adapterConfig !== undefined || input.runner !== undefined || input.budgetMonthlyCents !== undefined;
}
function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) {
@@ -883,13 +886,13 @@ export function builtInAgentService(db: Db) {
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const candidate = rows.find((row) =>
definition.allowedAdapterTypes?.includes(row.adapterType)
definition.allowedAdapterTypes?.includes(agentHarnessType(row.adapterType, row.adapterConfig))
&& hasCompleteAdapterConfig(row.adapterType, row.adapterConfig)
);
if (!candidate) return input;
return {
...input,
adapterType: candidate.adapterType,
adapterType: agentHarnessType(candidate.adapterType, candidate.adapterConfig),
adapterConfig: {},
};
}
@@ -1735,7 +1738,7 @@ export function builtInAgentService(db: Db) {
&& !existingPendingApproval
&& input.adapterType === undefined
&& input.adapterConfig === undefined
&& hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig),
&& input.runner === undefined,
);
const resolvedInput = existingPendingApproval || preserveExistingAdapter
? input
@@ -1749,12 +1752,24 @@ export function builtInAgentService(db: Db) {
};
if (
!existingPendingApproval
&& (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined)
&& (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined || resolvedInput.runner !== undefined)
) {
const adapterType = resolvedInput.adapterType ?? existing.adapterType;
assertAdapterAllowed(definition, adapterType);
patch.adapterType = adapterType;
patch.adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
assertAdapterAllowed(definition, adapterType, adapterConfig);
const sameCodexHarness = agentHarnessType(adapterType, adapterConfig) === "codex_local"
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === "codex_local";
const runner = resolvedInput.runner ?? (sameCodexHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
? agentRunner(existing.adapterType) : undefined);
Object.assign(patch, runner !== undefined || existing.metadata?.paperclipBuiltInSetupRequired === true
? await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType, adapterConfig, runner,
defaultEnvironmentId: existing.defaultEnvironmentId,
})
: { adapterType, adapterConfig });
if (hasCompleteAdapterConfig(patch.adapterType ?? existing.adapterType, patch.adapterConfig ?? existing.adapterConfig)) {
patch.metadata = { ...patch.metadata, paperclipBuiltInSetupRequired: false };
}
}
if (!existingPendingApproval && resolvedInput.budgetMonthlyCents !== undefined) {
patch.budgetMonthlyCents = resolvedInput.budgetMonthlyCents;
@@ -1793,7 +1808,14 @@ export function builtInAgentService(db: Db) {
: null,
pausedAt: definition.defaultStatus === "paused" ? new Date() : null,
reportsTo,
metadata: builtInMetadata(definition),
metadata: {
...builtInMetadata(definition),
// Provider defaults must not turn an unconfigured built-in into a ready agent.
paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig(
definitionPatch(definition, resolvedInput).adapterType,
definitionPatch(definition, resolvedInput).adapterConfig,
),
},
runtimeConfig: definition.defaultRuntimeConfig ?? {},
permissions: definition.defaultPermissions ?? {},
spentMonthlyCents: 0,
@@ -1858,7 +1880,7 @@ export function builtInAgentService(db: Db) {
};
}
const providesAdapterSetup = input.adapterType !== undefined || input.adapterConfig !== undefined;
const providesAdapterSetup = input.adapterType !== undefined || input.adapterConfig !== undefined || input.runner !== undefined;
// A built-in row that has never completed adapter setup (incomplete
// config, i.e. `needs_setup`) is still first-time configuration, not a
@@ -1868,7 +1890,7 @@ export function builtInAgentService(db: Db) {
// adapterConfig is still empty. Completing that setup applies directly, as
// it does when board approval is not required, instead of dead-ending on a
// fresh board-approval requirement the operator can never satisfy.
if (providesAdapterSetup && !hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig)) {
if (providesAdapterSetup && (existing.metadata?.paperclipBuiltInSetupRequired === true || !hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig))) {
return { state: await ensure(companyId, key, input), approval: null };
}
@@ -1894,7 +1916,13 @@ export function builtInAgentService(db: Db) {
...definitionPatch(definition, input),
status: "pending_approval",
reportsTo,
metadata: builtInMetadata(definition),
metadata: {
...builtInMetadata(definition),
paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig(
definitionPatch(definition, input).adapterType,
definitionPatch(definition, input).adapterConfig,
),
},
runtimeConfig: definition.defaultRuntimeConfig ?? {},
permissions: definition.defaultPermissions ?? {},
spentMonthlyCents: 0,
+38 -4
View File
@@ -1,4 +1,5 @@
import { agentAppearanceSchema } from "@paperclipai/shared";
import { agentAppearanceSchema, agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { execFile } from "node:child_process";
@@ -3233,6 +3234,12 @@ function buildManifestFromPackageFiles(
const slug = asString(frontmatter.slug) ?? fallbackSlug;
const extension = isPlainRecord(paperclipAgents[slug]) ? paperclipAgents[slug] : {};
const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null;
const runner = extensionAdapter?.runner;
if (runner !== undefined && (typeof runner !== "string" || !["auto", "paperclip", "legacy"].includes(runner))) {
throw unprocessable(`Invalid runner choice for agent ${slug}. Use auto, paperclip, or legacy.`, {
code: "agent_runner_choice_invalid", field: `agents.${slug}.adapter.runner`,
});
}
const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null;
const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null;
const extensionPermissionGrants = normalizePortablePermissionGrants(extension.permissionGrants);
@@ -3256,6 +3263,7 @@ function buildManifestFromPackageFiles(
reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo),
reportsToExistingAgentId: asString(extension.reportsToExistingAgentId),
reportsToExistingAgentSlug: asString(extension.reportsToExistingAgentSlug),
runner: runner as AgentRunnerChoice | undefined,
adapterType: asString(extensionAdapter?.type) ?? "process",
adapterConfig,
runtimeConfig,
@@ -3651,8 +3659,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
adapterConfig: Record<string, unknown>,
desiredSkills: string[],
mode: ImportMode,
runner?: AgentRunnerChoice,
defaultEnvironmentId?: string | null,
) {
const effectiveAdapterType = assertKnownImportAdapterType(adapterType);
const resolved = await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType, adapterConfig, runner, defaultEnvironmentId,
});
adapterConfig = resolved.adapterConfig;
const effectiveAdapterType = assertKnownImportAdapterType(resolved.adapterType);
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
@@ -4276,6 +4290,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
capabilities: agent.capabilities ?? null,
adapter: {
type: agent.adapterType,
runner: agentRunner(agent.adapterType),
config: portableAdapterConfig,
},
runtime: portableRuntimeConfig,
@@ -5277,7 +5292,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
"OpenAI Dot is experimental and disabled on this instance.",
{ code: "paperclip_runner_dot_disabled" },
);
} else if (experimental.enableNativeRunner !== true) {
} else if (config.provider !== "codex" && config.provider !== undefined && experimental.enableNativeRunner !== true) {
throw unprocessable(
"Paperclip Runner is experimental and disabled on this instance.",
{ code: "paperclip_runner_rollout_disabled" },
@@ -5607,12 +5622,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef);
const existingImportAgent = planAgent.action === "update" && planAgent.existingAgentId
? await agents.getById(planAgent.existingAgentId) : null;
const selectedAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
const selectedHarness = agentHarnessType(selectedAdapterType, baseAdapterConfig);
// Config-only edits keep the package's explicit runner or an existing
// agent's execution. A genuinely changed harness selects its default.
let runner = adapterOverride?.runner;
if (runner === undefined && adapterOverride?.adapterType !== "paperclip_runner") {
if (selectedHarness === agentHarnessType(manifestAgent.adapterType, manifestAgent.adapterConfig)) {
runner = manifestAgent.runner;
}
if (runner === undefined && existingImportAgent
&& selectedHarness === agentHarnessType(existingImportAgent.adapterType, existingImportAgent.adapterConfig)) {
runner = agentRunner(existingImportAgent.adapterType);
}
}
const normalizedAdapter = await prepareImportedAgentAdapter(
targetCompany.id,
adapterOverride?.adapterType ?? manifestAgent.adapterType,
selectedAdapterType,
baseAdapterConfig,
desiredSkills,
mode,
runner,
existingImportAgent?.defaultEnvironmentId,
);
const patch = {
name: planAgent.plannedName,
@@ -5692,6 +5725,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
let created = await agents.create(targetCompany.id, {
...patch,
runner: agentRunner(patch.adapterType),
...automationPausePatch,
status: pauseAutomations ? "paused" : "idle",
}, { createdByUserId: actorUserId });
@@ -12624,12 +12624,12 @@ describe("runnerd provider runtime wiring", () => {
it.each([
...["current", "preinstalled-exact", "preinstalled-mismatch", "preinstalled-error", "preinstalled-timeout", "stale", "missing", "retained", "retained-mismatch", "retained-error", "retained-timeout", "retained-explicit"]
.map((image) => ({ image, version: "0.160.0", compatible: true })),
.map((image) => ({ image, version: "0.160.0" })),
...["0.149.0", "0.149.1", "0.153.4", "0.156.0", "0.160.1"]
.map((version) => ({ image: "current", version, compatible: true })),
.map((version) => ({ image: "current", version })),
...["0.148.9", "0.161.0", "1.0.0", "0.160.0-alpha.1", "unknown"]
.map((version) => ({ image: "current", version, compatible: false })),
])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version, compatible }) => {
.map((version) => ({ image: "current", version })),
])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version }) => {
const retained = image.startsWith("retained");
const exactRetained = image === "retained" || image === "retained-explicit";
const needsReplacement = image !== "current" && image !== "preinstalled-exact" && !exactRetained;
@@ -12721,16 +12721,10 @@ describe("runnerd provider runtime wiring", () => {
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
};
await expect(transport.controlPlaneRegistration({})).rejects.toThrow(
compatible ? "reached-preinstalled-codex-verification" : "runner_remote_provider_artifact_incompatible: supported Codex versions >=0.149.0 <0.161.0",
"reached-preinstalled-codex-verification",
);
if (!compatible) {
expect(syncIn).not.toHaveBeenCalled();
expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm" || call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false);
expect(onLog).not.toHaveBeenCalledWith("stderr", expect.stringContaining("using compatible Codex"));
return;
}
if (version !== "0.160.0") {
expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining(`using compatible Codex ${version}`));
expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining(`using installed Codex ${version === "unknown" || version.includes("alpha") ? "unrecognized version" : version}`));
}
if (needsReplacement) {
expect(transport.runnerBinary).toBe(controllerArtifact);
@@ -12776,12 +12770,9 @@ describe("runnerd provider runtime wiring", () => {
].map((scenario) => ({ ...scenario, recover: false })),
{ version: "0.156.0", model: "gpt-6.1-sol", floor: null, recover: true },
{ version: "0.158.0", model: "gpt-6.1-sol", floor: null, recover: true },
])("verifies sandbox Codex $version against the prepared $model selection (floor=$floor, recover=$recover)", async ({ version, model, floor, recover }) => {
// A preinstalled Codex inside the compatibility window can still be too
// old for the configured model: the ChatGPT backend rejects every turn
// with "not supported when using Codex with a ChatGPT account". The
// verifier names the stale image instead of letting the run fail as an
// account problem.
])("lets sandbox Codex $version reach actual model verification ($model, recover=$recover)", async ({ version, model, recover }) => {
// Version metadata is diagnostic. A working app-server reaches the model
// turn instead of being rejected or replaced by a static CLI version floor.
const preparedModel = recover ? resolvePaperclipRunnerNativeProviderInput({
backend: "codex_app_server", adapterConfig: { provider: "codex", model }, codexCliVersion: version,
}).model : model;
@@ -12851,16 +12842,10 @@ describe("runnerd provider runtime wiring", () => {
);
}
await expect(transport.controlPlaneRegistration({})).rejects.toThrow(
floor
? `runner_remote_provider_artifact_incompatible: ${model} requires Codex ${floor} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex 0.160.0 or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@0.160.0`
: "reached-preinstalled-codex-verification",
"reached-preinstalled-codex-verification",
);
if (floor) {
// No npm spec is configured, so there is no fallback install; the run
// fails before launch instead of running against the stale CLI.
expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false);
expect(remoteExecute.mock.calls.some(([call]) => call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false);
}
expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false);
});
it("binds a remote launch to the configured controller-owned runner artifact", async () => {
@@ -16,11 +16,7 @@ import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server";
import { inferOpenAiCompatibleBiller, type AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
import {
isSupportedRemoteCodexVersion,
parseCodexCliVersion,
REMOTE_CODEX_SUPPORTED_RANGE,
} from "./codex-runtime-compatibility.js";
import { parseCodexCliVersion } from "./codex-runtime-compatibility.js";
import { createNativeToolTrace, type NativeToolTrace } from "./native-tool-trace.js";
import { createNativeProviderFailureObservation } from "./native-provider-failure.js";
import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js";
@@ -29,11 +25,6 @@ import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn }
import { createLocalNativeQuestionBridge } from "./local-native-question-bridge.js";
import { readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js";
import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
import {
codexCliVersionAtLeast,
minimumCodexCliVersionForModel,
normalizeCodexModel,
} from "@paperclipai/adapter-codex-local";
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
@@ -10174,7 +10165,7 @@ export function assertRemoteRunnerBuildMetadata(
}
}
async function stageRemoteRunnerFile(input: {
export async function stageRemoteRunnerFile(input: {
target: Extract<AdapterExecutionTarget, { kind: "remote" }>;
runner: CommandManagedRuntimeRunner;
sourcePath: string;
@@ -10566,7 +10557,7 @@ export async function syncRemoteRunnerDirectoryOut(input: {
}
}
async function readRemoteRunnerState(input: {
export async function readRemoteRunnerState(input: {
runner: CommandManagedRuntimeRunner;
stateDirectory: string;
}): Promise<Record<string, unknown>> {
@@ -11498,76 +11489,6 @@ async function createRunnerdBackendWithinSessionClaim(
})
: Promise.resolve();
const verifyRemoteRunner = async (
requiredMode: "dial_wss" | "listen_ws",
executable = remoteBinary,
) => {
if (!remoteTarget || !remoteCommandRunner || !executable) return;
const metadataResult = await remoteCommandRunner.execute({
command: executable,
args: ["--build-metadata"],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 30_000,
});
if (metadataResult.exitCode !== 0 || metadataResult.timedOut) {
throw new Error("runner_remote_artifact_verification_failed");
}
let metadata: Record<string, unknown>;
try {
metadata = JSON.parse(metadataResult.stdout) as Record<string, unknown>;
} catch (error) {
throw new Error("runner_remote_artifact_metadata_invalid", {
cause: error,
});
}
assertRemoteRunnerBuildMetadata(metadata, requiredMode);
};
const reportedCodexVersions = new Set<string>();
const verifyRemoteCodex = async (executable = remoteCodexBinary) => {
if (!remoteTarget || !remoteCommandRunner || !executable) return;
const versionResult = await remoteCommandRunner.execute({
command: executable,
args: ["--version"],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 30_000,
});
if (versionResult.exitCode !== 0 || versionResult.timedOut) {
throw new Error("runner_remote_codex_artifact_verification_failed");
}
const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`;
const version = parseCodexCliVersion(versionOutput);
if (!version || !isSupportedRemoteCodexVersion(version)) {
throw new Error(
`runner_remote_provider_artifact_incompatible: supported Codex versions ${REMOTE_CODEX_SUPPORTED_RANGE}, received ${version ?? "an unrecognized or prerelease version"}; install a supported stable Codex release or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`,
);
}
// A Codex inside the compatibility window can still be too old for the
// configured model: the ChatGPT backend rejects a model from clients
// below the model's floor on every turn. Fail before launch with the
// exact gap, so a stale sandbox image is not reported as an account
// problem. When a preinstalled Codex fails here and an npm spec is
// configured, the caller falls back to installing the pinned release.
const configuredModel = input.execution.provider.kind === "codex"
? input.execution.provider.model
: null;
const modelMinimum = minimumCodexCliVersionForModel(configuredModel);
if (modelMinimum && !codexCliVersionAtLeast(version, modelMinimum)) {
throw new Error(
`runner_remote_provider_artifact_incompatible: ${normalizeCodexModel(configuredModel)} requires Codex ${modelMinimum} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex ${REMOTE_PROVIDER_PACK_PINS.codex} or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`,
);
}
if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) {
reportedCodexVersions.add(version);
await input.onLog?.(
"stderr",
`[paperclip-runner] using compatible Codex ${version} (supported ${REMOTE_CODEX_SUPPORTED_RANGE}; install pin ${REMOTE_PROVIDER_PACK_PINS.codex})\n`,
);
}
};
const verifyRemoteProviderPack = async (packRoot: string) => {
if (!remoteTarget || !remoteCommandRunner || !expectedProviderPackManifest)
return;
@@ -11652,41 +11573,20 @@ async function createRunnerdBackendWithinSessionClaim(
return parseRemoteExecutableCandidate(result.stdout);
};
// Image policy: keep one latest stable CLI installation shared by native and
// local adapters. Preferred bin entries must point to that same installation;
// never bake an older global CLI alongside a private runner-only version.
const discoverPreinstalledExecutable = async (
name: "paperclip-runnerd" | "codex",
) => {
if (!remoteTarget || !remoteCommandRunner) return null;
return discoverRemoteExecutable(remoteCommandRunner, remoteTarget.remoteCwd, name);
};
const linkPreinstalledExecutable = async (
sourcePath: string,
targetPath: string,
) => {
if (!remoteTarget || !remoteCommandRunner) return;
const escapedSource = sourcePath.replaceAll("'", "'\\''");
const escapedTarget = targetPath.replaceAll("'", "'\\''");
const escapedDirectory = posix.dirname(targetPath).replaceAll("'", "'\\''");
const result = await remoteCommandRunner.execute({
command: "sh",
args: [
"-c",
targetPath === remoteCodexBinary
? buildRemoteCodexLauncherCommand(sourcePath, targetPath)
: `umask 077; mkdir -p '${escapedDirectory}' && ` +
`ln -sfn '${escapedSource}' '${escapedTarget}'`,
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 10_000,
});
if (result.exitCode !== 0 || result.timedOut) {
throw new Error("runner_remote_preinstalled_link_failed");
}
const remoteNativeArtifacts = remoteTarget && remoteCommandRunner && remoteBinary && remoteRuntimeRoot
? createRemoteNativeArtifactPreparation({
target: remoteTarget, runner: remoteCommandRunner, remoteBinary, controllerRunnerBinary,
remoteRuntimeRoot, remoteCodexBinary, runnerRemoteBinaryPath: input.runnerRemoteBinaryPath,
runnerRemoteCodexPath: input.runnerRemoteCodexPath, runnerRemoteCodexNpmSpec: input.runnerRemoteCodexNpmSpec,
model: input.execution.provider.kind === "codex" ? input.execution.provider.model : null,
reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null,
trace: input.trace, onLog: input.onLog,
})
: null;
const verifyRemoteRunner = async (mode: "dial_wss" | "listen_ws", executable?: string | null) => {
if (remoteNativeArtifacts) await remoteNativeArtifacts.verifyRemoteRunner(mode, executable ?? undefined);
};
const verifyRemoteCodex = async () => { await remoteNativeArtifacts?.verifyRemoteCodex(); };
const prepareRemoteRunner = async (
requiredMode: "dial_wss" | "listen_ws",
@@ -11708,234 +11608,7 @@ async function createRunnerdBackendWithinSessionClaim(
remotePrepared = true;
return;
}
// Compatibility metadata does not prove artifact identity. Reuse only the
// exact controller-owned bytes when the controller artifact is available.
const matchesControllerRunnerArtifact = async (executable: string): Promise<boolean> => {
const expected = createHash("sha256")
.update(readFileSync(controllerRunnerBinary))
.digest("hex");
try {
const probe = await remoteCommandRunner.execute({
command: "sh",
args: [
"-c",
'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi',
"paperclip-runner-artifact",
executable,
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 10_000,
});
return probe.exitCode === 0 && !probe.timedOut &&
/^[a-f0-9]{64}\s/.test(probe.stdout) &&
probe.stdout.trim().split(/\s+/)[0] === expected;
} catch {
// An unavailable checksum uses the verified staging path.
return false;
}
};
let runnerArtifactPrepared = false;
if (
sandboxLeaseAcquisition?.outcome === "resumed" &&
existsSync(controllerRunnerBinary)
) {
runnerArtifactPrepared = await measureNativeRunnerSpan(
input.trace,
"runner.artifact.verify_retained",
() => matchesControllerRunnerArtifact(remoteBinary),
);
}
const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null;
if (!runnerArtifactPrepared && mayUsePreinstalledRunnerArtifact(explicitRemoteBinary)) {
const preinstalledRunner = await measureNativeRunnerSpan(
input.trace,
"runner.artifact.discover",
() => discoverPreinstalledExecutable("paperclip-runnerd"),
);
if (preinstalledRunner) {
try {
await measureNativeRunnerSpan(
input.trace,
"runner.artifact.verify_preinstalled",
() => verifyRemoteRunner(requiredMode, preinstalledRunner),
);
if (existsSync(controllerRunnerBinary) &&
!await matchesControllerRunnerArtifact(preinstalledRunner)) {
throw new Error("runner_remote_preinstalled_artifact_mismatch");
}
await measureNativeRunnerSpan(
input.trace,
"runner.artifact.link",
() => linkPreinstalledExecutable(preinstalledRunner, remoteBinary),
);
runnerArtifactPrepared = true;
await input.onLog?.(
"stderr",
"[paperclip-runner] using preinstalled runnerd from the sandbox image\n",
);
} catch {
runnerArtifactPrepared = false;
}
}
}
if (!runnerArtifactPrepared) {
// Upload the same artifact used for the controller identity. The server
// vendors the runner under vendor/paperclip-runner/bin, so the package
// development fallback cannot locate it in a deployed server.
const sourceBinary = controllerRunnerBinary;
if (!existsSync(sourceBinary)) {
throw new Error("runner_remote_artifact_unavailable");
}
if (!explicitRemoteBinary && !remotePiCompanion) {
const platform = await remoteCommandRunner.execute({
command: "sh",
args: ["-c", "uname -s; uname -m"],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 10_000,
});
const [remoteOs = "", remoteArch = ""] = platform.stdout
.trim()
.split(/\r?\n/);
const localOs =
process.platform === "darwin"
? "Darwin"
: process.platform === "linux"
? "Linux"
: process.platform;
const localArch =
process.arch === "x64"
? "x86_64"
: process.arch === "arm64"
? "aarch64"
: process.arch;
const archMatches =
remoteArch === localArch ||
(localArch === "aarch64" && remoteArch === "arm64");
if (
platform.exitCode !== 0 ||
platform.timedOut ||
remoteOs !== localOs ||
!archMatches
) {
throw new Error(
"runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the remote OS and architecture",
);
}
}
await stageRemoteRunnerFile({
target: remoteTarget,
runner: remoteCommandRunner,
sourcePath: sourceBinary,
targetPath: remoteBinary,
mode: 0o700,
});
}
await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () =>
verifyRemoteRunner(requiredMode),
);
if (remoteCodexBinary && explicitRemoteCodex) {
if (!existsSync(explicitRemoteCodex)) {
throw new Error("runner_remote_codex_artifact_unavailable");
}
await stageRemoteRunnerFile({
target: remoteTarget,
runner: remoteCommandRunner,
sourcePath: explicitRemoteCodex,
targetPath: remoteCodexBinary,
mode: 0o700,
});
await verifyRemoteCodex();
}
if (remoteCodexBinary && remoteCodexNpmSpec) {
let usedPreinstalledCodex = false;
const preinstalledCodex = await measureNativeRunnerSpan(
input.trace,
"harness.artifact.discover",
() => discoverPreinstalledExecutable("codex"),
);
if (preinstalledCodex) {
try {
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.verify_preinstalled",
() => verifyRemoteCodex(preinstalledCodex),
);
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.link",
() =>
linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary),
);
usedPreinstalledCodex = true;
await input.onLog?.(
"stderr",
"[paperclip-runner] using preinstalled Codex from the sandbox image\n",
);
} catch {
usedPreinstalledCodex = false;
}
}
if (!usedPreinstalledCodex) {
const installRoot = posix.join(
remoteRuntimeRoot!,
"harnesses",
"codex",
);
const installResult = await remoteCommandRunner.execute({
command: "npm",
args: [
"install",
"--prefix",
installRoot,
"--no-audit",
"--no-fund",
remoteCodexNpmSpec,
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 180_000,
});
if (installResult.exitCode !== 0 || installResult.timedOut) {
throw new Error("runner_remote_codex_install_failed");
}
}
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.verify",
() => verifyRemoteCodex(),
);
}
if (remoteCodexBinary && !explicitRemoteCodex && !remoteCodexNpmSpec) {
const preinstalledCodex = await measureNativeRunnerSpan(
input.trace,
"harness.artifact.discover",
() => discoverPreinstalledExecutable("codex"),
);
if (!preinstalledCodex) {
throw new Error(
"runner_remote_codex_artifact_unavailable: install codex in the sandbox image or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC",
);
}
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.verify_preinstalled",
() => verifyRemoteCodex(preinstalledCodex),
);
await measureNativeRunnerSpan(input.trace, "harness.artifact.link", () =>
linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary),
);
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.verify",
() => verifyRemoteCodex(),
);
await input.onLog?.(
"stderr",
"[paperclip-runner] using preinstalled Codex from the sandbox image\n",
);
}
await remoteNativeArtifacts?.prepare(requiredMode);
if (
requiresRemoteProviderPack &&
expectedProviderPackManifest &&
@@ -13559,3 +13232,350 @@ async function createRunnerdBackendWithinSessionClaim(
},
} satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession };
}
/** Shared task/setup preparation; setup exercises the same remote artifact contract. */
export function createRemoteNativeArtifactPreparation(input: {
target: Extract<AdapterExecutionTarget, { kind: "remote" }>;
runner: CommandManagedRuntimeRunner;
remoteBinary: string;
controllerRunnerBinary: string;
remoteRuntimeRoot: string;
remoteCodexBinary?: string | null;
runnerRemoteBinaryPath?: string | null;
runnerRemoteCodexPath?: string | null;
runnerRemoteCodexNpmSpec?: string | null;
model?: string | null;
reuseRetainedRunner?: boolean;
/** Existing operator-imported Pi companion already verifies its target-platform artifact. */
targetPlatformArtifactVerified?: boolean;
trace?: NativeRunTrace;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) {
const remoteTarget = input.target;
const remoteCommandRunner = input.runner;
const { remoteBinary, controllerRunnerBinary, remoteRuntimeRoot } = input;
const remoteCodexBinary = input.remoteCodexBinary ?? null;
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
if (explicitRemoteCodex && remoteCodexNpmSpec) throw new Error("runner_remote_codex_source_conflict");
const verifyRemoteRunner = async (
requiredMode: "dial_wss" | "listen_ws",
executable = remoteBinary,
) => {
if (!remoteTarget || !remoteCommandRunner || !executable) return;
const metadataResult = await remoteCommandRunner.execute({
command: executable,
args: ["--build-metadata"],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 30_000,
});
if (metadataResult.exitCode !== 0 || metadataResult.timedOut) {
throw new Error("runner_remote_artifact_verification_failed");
}
let metadata: Record<string, unknown>;
try {
metadata = JSON.parse(metadataResult.stdout) as Record<string, unknown>;
} catch (error) {
throw new Error("runner_remote_artifact_metadata_invalid", {
cause: error,
});
}
assertRemoteRunnerBuildMetadata(metadata, requiredMode);
};
const reportedCodexVersions = new Set<string | null>();
const verifyRemoteCodex = async (executable = remoteCodexBinary) => {
if (!remoteTarget || !remoteCommandRunner || !executable) return;
const versionResult = await remoteCommandRunner.execute({
command: executable,
args: ["--version"],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 30_000,
});
if (versionResult.exitCode !== 0 || versionResult.timedOut) {
throw new Error("runner_remote_codex_artifact_verification_failed");
}
const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`;
const version = parseCodexCliVersion(versionOutput);
// The install pin describes our tested image, not a CLI admission rule.
// Ordinary installations reach the real app-server/model probe; a version
// difference alone must neither fail setup nor replace a working CLI.
if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) {
reportedCodexVersions.add(version);
await input.onLog?.(
"stderr",
`[paperclip-runner] using installed Codex ${version ?? "unrecognized version"} (image install pin ${REMOTE_PROVIDER_PACK_PINS.codex}); compatibility is checked by the native app-server\n`,
);
}
};
// Image policy: keep one latest stable CLI installation shared by native and
// local adapters. Preferred bin entries must point to that same installation;
// never bake an older global CLI alongside a private runner-only version.
const discoverPreinstalledExecutable = async (
name: "paperclip-runnerd" | "codex",
) => {
if (!remoteTarget || !remoteCommandRunner) return null;
return discoverRemoteExecutable(remoteCommandRunner, remoteTarget.remoteCwd, name);
};
const linkPreinstalledExecutable = async (
sourcePath: string,
targetPath: string,
) => {
if (!remoteTarget || !remoteCommandRunner) return;
const escapedSource = sourcePath.replaceAll("'", "'\\''");
const escapedTarget = targetPath.replaceAll("'", "'\\''");
const escapedDirectory = posix.dirname(targetPath).replaceAll("'", "'\\''");
const result = await remoteCommandRunner.execute({
command: "sh",
args: [
"-c",
targetPath === remoteCodexBinary
? buildRemoteCodexLauncherCommand(sourcePath, targetPath)
: `umask 077; mkdir -p '${escapedDirectory}' && ` +
`ln -sfn '${escapedSource}' '${escapedTarget}'`,
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 10_000,
});
if (result.exitCode !== 0 || result.timedOut) {
throw new Error("runner_remote_preinstalled_link_failed");
}
};
const prepare = async (requiredMode: "dial_wss" | "listen_ws") => {
// Compatibility metadata does not prove artifact identity. Reuse only the
// exact controller-owned bytes when the controller artifact is available.
const matchesControllerRunnerArtifact = async (executable: string): Promise<boolean> => {
const expected = createHash("sha256")
.update(readFileSync(controllerRunnerBinary))
.digest("hex");
try {
const probe = await remoteCommandRunner.execute({
command: "sh",
args: [
"-c",
'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi',
"paperclip-runner-artifact",
executable,
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 10_000,
});
return probe.exitCode === 0 && !probe.timedOut &&
/^[a-f0-9]{64}\s/.test(probe.stdout) &&
probe.stdout.trim().split(/\s+/)[0] === expected;
} catch {
// An unavailable checksum uses the verified staging path.
return false;
}
};
let runnerArtifactPrepared = false;
if (
input.reuseRetainedRunner === true &&
existsSync(controllerRunnerBinary)
) {
runnerArtifactPrepared = await measureNativeRunnerSpan(
input.trace,
"runner.artifact.verify_retained",
() => matchesControllerRunnerArtifact(remoteBinary),
);
}
const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null;
if (!runnerArtifactPrepared && mayUsePreinstalledRunnerArtifact(explicitRemoteBinary)) {
const preinstalledRunner = await measureNativeRunnerSpan(
input.trace,
"runner.artifact.discover",
() => discoverPreinstalledExecutable("paperclip-runnerd"),
);
if (preinstalledRunner) {
try {
await measureNativeRunnerSpan(
input.trace,
"runner.artifact.verify_preinstalled",
() => verifyRemoteRunner(requiredMode, preinstalledRunner),
);
if (existsSync(controllerRunnerBinary) &&
!await matchesControllerRunnerArtifact(preinstalledRunner)) {
throw new Error("runner_remote_preinstalled_artifact_mismatch");
}
await measureNativeRunnerSpan(
input.trace,
"runner.artifact.link",
() => linkPreinstalledExecutable(preinstalledRunner, remoteBinary),
);
runnerArtifactPrepared = true;
await input.onLog?.(
"stderr",
"[paperclip-runner] using preinstalled runnerd from the sandbox image\n",
);
} catch {
runnerArtifactPrepared = false;
}
}
}
if (!runnerArtifactPrepared) {
// Upload the same artifact used for the controller identity. The server
// vendors the runner under vendor/paperclip-runner/bin, so the package
// development fallback cannot locate it in a deployed server.
const sourceBinary = controllerRunnerBinary;
if (!existsSync(sourceBinary)) {
throw new Error("runner_remote_artifact_unavailable");
}
if (!explicitRemoteBinary && !input.targetPlatformArtifactVerified) {
const platform = await remoteCommandRunner.execute({
command: "sh",
args: ["-c", "uname -s; uname -m"],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 10_000,
});
const [remoteOs = "", remoteArch = ""] = platform.stdout
.trim()
.split(/\r?\n/);
const localOs =
process.platform === "darwin"
? "Darwin"
: process.platform === "linux"
? "Linux"
: process.platform;
const localArch =
process.arch === "x64"
? "x86_64"
: process.arch === "arm64"
? "aarch64"
: process.arch;
const archMatches =
remoteArch === localArch ||
(localArch === "aarch64" && remoteArch === "arm64");
if (
platform.exitCode !== 0 ||
platform.timedOut ||
remoteOs !== localOs ||
!archMatches
) {
throw new Error(
"runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the remote OS and architecture",
);
}
}
await stageRemoteRunnerFile({
target: remoteTarget,
runner: remoteCommandRunner,
sourcePath: sourceBinary,
targetPath: remoteBinary,
mode: 0o700,
});
}
await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () =>
verifyRemoteRunner(requiredMode),
);
if (remoteCodexBinary && explicitRemoteCodex) {
if (!existsSync(explicitRemoteCodex)) {
throw new Error("runner_remote_codex_artifact_unavailable");
}
await stageRemoteRunnerFile({
target: remoteTarget,
runner: remoteCommandRunner,
sourcePath: explicitRemoteCodex,
targetPath: remoteCodexBinary,
mode: 0o700,
});
await verifyRemoteCodex();
}
if (remoteCodexBinary && remoteCodexNpmSpec) {
let usedPreinstalledCodex = false;
const preinstalledCodex = await measureNativeRunnerSpan(
input.trace,
"harness.artifact.discover",
() => discoverPreinstalledExecutable("codex"),
);
if (preinstalledCodex) {
try {
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.verify_preinstalled",
() => verifyRemoteCodex(preinstalledCodex),
);
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.link",
() =>
linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary),
);
usedPreinstalledCodex = true;
await input.onLog?.(
"stderr",
"[paperclip-runner] using preinstalled Codex from the sandbox image\n",
);
} catch {
usedPreinstalledCodex = false;
}
}
if (!usedPreinstalledCodex) {
const installRoot = posix.join(
remoteRuntimeRoot!,
"harnesses",
"codex",
);
const installResult = await remoteCommandRunner.execute({
command: "npm",
args: [
"install",
"--prefix",
installRoot,
"--no-audit",
"--no-fund",
remoteCodexNpmSpec,
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
timeoutMs: 180_000,
});
if (installResult.exitCode !== 0 || installResult.timedOut) {
throw new Error("runner_remote_codex_install_failed");
}
}
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.verify",
() => verifyRemoteCodex(),
);
}
if (remoteCodexBinary && !explicitRemoteCodex && !remoteCodexNpmSpec) {
const preinstalledCodex = await measureNativeRunnerSpan(
input.trace,
"harness.artifact.discover",
() => discoverPreinstalledExecutable("codex"),
);
if (!preinstalledCodex) {
throw new Error(
"runner_remote_codex_artifact_unavailable: install codex in the sandbox image or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC",
);
}
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.verify_preinstalled",
() => verifyRemoteCodex(preinstalledCodex),
);
await measureNativeRunnerSpan(input.trace, "harness.artifact.link", () =>
linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary),
);
await measureNativeRunnerSpan(
input.trace,
"harness.artifact.verify",
() => verifyRemoteCodex(),
);
await input.onLog?.(
"stderr",
"[paperclip-runner] using preinstalled Codex from the sandbox image\n",
);
}
};
return { prepare, verifyRemoteRunner, verifyRemoteCodex };
}
@@ -43,10 +43,18 @@ describe("resolveNativeRuntimeMode", () => {
}
});
it("rejects a fresh Paperclip Runner start while the rollout flag is disabled", () => {
it("admits fresh native Codex when the experimental rollout is disabled", () => {
expect(resolveNativeRuntimeMode({
...eligible,
enabled: false,
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
});
it("still rejects experimental providers while the rollout flag is disabled", () => {
expect(() => resolveNativeRuntimeMode({
...eligible,
enabled: false,
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
})).toThrow(expect.objectContaining({
code: "paperclip_runner_rollout_disabled",
}));
@@ -187,14 +195,17 @@ describe("resolveNativeRuntimeMode", () => {
}));
});
it("keeps a persisted active run native while the global flag rejects a fresh runner start", () => {
const disabled = { ...eligible, enabled: false };
it("keeps a persisted experimental run native while the flag rejects a fresh start", () => {
const disabled = { ...eligible, enabled: false,
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
};
expect(resolveHeartbeatNativeRuntimeMode({
...disabled,
persisted: {
runtimeMode: "native",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date(),
driverKind: "opencode_server",
},
})).toEqual(expect.objectContaining({
kind: "native",
@@ -458,6 +469,7 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => {
...compatibilityInput,
enabled: false,
adapterType: "paperclip_runner",
adapterConfig: { provider: "opencode", model: "opencode/gpt-5" },
})).toThrow(NativeRunnerSelectionError);
});
});
@@ -126,10 +126,11 @@ export function resolveNativeRuntimeMode(input: {
}
throw error;
}
// Dot has its own rollout; enabling it does not opt in other Runner providers.
// Native Codex is a supported default. Other providers retain their rollout;
// enabling Dot does not opt in any of those providers.
if (runnerProfile.provider === "openai_dot") {
if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work.");
} else if (!input.enabled) {
} else if (runnerProfile.provider !== "codex" && !input.enabled) {
throw ineligible(
"paperclip_runner_rollout_disabled",
"Paperclip Runner is experimental and disabled on this instance.",
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
const { execute, nativeProbe, selectedRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() }));
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: execute }));
vi.mock("./native-ssh-command-runner.js", () => ({ createNativeSshCommandRunner: sshRunner }));
vi.mock("../../realtime/runner-prp-ws.js", () => ({ registerRunnerPrpAuthority: registerPrp }));
vi.mock("../../realtime/runner-prp-outbound.js", () => ({ connectRunnerPrpIngress: ingressConnect }));
vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: selectedRunner }));
vi.mock("../../vendor/paperclip-runner/index.js", async original => ({ ...await original<typeof import("../../vendor/paperclip-runner/index.js")>(), probeNativeRunnerEnvironment: nativeProbe }));
vi.mock("./native-session-executor.js", async original => ({ ...await original<typeof import("./native-session-executor.js")>(), createRemoteNativeArtifactPreparation: nativeArtifacts, createRemoteRunnerProcessLauncher: remoteLauncher }));
import { testNativeRunnerAuthentication } from "./setup-readiness.js";
const context = {
companyId: "company", adapterType: "paperclip_runner", config: {},
executionTarget: { kind: "remote" as const, transport: "sandbox" as const, providerKey: "test", remoteCwd: "/workspace", runner: { execute: vi.fn() } },
};
describe("Codex selected native account verification", () => {
const receipt = (provider: "codex", model: string | null) => ({ provider, effectiveModel: model, providerDriver: "codex_app_server", helloProbePassed: true, cleanupConfirmed: true });
beforeEach(() => {
execute.mockReset(); nativeProbe.mockReset();
sshExecute.mockReset().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "", stderr: "" });
sshRunner.mockReset().mockReturnValue({ execute: sshExecute });
nativeArtifacts.mockReset().mockReturnValue({ prepare: vi.fn().mockResolvedValue(undefined) });
remoteLauncher.mockReset().mockReturnValue(vi.fn());
registerPrp.mockReset().mockImplementation(async () => ({ release: vi.fn() }));
vi.stubEnv("PAPERCLIP_RUNNER_PUBLIC_URL", "wss://paperclip.example.test");
vi.stubEnv("PAPERCLIP_RUNNER_CA_BUNDLE_PATH", "");
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_PATH", "");
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", "");
selectedRunner.mockReset().mockReturnValue("/fixture/bin/paperclip-runnerd");
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "");
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "");
});
afterEach(async () => {
vi.unstubAllEnvs();
for (const [input] of nativeProbe.mock.calls) if (input.runtimeDirectory) await rm(input.runtimeDirectory, { recursive: true, force: true });
});
it.each([["codex", "gpt-6.1-sol", "OPENAI_API_KEY"]] as const)("requires the native %s host and only the selected credential", async (provider, model, key) => {
vi.stubEnv(key, "ambient-account");
vi.stubEnv("ANTHROPIC_API_KEY", "ambient-anthropic");
nativeProbe.mockImplementation(async input => { await input.onCleanupConfirmed(); return receipt(provider, model); });
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { env: { [key]: "bound-account" }, modelReasoningEffort: "low" } }, provider, model);
expect(result.status).toBe("pass");
const input = nativeProbe.mock.calls[0][0];
expect(input).toMatchObject({ provider, model, timeoutMs: 45_000, environment: { [key]: "bound-account" } });
expect(input.transportOptions.runnerBinary).toBe("/fixture/bin/paperclip-runnerd");
expect(selectedRunner).toHaveBeenCalledOnce();
expect(input.environment.ANTHROPIC_API_KEY).toBeUndefined();
if (provider === "codex") expect(input.reasoningEffort).toBe("low");
else expect(input.reasoningEffort).toBeUndefined();
await expect(stat(input.runtimeDirectory)).rejects.toThrow();
expect(execute).not.toHaveBeenCalled();
});
it.each(["host-home", "host-codex-home", "configured-home", "managed-home"] as const)("reuses the task's authorized local Codex home before isolation (%s)", async mode => {
vi.stubEnv("HOME", "/host-home");
vi.stubEnv("CODEX_HOME", mode === "host-home" ? "" : "/host-codex");
vi.stubEnv("OPENAI_API_KEY", "ambient-key-is-not-authorized");
nativeProbe.mockResolvedValue(receipt("codex", "observed-model"));
const config = mode === "configured-home" || mode === "managed-home" ? { env: { CODEX_HOME: "/configured-codex" } } : {};
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config,
...(mode === "managed-home" ? { managedAiCredentialHome: "/managed-codex" } : {}) }, "codex", null)).status).toBe("pass");
const input = nativeProbe.mock.calls[0][0];
expect(input.transportOptions.sourceCodexHome).toBe({ "host-home": "/host-home/.codex", "host-codex-home": "/host-codex", "configured-home": "/configured-codex", "managed-home": "/managed-codex" }[mode]);
expect(input.environment.OPENAI_API_KEY).toBeUndefined();
expect(input.environment.HOME).toBeUndefined();
expect(input.runtimeDirectory).not.toContain("host-home");
});
const sshContext = { companyId: "company", adapterType: "paperclip_runner", config: {}, executionTarget: {
kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/qa-workspace", spec: { host: "qa-host", username: "qa-user", port: 22, remoteCwd: "/qa-workspace", remoteWorkspacePath: "/qa-workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true },
} };
it("uses task staging and the company-scoped native SSH transport without a provider pack", async () => {
let released = false;
registerPrp.mockResolvedValue({ release: async () => { released = true; } });
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({ marker: "native-authority" });
expect(registration.connection).toMatchObject({ mode: "connect", connectUrl: expect.stringMatching(/^wss:\/\/paperclip\.example\.test\/api\/runner\/v1\/connect\//) });
await registration.release();
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
expect((await testNativeRunnerAuthentication({ ...sshContext, config: { env: { OPENAI_API_KEY: "company-account" }, modelReasoningEffort: "low" } }, "codex", "gpt-6.1-sol")).status).toBe("pass");
const input = nativeProbe.mock.calls[0][0];
expect(input).toMatchObject({ reasoningEffort: "low", environment: { OPENAI_API_KEY: "company-account" }, workingDirectory: expect.stringMatching(/^\/qa-workspace\/.paperclip-runtime\/paperclip-native-setup-.*\/filesystem\/workspace$/) });
expect(input.transportOptions).toMatchObject({ runnerBinary: "/fixture/bin/paperclip-runnerd", codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/) });
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: "/fixture/bin/paperclip-runnerd", model: "gpt-6.1-sol" }));
expect(nativeArtifacts.mock.results[0]!.value.prepare).toHaveBeenCalledWith("dial_wss");
expect(registerPrp).toHaveBeenCalledWith({ companyId: "company", runId: input.transportOptions.prpIdentity.runId, authority: { marker: "native-authority" } });
expect(remoteLauncher).toHaveBeenCalledWith(expect.objectContaining({ runnerInstanceId: input.transportOptions.prpIdentity.runnerInstanceId }));
expect(released).toBe(true);
expect(sshExecute.mock.calls.at(-1)![0]).toMatchObject({ command: "rm", args: ["-rf", "--", expect.stringContaining("paperclip-native-setup-")] });
expect(sshExecute.mock.calls.some(([value]) => JSON.stringify(value).includes("company-account"))).toBe(false);
expect(execute).not.toHaveBeenCalled();
});
it.each(["runtime missing", "authentication failed: selected-key", "no native completion", "model mismatch"])("fails SSH native setup without a legacy fallback (%s)", async message => {
nativeProbe.mockRejectedValue(new Error(message));
const result = await testNativeRunnerAuthentication({ ...sshContext, config: { env: { OPENAI_API_KEY: "selected-key" } } }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail"); expect(JSON.stringify(result)).not.toContain("selected-key");
expect(nativeProbe).toHaveBeenCalledOnce(); expect(execute).not.toHaveBeenCalled();
expect(sshExecute.mock.calls.some(([value]) => value.command === "rm")).toBe(false);
expect((await stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).isDirectory()).toBe(true);
});
it("fails SSH transport eligibility before staging or opening the native provider", async () => {
vi.stubEnv("PAPERCLIP_RUNNER_PUBLIC_URL", "");
expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail");
expect(sshExecute).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled();
});
it.each([true, false])("removes SSH probe state only after confirmed teardown (%s)", async confirmed => {
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({});
await registration.release();
if (confirmed) await input.onCleanupConfirmed();
throw new Error("authentication failed");
});
expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail");
expect(sshExecute.mock.calls.some(([value]) => value.command === "rm")).toBe(confirmed);
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
if (confirmed) await expect(stat(root)).rejects.toThrow(); else expect((await stat(root)).isDirectory()).toBe(true);
});
it.each(["same-account", "another-account", "read-failure", "uncertain-close"] as const)("preserves managed SSH account refresh and confirmed cleanup (%s)", async mode => {
const home = await mkdtemp(join(tmpdir(), "native-ssh-managed-account-"));
const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() });
try {
await writeFile(join(home, "auth.json"), auth("qa-account", "old", 120_000), { mode: 0o600 });
const refreshed = auth(mode === "another-account" ? "different-account" : "qa-account", "new", 60_000);
sshExecute.mockImplementation(async input => ({ exitCode: input.args?.[2] === "paperclip-native-refresh" && mode === "read-failure" ? 1 : 0, timedOut: false,
stdout: input.args?.[2] === "paperclip-native-refresh" ? Buffer.from(refreshed).toString("base64") : "", stderr: "" }));
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({});
await registration.release();
const original = new Error("authentication failed in native SSH" + (mode === "uncertain-close" ? "; daemon teardown was not confirmed" : ""));
try { await input.onCodexCredentialRefresh(posix.join(input.transportOptions.runnerFilesystemRoot, "codex-home", "auth.json")); }
catch (error) { throw new AggregateError([original, error], original.message + "; " + (error as Error).message, { cause: original }); }
if (mode !== "uncertain-close") await input.onCleanupConfirmed();
throw original;
});
const result = await testNativeRunnerAuthentication({ ...sshContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail"); expect(result.checks[0].message).toContain("authentication failed in native SSH");
expect(JSON.parse(await readFile(join(home, "auth.json"), "utf8")).tokens.access_token).toBe(mode === "same-account" || mode === "uncertain-close" ? "access-new" : "access-old");
expect(sshExecute.mock.calls.some(([input]) => input.command === "rm")).toBe(mode === "same-account" || mode === "another-account");
expect(JSON.stringify(result)).not.toContain("access-new");
const read = sshExecute.mock.calls.find(([input]) => input.args?.[2] === "paperclip-native-refresh")![0];
expect(read.command).toBe("sh"); expect(read.args[1]).toContain("head -c 65537"); expect(read.args[1]).toContain("test ! -L");
expect(nativeProbe.mock.calls[0][0].transportOptions.sourceCodexHome).toBe(home);
if (mode === "read-failure") expect(result.checks[0].message).toContain("refresh handoff unavailable");
if (mode === "uncertain-close") expect(result.checks[0].message).toContain("teardown was not confirmed");
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each([
["authentication failed: bound-account", "auth_required"], ["Native provider hello probe timed out.", "timeout"],
["Model is unavailable", "failed"], ["native executable is missing", "failed"], ["native provider returned no response", "failed"],
])("fails closed and redacts native failures (%s)", async (message, code) => {
nativeProbe.mockRejectedValue(new Error(message));
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { env: { OPENAI_API_KEY: "bound-account" } } }, "codex", "gpt-6.1-sol");
expect(result).toMatchObject({ status: "fail", checks: [{ code: `codex_hello_probe_${code}`, level: "error" }] });
expect(JSON.stringify(result)).not.toContain("bound-account");
expect(nativeProbe).toHaveBeenCalledOnce();
expect(execute).not.toHaveBeenCalled();
});
it.each([
{ helloProbePassed: undefined }, { effectiveModel: "another-model" }, { effectiveModel: null }, { effectiveModel: "" }, { provider: "opencode" }, { providerDriver: "codex_cli" },
])("rejects installation-only, legacy, or mismatched receipts", async override => {
nativeProbe.mockResolvedValue({ ...receipt("codex", "gpt-6.1-sol"), ...override });
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", "gpt-6.1-sol")).status).toBe("fail");
});
it("accepts an automatic Codex model only when the native provider observed a nonempty model", async () => {
nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol"));
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("pass");
expect(nativeProbe.mock.calls[0][0].model).toBeNull();
nativeProbe.mockResolvedValue(receipt("codex", null));
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
nativeProbe.mockResolvedValue(receipt("codex", "unknown"));
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
});
it.each([true, false])("removes failed local probe state only after confirmed teardown (%s)", async confirmed => {
nativeProbe.mockImplementation(async input => {
if (confirmed) await input.onCleanupConfirmed();
throw new Error("authentication failed");
});
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
if (confirmed) await expect(stat(root)).rejects.toThrow();
else expect((await stat(root)).isDirectory()).toBe(true);
});
it("uses the same preparation and controller PRP for a sandbox, without a guest provider pack", async () => {
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({});
expect(registration.connection.mode).toBe("connect");
await registration.release(); await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const target = { ...context.executionTarget, runner: { execute: sshExecute } };
expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass");
expect(sshRunner).not.toHaveBeenCalled();
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ target, runner: target.runner }));
expect(nativeArtifacts.mock.results[0].value.prepare).toHaveBeenCalledWith("dial_wss");
expect(execute).not.toHaveBeenCalled();
});
it("prepares listener artifacts before acquiring sandbox ingress and owns its release", async () => {
const order: string[] = [];
const close = vi.fn();
const endpoint = { kind: "authenticated_websocket" as const, websocketUrl: "wss://ingress.test", secretHeaders: [], generation: "generation", refresh: vi.fn(), close };
const getRunnerIngressEndpoint = vi.fn(async () => { order.push("ingress"); return endpoint; });
nativeArtifacts.mockReturnValue({ prepare: vi.fn(async mode => { expect(mode).toBe("listen_ws"); order.push("artifacts"); }) });
const connectionClose = vi.fn();
ingressConnect.mockImplementation(() => { order.push("connect"); return { ready: Promise.resolve(), close: connectionClose }; });
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({});
expect(registration.connection.mode).toBe("listen");
remoteLauncher.mock.calls[0][0].onRunnerProcessSpawned();
await registration.activate(); await registration.ready(); await registration.release();
await input.onCleanupConfirmed(); return receipt("codex", "gpt-6.1-sol");
});
const target = { ...context.executionTarget, providerKey: "daytona", runner: { execute: sshExecute }, leaseId: "lease", effectiveCapabilities: { reusableLeases: false, nativeSyncIn: false, nativeSyncOut: false, persistentProcessSessions: false, independentControlCommands: true, incrementalSessionOutput: false, concurrentSyncOperations: false, duplexCommandStream: false, runnerWebSocketIngress: true }, getRunnerIngressEndpoint };
expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass");
expect(order).toEqual(["artifacts", "ingress", "connect"]);
expect(connectionClose).toHaveBeenCalledOnce(); expect(registerPrp).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,198 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
import type { AdapterEnvironmentTestContext, AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils";
import { probeNativeRunnerEnvironment, resolveSourceCodexHome } from "../../vendor/paperclip-runner/index.js";
import { resolvePaperclipRunnerBinary } from "./native-codex-runner.js";
import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
import { createNativeSshCommandRunner } from "./native-ssh-command-runner.js";
import { resolvePaperclipRunnerTransport } from "@paperclipai/adapter-utils/runner-connectivity";
import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
import { connectRunnerPrpIngress } from "../../realtime/runner-prp-outbound.js";
const PROBE_TRANSPORT_ENV_KEYS = ["PATH", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "no_proxy", "all_proxy"];
function redactNativeProbeMessage(message: string, environment: Record<string, string>): string {
const redact = (value: unknown): void => {
if (typeof value === "string" && value) message = message.replaceAll(value, "[REDACTED]");
else if (value && typeof value === "object") for (const item of Object.values(value)) redact(item);
};
for (const [name, value] of Object.entries(environment)) {
if (!/key|token|secret|password/i.test(name) || !value) continue;
redact(value);
try { redact(JSON.parse(value)); } catch { /* Most credentials are plain strings. */ }
}
return message.slice(0, 2000);
}
/** Remote setup shares task artifacts, PRP, isolated launch home, and process ownership. */
async function probeRemoteNativeCodex(options: {
context: AdapterEnvironmentTestContext;
model: string | null;
reasoningEffort?: string;
environment: Record<string, string>;
sourceCodexHome: string;
timeoutMs: number;
}) {
const target = options.context.executionTarget;
if (target?.kind !== "remote") throw new Error("Native setup requires the selected remote execution target.");
const { createRemoteNativeArtifactPreparation, createRemoteRunnerProcessLauncher, stageRemoteRunnerFile, readRemoteRunnerState } = await import("./native-session-executor.js");
const runner = target.transport === "ssh" ? createNativeSshCommandRunner({ spec: target.spec, defaultCwd: target.remoteCwd }) : target.runner;
if (!runner) throw new Error("runner_transport_ineligible: remote process runner is unavailable");
const id = crypto.randomUUID();
const runnerInstanceId = `setup-${id}`;
// Eligibility is checked before writing to the selected host, using the
// same server-owned public URL and CA configuration as ordinary tasks.
const ingress = target.transport === "sandbox" && target.effectiveCapabilities?.runnerWebSocketIngress === true;
// Outbound eligibility is read-only; acquire provider ingress only after the
// exact daemon has been prepared and its listener capability verified.
const outbound = !ingress ? await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused",
runnerPublicUrl: process.env.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath: process.env.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() || null, runnerIngressAuthorized: true }) : null;
if (outbound && outbound.mode !== "direct_outbound") throw new Error("runner_transport_ineligible: expected outbound native transport");
if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) await stat(outbound.caBundlePath).catch(() => { throw new Error("runner_direct_wss_failed: configured runner CA bundle is unavailable"); });
const remoteRoot = posix.join(target.remoteCwd, ".paperclip-runtime", `paperclip-native-setup-${id}`);
const remoteBinary = posix.join(remoteRoot, "bin", "paperclip-runnerd");
const remoteStateDirectory = posix.join(remoteRoot, "runner");
const remoteFilesystemRoot = posix.join(remoteRoot, "filesystem");
const remoteWorkspace = posix.join(remoteFilesystemRoot, "workspace");
const remoteCodexNpmSpec = process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() || null;
const remoteCodexBinary = remoteCodexNpmSpec ? posix.join(remoteRoot, "harnesses", "codex", "node_modules", ".bin", "codex") : posix.join(remoteRoot, "bin", "codex");
const explicitRunner = process.env.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() || null;
const controllerRunnerBinary = explicitRunner || resolvePaperclipRunnerBinary();
const artifacts = createRemoteNativeArtifactPreparation({ target, runner, remoteBinary, controllerRunnerBinary, remoteRuntimeRoot: remoteRoot, remoteCodexBinary,
runnerRemoteBinaryPath: explicitRunner, runnerRemoteCodexPath: process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim(), runnerRemoteCodexNpmSpec: remoteCodexNpmSpec, model: options.model });
const requireSuccess = (result: { timedOut: boolean; exitCode: number | null }, message: string) => {
if (result.timedOut || result.exitCode !== 0) throw new Error(message);
};
const runtimeDirectory = await mkdtemp(join(tmpdir(), "paperclip-native-setup-"));
let remoteDirectoryCreated = false;
let remoteCaBundlePath: string | undefined;
let signalSpawned!: () => void;
const spawned = new Promise<void>(resolve => { signalSpawned = resolve; });
return probeNativeRunnerEnvironment({ runtimeDirectory: runtimeDirectory, provider: "codex", model: options.model, reasoningEffort: options.reasoningEffort,
workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs,
transportOptions: {
runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome: options.sourceCodexHome,
runnerFilesystemRoot: remoteFilesystemRoot, runnerStateDirectory: remoteStateDirectory,
prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` },
readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }),
controlPlaneRegistration: async authority => {
// Claim a private per-probe root. Never merge credentials or state into
// another session's directory on the remote host.
const created = await runner.execute({ command: "sh", args: ["-c", 'set -eu; umask 077; mkdir -p -- "$1"; test -d "$1" && test ! -L "$1"; mkdir -- "$2"; install -d -m 0700 "$3" "$4" "$5"',
"paperclip-native-setup", posix.dirname(remoteRoot), remoteRoot, remoteFilesystemRoot, remoteWorkspace, posix.join(remoteFilesystemRoot, "probe-home")], cwd: target.remoteCwd, bypassSession: true, timeoutMs: 10_000 });
requireSuccess(created, "Native setup could not claim a private runtime directory.");
remoteDirectoryCreated = true;
await artifacts.prepare(ingress ? "listen_ws" : "dial_wss");
if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) {
remoteCaBundlePath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem");
await stageRemoteRunnerFile({ target, runner, sourcePath: outbound.caBundlePath, targetPath: remoteCaBundlePath, mode: 0o600 });
}
if (outbound?.mode === "direct_outbound") {
const registration = await registerRunnerPrpAuthority({ companyId: options.context.companyId, runId: id, authority });
return { connection: { mode: "connect" as const, connectUrl: outbound.connectUrl, ...(remoteCaBundlePath ? { caBundlePath: remoteCaBundlePath } : {}) },
startupFailureCode: "runner_direct_wss_failed" as const, release: registration.release };
}
const transport = await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused", runnerIngressAuthorized: true });
if (transport.mode !== "provider_ingress") throw new Error("runner_transport_ineligible: expected provider ingress");
let connection: ReturnType<typeof connectRunnerPrpIngress> | undefined;
let activation: Promise<void> | undefined;
return {
connection: { mode: "listen" as const, listenAddress: transport.listenAddress, listenPort: transport.listenPort, listenPath: transport.listenPath },
activate: () => {
activation = spawned.then(() => { connection = connectRunnerPrpIngress({ authority, endpoint: transport.ingress }); });
void activation.catch(() => undefined);
},
ready: async () => { await activation; if (!connection) throw new Error("runner_ingress_unavailable"); await connection.ready; },
get failure() { return connection?.failure; },
startupFailureCode: "runner_ingress_unavailable" as const,
release: async () => { if (connection) await connection.close(); else await transport.ingress.close(); },
};
},
runnerProcessLauncher: createRemoteRunnerProcessLauncher({ target, runner, remoteBinary, stateDirectory: remoteStateDirectory,
onRunnerProcessSpawned: signalSpawned, processIdentityPath: posix.join(remoteStateDirectory, "runner-process.identity"), diagnosticsDirectory: posix.join(remoteRoot, "diagnostics"), runnerInstanceId,
ensureArtifact: async () => {
// The normal transport has already materialized the bound account and
// its managed config locally. Publish only those two fresh files.
for (const name of ["auth.json", "config.toml"] as const) {
const sourcePath = join(runtimeDirectory, "codex-home", name);
try { await stat(sourcePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
await stageRemoteRunnerFile({ target, runner, sourcePath, targetPath: posix.join(remoteFilesystemRoot, "codex-home", name), mode: 0o600 });
}
} }),
},
...(options.context.managedAiCredentialHome ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== posix.join(remoteFilesystemRoot, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: join(options.context.managedAiCredentialHome!, "auth.json"), log: () => {}, readSandboxAuth: async () => {
// Reuse the task's shell/base64 handoff without requiring a guest JS
// runtime. Bound and validate the private owner-only credential file.
const read = await runner.execute({ command: "sh", args: ["-c", 'set -eu; file=$1; test -f "$file" || exit 66; test ! -L "$file"; parent=$(dirname -- "$file"); while test "$parent" != /; do test -d "$parent" && test ! -L "$parent"; parent=$(dirname -- "$parent"); done; metadata=$(stat -c "%u %a %s" "$file" 2>/dev/null || stat -f "%u %Lp %z" "$file"); set -- $metadata; test "$1" = "$(id -u)" && test "$2" = 600 && test "$3" -le 65536; head -c 65537 -- "$file" | base64', "paperclip-native-refresh", filename], bypassSession: true, timeoutMs: 10_000 });
if (read.timedOut || read.exitCode !== 0) throw Object.assign(new Error("Native Codex credential refresh handoff unavailable."), { code: read.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" });
const body = Buffer.from(read.stdout, "base64");
if (body.length > 65_536) throw new Error("Native Codex credential refresh handoff exceeds the credential size limit.");
return body;
} });
} } : {}),
onCleanupConfirmed: async () => {
if (remoteDirectoryCreated) {
const cleaned = await runner.execute({ command: "rm", args: ["-rf", "--", remoteRoot], bypassSession: true, timeoutMs: 10_000 });
requireSuccess(cleaned, "Native setup cleanup is incomplete; private recovery state was retained.");
}
await rm(runtimeDirectory, { recursive: true, force: true });
},
});
}
/** Codex readiness uses the same daemon, bound credentials and cleanup as task execution. */
export async function testNativeRunnerAuthentication(context: AdapterEnvironmentTestContext, provider: "codex", model: string | null): Promise<AdapterEnvironmentTestResult> {
const remote = context.executionTarget?.kind === "remote";
const configured = context.config.env;
const environment: Record<string, string> = Object.fromEntries([
...(remote ? [] : PROBE_TRANSPORT_ENV_KEYS.flatMap(key => typeof process.env[key] === "string" ? [[key, process.env[key]!]] : [])),
...Object.entries(configured && typeof configured === "object" && !Array.isArray(configured) ? configured : {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
]);
const authKey = "_PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET";
const configKey = "_PAPERCLIP_NATIVE_SETUP_CODEX_CONFIG_TOML_SECRET";
delete environment[authKey];
delete environment[configKey];
let sourceCodexHome: string | null | undefined;
const effort = context.config.modelReasoningEffort ?? context.config.reasoningEffort ?? context.config.effort;
const timeoutMs = remote ? 90_000 : 45_000;
let runtimeDirectory: string | undefined;
try {
// Match task execution's closed host projection before the probe replaces
// HOME. API keys never enter this projection from the ambient process.
if (provider === "codex") {
const { buildNativeProviderEnvironment } = await import("./native-session-executor.js");
sourceCodexHome = context.managedAiCredentialHome ?? resolveSourceCodexHome(buildNativeProviderEnvironment(environment));
}
let receipt: Awaited<ReturnType<typeof probeNativeRunnerEnvironment>>;
if (context.executionTarget?.kind === "remote") {
receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", timeoutMs,
...(typeof effort === "string" && effort ? { reasoningEffort: effort } : {}) });
} else {
runtimeDirectory = await mkdtemp(join(tmpdir(), "paperclip-native-setup-"));
receipt = await probeNativeRunnerEnvironment({ runtimeDirectory, provider, model, environment, timeoutMs,
...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}),
onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); },
transportOptions: { runnerBinary: resolvePaperclipRunnerBinary(), sourceCodexHome: sourceCodexHome ?? "" },
...(context.managedAiCredentialHome && provider === "codex" ? { onCodexCredentialRefresh: async (filename: string) => {
await copyBackCodexAuth({ hostAuthPath: join(context.managedAiCredentialHome!, "auth.json"), log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) });
} } : {}),
});
}
if (receipt.helloProbePassed !== true || receipt.provider !== provider || typeof receipt.effectiveModel !== "string" || !receipt.effectiveModel.trim() || receipt.effectiveModel === "unknown" || (model !== null && receipt.effectiveModel !== model)
|| receipt.providerDriver !== "codex_app_server") throw new Error("The selected native runtime returned an incompatible account or model verification receipt.");
return { adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_passed`, level: "info",
message: `The native ${provider} runtime verified the selected account and model in ${remote ? "the selected environment" : "the Paperclip host"}.` }] };
} catch (error) {
const message = redactNativeProbeMessage(error instanceof Error ? error.message : "The selected native runtime could not verify this account.", environment);
const authentication = /(?:invalid (?:api[- ]?key|auth(?:entication)? token)|authentication (?:failed|required)|unauthenticated|unauthorized|not authenticated|please (?:log|sign) in|not logged in|\b(?:401|403)\b)/i.test(message);
return { adapterType: "paperclip_runner", status: "fail", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_${authentication ? "auth_required" : /timed out/i.test(message) ? "timeout" : "failed"}`, level: "error", message,
hint: "Check the selected account, model access, and native runtime prerequisites, then retry. Legacy runner is available explicitly in Advanced." }] };
}
}
+14 -5
View File
@@ -14,9 +14,10 @@ import type {
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
} from "@paperclipai/shared";
import { isUuidLike } from "@paperclipai/shared";
import { agentHarnessType, agentRunner, isUuidLike } from "@paperclipai/shared";
import { conflict, forbidden, notFound } from "../errors.js";
import { agentService } from "./agents.js";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import { approvalService } from "./approvals.js";
import { logActivity } from "./activity-log.js";
import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js";
@@ -354,12 +355,12 @@ export function pluginManagedAgentService(
async function companyAdapterUsage(companyId: string) {
const rows = await db
.select({ adapterType: agents.adapterType })
.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig })
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const counts = new Map<string, number>();
for (const row of rows) {
const adapterType = normalizeAdapterType(row.adapterType);
const adapterType = normalizeAdapterType(agentHarnessType(row.adapterType, row.adapterConfig));
if (!adapterType) continue;
counts.set(adapterType, (counts.get(adapterType) ?? 0) + 1);
}
@@ -656,11 +657,18 @@ export function pluginManagedAgentService(
? reconciled.agent.metadata
: {};
const adapterType = await resolveManagedAdapterType(companyId, declaration);
const defaults = declarationPatch(declaration, { adapterType });
// A Codex reset keeps its selected runner; other harness reset behavior is unchanged.
const execution = await resolveNewAgentRunnerForCompany(db, companyId, {
...defaults,
defaultEnvironmentId: reconciled.agent.defaultEnvironmentId,
runner: adapterType === "codex_local" && agentHarnessType(reconciled.agent.adapterType, reconciled.agent.adapterConfig) === adapterType
? agentRunner(reconciled.agent.adapterType) : undefined,
});
// Reset content through the canonical CAS path before changing defaults.
// A conflict must leave the existing adapter configuration untouched.
const withInstructions = await materializeDeclaredInstructions(companyId, reconciled.agent, declaration, { replaceExisting: true });
const defaults = declarationPatch(declaration, { adapterType });
const adapterConfig = { ...defaults.adapterConfig } as Record<string, unknown>;
const adapterConfig = { ...execution.adapterConfig };
if (declaration.instructions) {
for (const key of ["instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath"]) {
if (withInstructions.adapterConfig[key] !== undefined) adapterConfig[key] = withInstructions.adapterConfig[key];
@@ -668,6 +676,7 @@ export function pluginManagedAgentService(
}
const updated = await agentSvc.update(reconciled.agent.id, {
...defaults,
adapterType: execution.adapterType,
adapterConfig,
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata),
}, {
+2
View File
@@ -158,3 +158,5 @@ export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS;
export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS;
export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection;
export const configuredEnvironment = runner.configuredEnvironment;
export const probeNativeRunnerEnvironment = runner.probeNativeRunnerEnvironment;