fix: preserve Codex policies and credential refresh during setup

Restore actor guards before runner resolution and gated experimental provider access. Keep existing legacy fixtures explicit and synchronize documentation anchors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 10:12:15 -05:00
1 parent eaabc5c624
commit 38199ea8e6
25 files changed
+354 -107

No files matched your search

+35 -4
View File
@@ -41,7 +41,7 @@ Canonical PR: [draft #15683](https://github.com/paperclipai/paperclip/pull/15683
Initial CI candidate: `8ce217b71`. Scoped environment discovery and native import
preservation are committed at `5ba1bc986`; the final follow-up preserves saved
model, effort, and timeout through runner-only built-in configuration changes.
Preview: [isolated Codex QA](http://127.0.0.1:3124), normal dev supervisor,
Preview: [isolated Codex QA](http://127.0.0.1:3125), normal dev supervisor,
separate empty database. Current-head live evidence is pending the source freeze
and verified binary provenance; older services and artifacts are not proof.
@@ -136,6 +136,37 @@ pass 280 cases plus three final regressions. Missing discovery metadata shows
loading or the server error with Retry; explicit Legacy remains available.
Final portability checks pass 105 cases; approval, built-in asset and CLI unit
controls pass eight, three and three. The 72 skipped DB cases still need Linux.
Final candidate Storybook builds at `5ba1bc986`; fresh desktop/mobile captures
are in progress against the frozen UI tree. The runner-only built-in regression
now checks nondefault settings through switches in both directions.
Candidate `b7194275e` completed 38 CI checks but failed 14, including aggregate
checks. Its fresh review reported three P1 findings. The fixes preserve explicit
legacy sandbox policy, return refreshed unmanaged subscription credentials to
their authorized source account, and restore gated experimental native choices
for other providers under Advanced. Actor restrictions now run before harness
translation and again on the resolved configuration. Existing route fixtures
use the real resolver; legacy execution controls request Legacy explicitly.
Capability drift was fixed by preserving existing documentation heading anchors,
without changing generated contracts. The retired Codex flag control now uses
an experimental OpenCode profile; all 45 corpus regressions pass locally.
Core regression checks pass 394 cases, with 147 final guard/inheritance controls;
server noemit passes. UI checks pass 212 cases, typecheck and token gates. The
original touch/wheel journeys pass two cases against the isolated preview.
Final desktop/mobile/keyboard captures and the Advanced experimental story are
being checked against the frozen UI tree. Earlier 26 desktop/six mobile/three
keyboard checks passed on the previous UI revision. The built-in regression
checks nondefault settings through runner-only switches in both directions.
Exactly $22 of documented unstarted staging reservations were reassigned to
Codex-only proof: $12 Linux onboarding (one existing campaign, at most two
attempts), $5.50 managed onboarding, $2.50 attended local proof, and $2 incremental
compute/cleanup. The attempted $5 staging setup envelope remains an unknown-cost
hold. Aggregate costs/reservations stay $248.179254104 under the original $250
allocation; no historical unknown or cleanup hold was released. This is not an
invoice-certified balance. No new paid provider task has run in this slice.
Attended local Codex login is pending in the embedded browser at OpenAI security
verification. Staging Fleet Admin requires fresh attended sign-in. The dedicated
QA stack `stack-pool-d06dca5eafe7` and its old immutable environment are identified
historically; current access, serving revision, retained snapshot and physical
cleanup must be verified before reuse. Old archived probe resources and missing
compute billing remain unresolved. Do not substitute old artifacts or passing CI
for these live gates. The next candidate must pass fresh CI and review before
merge sign-off; actual private cloud composition remains post-merge work.
-26
View File
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform } from "./agent-runner.js";
import { createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "./validators/agent.js";
describe("Codex creation runner contract", () => {
it("graduates only Codex and retains saved provider identity", () => {
expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" });
for (const harness of ["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local");
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local");
expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown");
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "unknown" })).toBe("acpx:unknown");
expect(agentRunner("codex_local")).toBe("legacy");
expect(agentRunner("paperclip_runner")).toBe("paperclip");
});
it("qualifies only the daemon actually shipped in public server packages", () => {
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", "x64")).toBe(true);
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "arm64"], ["win32", "x64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false);
});
it("keeps auto out of saved update defaults and validates request intent", () => {
expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined();
expect(updateAgentSchema.parse({ title: "same execution" })).not.toHaveProperty("runner");
expect(testAdapterEnvironmentSchema.parse({ runner: "legacy" }).runner).toBe("legacy");
expect(createAgentSchema.safeParse({ name: "Codex", adapterType: "codex_local", runner: "other" }).success).toBe(false);
});
});
@@ -488,7 +488,7 @@ describe("agent-created hires use managed AI connections", () => {
describe("hired agents sharing a subscription", () => {
it("keeps a credential-lock timeout on automatic retry without blocking the task or starting a provider", async () => {
const f = await fixture("openai", "subscription");
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Waiting teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Waiting teammate", role: "engineer", adapterType: f.adapterType, runner: "legacy", reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
// Only an operator may set host execution paths after the agent is hired.
await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id));
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Wait for credential rotation", status: "todo", assigneeAgentId: agent.id, responsibleUserId: f.userId, createdByUserId: f.userId }).returning();
@@ -531,7 +531,7 @@ describe("hired agents sharing a subscription", () => {
it.each(["openai", "anthropic"] as const)("runs the %s child alongside a live parent and inherits its connection", async (provider) => {
const f = await fixture(provider, "subscription");
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, runner: "legacy", reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
// Host working directories are configured by an operator, not an agent key.
await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id));
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Subscription child task", status: "todo", assigneeAgentId: agent.id, responsibleUserId: f.userId, createdByUserId: f.userId }).returning();
@@ -117,13 +117,22 @@ const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn());
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
listBoundCompanyIds: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
getGeneral: vi.fn(),
getExperimental: vi.fn(),
}));
const mockRunSshCommand = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
vi.doMock("@paperclipai/adapter-utils/ssh", async (importOriginal) => ({
...(await importOriginal<typeof import("@paperclipai/adapter-utils/ssh")>()),
runSshCommand: mockRunSshCommand,
}));
vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
return {
@@ -336,7 +345,11 @@ describe("agent permission routes", () => {
mockTrackAgentCreated.mockReset();
mockGetTelemetryClient.mockReset();
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
mockInstanceSettingsService.get.mockReset().mockResolvedValue({ defaultEnvironmentId: null, experimental: {}, general: {} });
mockInstanceSettingsService.getExperimental.mockReset().mockResolvedValue({});
mockInstanceSettingsService.getGeneral.mockReset();
mockEnvironmentService.listBoundCompanyIds.mockReset().mockResolvedValue([]);
mockRunSshCommand.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n", stderr: "", code: 0 });
mockEnvironmentService.getById.mockReset();
mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
@@ -1195,6 +1208,9 @@ describe("agent permission routes", () => {
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockRunSshCommand).not.toHaveBeenCalled();
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
@@ -1225,6 +1241,36 @@ describe("agent permission routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.each(["agents", "agent-hires"])("rejects agent-supplied Codex host commands before resolving the runner via %s", async (route) => {
mockAccessService.hasPermission.mockResolvedValue(true);
mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets"));
const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/companies/${companyId}/${route}`)
.send({ name: "Injected", role: "engineer", adapterType: "codex_local", adapterConfig: { command: "untrusted-codex" } }));
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockRunSshCommand).not.toHaveBeenCalled();
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it.each(["agents", "agent-hires"])("requires instance admin for external instructions before resolving the runner via %s", async (route) => {
mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets"));
const app = createApp({ type: "board", userId: "member-user", source: "session", companyIds: [companyId], isInstanceAdmin: false });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/companies/${companyId}/${route}`)
.send({ name: "External", role: "engineer", adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "external" } }));
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockRunSshCommand).not.toHaveBeenCalled();
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("allows direct agent creation for authenticated board users with agent create permission when approval is not required", async () => {
mockAccessService.canUser.mockResolvedValue(true);
@@ -1251,7 +1297,7 @@ describe("agent permission routes", () => {
expect.objectContaining({
status: "idle",
}),
{ createdByUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
);
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
companyId,
@@ -1381,7 +1427,7 @@ describe("agent permission routes", () => {
},
},
}),
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
});
@@ -1417,7 +1463,7 @@ describe("agent permission routes", () => {
model: DEFAULT_OPENCODE_LOCAL_MODEL,
}),
}),
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
});
@@ -1455,7 +1501,7 @@ describe("agent permission routes", () => {
model: "anthropic/claude-sonnet-4-5",
}),
}),
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
});
@@ -1495,6 +1541,8 @@ describe("agent permission routes", () => {
},
}),
{
runnerResolved: true,
aiConnectionInstall: undefined,
createdByUserId: "board-user",
claudeLogin: {
storedSessionId: null,
@@ -1726,7 +1774,7 @@ describe("agent permission routes", () => {
expect.objectContaining({
defaultEnvironmentId: environmentId,
}),
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
});
@@ -1773,17 +1821,18 @@ describe("agent permission routes", () => {
for (const adapterCase of sshCapableAdapterCases) {
it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => {
const expectedAdapterType = adapterCase.adapterType === "codex_local" ? "paperclip_runner" : adapterCase.adapterType;
const environmentId = "33333333-3333-4333-8333-333333333333";
mockEnvironmentService.getById.mockResolvedValue({
id: environmentId,
companyId,
driver: "ssh",
config: {},
config: { host: "qa.example.invalid", username: "qa", remoteWorkspacePath: "/workspace" },
});
mockAgentService.create.mockResolvedValue({
...baseAgent,
name: adapterCase.name,
adapterType: adapterCase.adapterType,
adapterType: expectedAdapterType,
defaultEnvironmentId: environmentId,
});
@@ -1809,11 +1858,17 @@ describe("agent permission routes", () => {
expect(mockAgentService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
adapterType: adapterCase.adapterType,
adapterType: expectedAdapterType,
defaultEnvironmentId: environmentId,
}),
{ createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
if (adapterCase.adapterType === "codex_local") {
expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), "uname -s; uname -m", { timeoutMs: 10_000 });
} else {
expect(mockRunSshCommand).not.toHaveBeenCalled();
}
});
}
@@ -50,6 +50,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
}));
@@ -279,6 +280,7 @@ describe("agent skill routes", () => {
agent: makeAgent("claude_local"),
});
mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } });
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null, experimental: {}, general: {} });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBetaSkills: false });
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([
@@ -1005,7 +1007,7 @@ describe("agent skill routes", () => {
}),
}),
}),
{ createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
);
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
expect.anything(),
@@ -1055,7 +1057,7 @@ describe("agent skill routes", () => {
expect.objectContaining({
role: "security",
}),
{ createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
);
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
expect.anything(),
@@ -1491,6 +1493,8 @@ describe("agent skill routes", () => {
}),
}),
{
runnerResolved: true,
aiConnectionInstall: undefined,
createdByUserId: "local-board",
claudeLogin: {
storedSessionId: null,
+1 -1
View File
@@ -198,7 +198,7 @@ describe("managed AI connections", () => {
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(result.status, JSON.stringify(result)).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));
@@ -848,8 +848,14 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
completionState === "kill_switch_during_active_native_run"
&& (fixture.covers.migrationRows ?? []).includes("MIG-08")
) {
// Codex has graduated; preserve the existing rollback control on an
// experimental provider, whose fresh runs still require the flag.
const experimentalAdapterConfig = {
provider: "opencode",
model: "openrouter/deepseek/deepseek-v4-flash-0731",
};
const runtimeConfig = {
nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
nativeRunner: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
};
const enabled = options.disableLiveEntrypoint === "rollout";
const activeResolution = resolveHeartbeatNativeRuntimeMode({
@@ -860,7 +866,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
},
enabled,
runtimeConfig,
adapterConfig: { provider: "codex" },
adapterConfig: experimentalAdapterConfig,
agent: { id: agentId, status: "running", adapterType: "paperclip_runner" },
issue: { id: seeded.issueId, workMode: "standard" },
target: { kind: "local" },
@@ -873,7 +879,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
persisted: { runtimeMode: null, runtimeModeReason: null, runtimeModeResolvedAt: null },
enabled,
runtimeConfig,
adapterConfig: { provider: "codex" },
adapterConfig: experimentalAdapterConfig,
agent: { id: agentId, status: "running", adapterType: "paperclip_runner" },
issue: { id: seeded.issueId, workMode: "standard" },
target: { kind: "local" },
+18 -2
View File
@@ -4706,6 +4706,16 @@ export function agentRoutes(
...hireInput
} = req.body;
// Check actor restrictions on the requested harness before runner translation
// changes its identity or reads the selected execution target.
const hiredAgentId = randomUUID();
const requestedHireConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, hireInput.runtimeConfig);
assertNoAgentAdapterConfigMutation(req, requestedHireConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType ?? "process", requestedHireConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, hireInput.adapterType ?? "process", Object.keys(requestedHireConfig).length > 0);
assertExternalInstructionsAdmin(req, { id: hiredAgentId, companyId, name: hireInput.name, adapterConfig: requestedHireConfig });
if (inheritRuntimeFrom === "caller") {
if (req.actor.type !== "agent" || !req.actor.agentId) {
throw forbidden("Only an agent can inherit native runtime settings from the caller");
@@ -4745,7 +4755,6 @@ export function agentRoutes(
assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType, rawHireAdapterConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, hireInput.adapterType, Object.keys(rawHireAdapterConfig).length > 0);
const hiredAgentId = randomUUID();
const authInheritance = await applyHiringAgentAuthInheritance(
req,
companyId,
@@ -5045,6 +5054,14 @@ export function agentRoutes(
runner: createRunner,
...createInput
} = req.body;
// Preserve the requested harness's actor restrictions before translation.
const agentId = randomUUID();
const requestedCreateConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
assertNoAgentAdapterConfigMutation(req, requestedCreateConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType ?? "process", requestedCreateConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, createInput.adapterType ?? "process", Object.keys(requestedCreateConfig).length > 0);
assertExternalInstructionsAdmin(req, { id: agentId, companyId, name: createInput.name, adapterConfig: requestedCreateConfig });
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>;
@@ -5061,7 +5078,6 @@ export function agentRoutes(
assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType, rawCreateAdapterConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, createInput.adapterType, Object.keys(rawCreateAdapterConfig).length > 0);
const agentId = randomUUID();
const requestedAdapterConfig = applyCodexLocalKeyIsolation(
companyId,
agentId,
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared";
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 }));
@@ -26,6 +27,12 @@ describe("server-owned Codex runner selection", () => {
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("preserves explicit legacy permission policy alongside compatible native lifecycle defaults", () => {
expect(resolveNewAgentRunner({ adapterType: "codex_local", runner: "legacy", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false, lifecycleMode: "per_turn" } }))
.toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } });
expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn", dangerouslyBypassApprovalsAndSandbox: false } }))
.toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } });
});
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");
@@ -94,3 +101,26 @@ describe("server-owned Codex runner selection", () => {
expect(result.adapterType).toBe("paperclip_runner"); expect(state.ssh).not.toHaveBeenCalled();
});
});
describe("Codex creation runner contract", () => {
it("graduates only Codex and retains saved provider identity", () => {
expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" });
for (const harness of ["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local");
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local");
expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown");
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "unknown" })).toBe("acpx:unknown");
expect(agentRunner("codex_local")).toBe("legacy");
expect(agentRunner("paperclip_runner")).toBe("paperclip");
});
it("qualifies only the daemon actually shipped in public server packages", () => {
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", "x64")).toBe(true);
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "arm64"], ["win32", "x64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false);
});
it("keeps auto out of saved update defaults and validates request intent", () => {
expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined();
expect(updateAgentSchema.parse({ title: "same execution" })).not.toHaveProperty("runner");
expect(testAdapterEnvironmentSchema.parse({ runner: "legacy" }).runner).toBe("legacy");
expect(createAgentSchema.safeParse({ name: "Codex", adapterType: "codex_local", runner: "other" }).success).toBe(false);
});
});
@@ -50,7 +50,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
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 } };
return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: 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" });
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, 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() }));
@@ -66,6 +66,33 @@ describe("Codex selected native account verification", () => {
expect(input.environment.HOME).toBeUndefined();
expect(input.runtimeDirectory).not.toContain("host-home");
});
it.each(["same-account", "another-account", "bound-key", "invalid-handoff"] as const)("preserves the authorized unmanaged login refresh before local cleanup (%s)", async mode => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-local-login-")));
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() });
const store = join(home, "account.json");
try {
await writeFile(store, auth("qa-account", "old", 120_000), { mode: 0o600 });
// Existing per-agent homes can point at their authorized shared account.
await symlink(store, join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
const privateHome = join(input.runtimeDirectory, "codex-home");
await mkdir(privateHome, { mode: 0o700 });
const filename = join(privateHome, "auth.json");
await writeFile(filename, auth(mode === "another-account" ? "other-account" : "qa-account", "new", 60_000), { mode: 0o600 });
if (mode === "bound-key") expect(input.onCodexCredentialRefresh).toBeUndefined();
else await input.onCodexCredentialRefresh(mode === "invalid-handoff" ? store : filename);
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { env: { CODEX_HOME: home, ...(mode === "bound-key" ? { OPENAI_API_KEY: "selected-key" } : {}) } } }, "codex", "gpt-6.1-sol");
expect(result.status).toBe(mode === "invalid-handoff" ? "fail" : "pass");
expect(JSON.parse(await readFile(store, "utf8")).tokens.access_token).toBe(mode === "same-account" ? "access-new" : "access-old");
expect((await stat(store)).mode & 0o777).toBe(0o600);
expect(await realpath(join(home, "auth.json"))).toBe(store);
expect(JSON.stringify(result)).not.toContain("access-new");
if (mode !== "invalid-handoff") await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" });
} finally { await rm(home, { recursive: true, force: true }); }
});
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 },
} };
@@ -1,4 +1,4 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { mkdtemp, realpath, 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";
@@ -34,6 +34,7 @@ async function probeRemoteNativeCodex(options: {
reasoningEffort?: string;
environment: Record<string, string>;
sourceCodexHome: string;
credentialRefreshAuthPath?: string;
timeoutMs: number;
}) {
const target = options.context.executionTarget;
@@ -67,7 +68,7 @@ async function probeRemoteNativeCodex(options: {
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-"));
const runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-")));
let remoteDirectoryCreated = false;
let remoteCaBundlePath: string | undefined;
let signalSpawned!: () => void;
@@ -124,9 +125,9 @@ async function probeRemoteNativeCodex(options: {
}
} }),
},
...(options.context.managedAiCredentialHome ? { onCodexCredentialRefresh: async (filename: string) => {
...(options.credentialRefreshAuthPath ? { 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 () => {
await copyBackCodexAuth({ hostAuthPath: options.credentialRefreshAuthPath!, 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 });
@@ -170,18 +171,31 @@ export async function testNativeRunnerAuthentication(context: AdapterEnvironment
const { buildNativeProviderEnvironment } = await import("./native-session-executor.js");
sourceCodexHome = context.managedAiCredentialHome ?? resolveSourceCodexHome(buildNativeProviderEnvironment(environment));
}
// Preserve refreshes in the authorized source account before deleting the
// private probe home. API-key probes must never write to a host login.
let credentialRefreshAuthPath: string | undefined;
if (sourceCodexHome && !environment.OPENAI_API_KEY?.trim() && !environment.PAPERCLIP_AI_PROVIDER_KEY?.trim()) {
try {
credentialRefreshAuthPath = await realpath(join(sourceCodexHome, "auth.json"));
const source = await stat(credentialRefreshAuthPath);
if (!source.isFile() || source.uid !== process.getuid?.()) throw new Error("Invalid Codex source credential owner.");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
let receipt: Awaited<ReturnType<typeof probeNativeRunnerEnvironment>>;
if (context.executionTarget?.kind === "remote") {
receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", timeoutMs,
receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", credentialRefreshAuthPath, timeoutMs,
...(typeof effort === "string" && effort ? { reasoningEffort: effort } : {}) });
} else {
runtimeDirectory = await mkdtemp(join(tmpdir(), "paperclip-native-setup-"));
runtimeDirectory = await realpath(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)) });
...(credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== join(runtimeDirectory!, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) });
} } : {}),
});
}
+1 -8
View File
@@ -898,14 +898,7 @@ POST /api/companies/{companyId}/agent-hires
}
```
New hires can set `runner` to `auto`, `paperclip`, or `legacy`. Omitted means
automatic: the Codex harness (`codex_local`) uses Paperclip Runner on supported
targets, while other harness defaults stay unchanged. Use `legacy` for custom
Codex CLI settings. The returned execution configuration is frozen for approval;
existing agents do not change runners after unrelated edits. Caller inheritance
continues to use the caller's validated runtime and credential restrictions.
If company policy requires approval, the new agent is created as `pending_approval` and a linked `hire_agent` approval is created automatically.
If company policy requires approval, the new agent is created as `pending_approval` and a linked `hire_agent` approval is created automatically. New hires can set `runner` to `auto`, `paperclip`, or `legacy`. Omitted means automatic: the Codex harness (`codex_local`) uses Paperclip Runner on supported targets, while other harness defaults stay unchanged. Use `legacy` for custom Codex CLI settings. The returned execution configuration is frozen for approval; existing agents do not change runners after unrelated edits. Caller inheritance continues to use the caller's validated runtime and credential restrictions.
Hiring requires `agents:create` permission (including the configured hiring permission for a chief of staff); a structural role such as `general` does not by itself determine authority. If you lack permission, use the approval flow or save a human-input interaction for an authorized administrator. Do not bypass a permission denial.
+2 -2
View File
@@ -65,7 +65,7 @@ test("new-task assignee, model, and project sheets scroll by touch and retain th
await json(await request.post(`/api/companies/${company.id}/agents`, {
data: {
name: `Touch Agent ${String(index).padStart(2, "0")}`, role: "engineer",
adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" },
adapterType: "codex_local", runner: "legacy", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
@@ -166,7 +166,7 @@ test.describe("desktop picker scrolling", () => {
await json(await request.post(`/api/companies/${company.id}/agents`, {
data: {
name: `Wheel Agent ${String(index).padStart(2, "0")}`, role: "engineer",
adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" },
adapterType: "codex_local", runner: "legacy", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
@@ -86,7 +86,8 @@ async function renderStory(page: Page, storyId: string, theme: (typeof THEMES)[n
const preview = (window as unknown as { __STORYBOOK_PREVIEW__?: { storyRenders?: Array<{ id: string; phase: string }> } }).__STORYBOOK_PREVIEW__;
return preview?.storyRenders?.some(render => render.id === id && render.phase === "finished");
}, storyId);
const expected = storyId.endsWith("--task-ready") ? page.getByRole("heading", { name: /is ready/ })
const expected = storyId.endsWith("--experimental-native-harnesses") ? page.getByRole("button", { name: "Experimental harness", exact: true })
: storyId.endsWith("--task-ready") ? page.getByRole("heading", { name: /is ready/ })
: page.getByRole("button", { name: codexCreation || storyId.endsWith("--codex-legacy-advanced") ? "Runner" : "Harness", exact: true });
await expect(expected).toBeVisible();
}
-24
View File
@@ -1,24 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { adaptersApi } from "./adapters";
import { api } from "./client";
import { queryKeys } from "../lib/queryKeys";
afterEach(() => vi.restoreAllMocks());
describe("adapter availability discovery", () => {
it("keeps inventory unscoped and sends the selected company and target", async () => {
const get = vi.spyOn(api, "get").mockResolvedValue([]);
await adaptersApi.list();
await adaptersApi.list({ companyId: "company-1", environmentId: "linux ssh" });
await adaptersApi.list({ companyId: "company-2", environmentId: null });
expect(get.mock.calls.map(([path]) => path)).toEqual([
"/adapters", "/adapters?companyId=company-1&environmentId=linux+ssh", "/adapters?companyId=company-2",
]);
});
it("isolates target availability from inventory, other targets, and other companies", () => {
const selected = queryKeys.adapters.availability("company-1", "linux");
expect(selected).not.toEqual(queryKeys.adapters.all);
expect(selected).not.toEqual(queryKeys.adapters.availability("company-1", "mac"));
expect(selected).not.toEqual(queryKeys.adapters.availability("company-2", "linux"));
});
});
@@ -680,6 +680,35 @@ describe("AgentConfigForm environment selector", () => {
expect(result.onSave).toHaveBeenCalledWith({ runner: "legacy" });
});
it.each([false, true])("keeps experimental native selection gated when enableNativeRunner is %s", async enabled => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: enabled });
const result = await renderForm([], { adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-5" } }, { showAdapterTypeField: true });
roots.push(result.root);
await clickElement(result.container.querySelector('[aria-label="Harness"]'));
const option = [...document.querySelectorAll('[role="option"]')].find(element => element.getAttribute("data-value") === "paperclip_runner");
expect(Boolean(option)).toBe(enabled);
if (!enabled) return;
expect(document.body.textContent).toContain("Advanced");
await clickElement(option);
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({
adapterType: "paperclip_runner",
adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }),
}));
expect(result.onSave.mock.calls[0][0].runner).toBeUndefined();
});
it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "acpx", acpxAgent: "claude" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Runner"]')).toBeNull();
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed native agent"));
await flushReact();
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed native agent" });
});
beforeEach(() => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.detectModel.mockResolvedValue(null);
+7 -2
View File
@@ -1631,6 +1631,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
value={isDotRunner ? "openai_dot" : isCodexHarness ? harnessType : adapterType}
disabledTypes={adapterPickerDisabledTypes}
openAiDotEnabled={experimentalSettings?.enableOpenAiDot === true}
nativeRunnerEnabled={experimentalSettings?.enableNativeRunner === true}
onChange={(choice) => {
const dot = choice === "openai_dot";
const t = dot ? "paperclip_runner" : choice;
@@ -3699,18 +3700,21 @@ export function AdapterTypeDropdown({
onChange,
disabledTypes,
openAiDotEnabled = false,
nativeRunnerEnabled = false,
}: {
value: string;
onChange: (type: string) => void;
disabledTypes: Set<string>;
openAiDotEnabled?: boolean;
nativeRunnerEnabled?: boolean;
}) {
const selectedDisplay = getAdapterDisplay(value);
const adapterList = useMemo(() =>
[...listAdapterOptions(type => adapterLabels[type] ?? getAdapterLabel(type)),
...(openAiDotEnabled ? [{ value: "openai_dot", label: "OpenAI Dot", experimental: true, comingSoon: false }] : []),
].filter(item => item.value !== "paperclip_runner" && !disabledTypes.has(item.value)),
[disabledTypes, openAiDotEnabled]);
].filter(item => (item.value !== "paperclip_runner" || nativeRunnerEnabled) && !disabledTypes.has(item.value))
.sort((a, b) => Number(a.value === "paperclip_runner") - Number(b.value === "paperclip_runner")),
[disabledTypes, openAiDotEnabled, nativeRunnerEnabled]);
return <SelectPopover aria-label="Harness" value={value} onValueChange={onChange}
displayValue={<span className="inline-flex min-w-0 items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={value} className="size-4" /></span>
@@ -3718,6 +3722,7 @@ export function AdapterTypeDropdown({
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>}
options={adapterList.map(item => ({ value: item.value, disabled: item.comingSoon,
group: item.value === "paperclip_runner" ? "Advanced" : undefined,
label: <span className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={item.value} className="size-4" /></span><span>{item.label}</span>{item.experimental && <ExperimentalBadge />}</span>,
suffix: item.comingSoon ? <span className="text-(length:--text-nano) text-muted-foreground">Coming soon</span> : undefined,
}))} />;
+19
View File
@@ -107,6 +107,25 @@ it("offers one Codex harness without a runner/provider tile", async () => {
expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1);
expect(document.querySelector("select")).toBeNull();
});
it.each(["claude", "grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => {
await name();
await act(async () => (document.querySelector("summary") as HTMLElement).click());
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Experimental harness"]')!.click());
await act(async () => document.querySelector<HTMLButtonElement>(`[role="option"][data-value="${provider}"]`)!.click());
await click("Configure agent");
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("adapterType")).toBe("paperclip_runner");
expect(query.get("runnerProvider")).toBe(provider);
});
it.each(["gate off", "disabled adapter"])("hides experimental native creation when %s", async reason => {
await act(async () => {
if (reason === "gate off") cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: false });
else cache.setQueryData(queryKeys.adapters.all, state.adapters.map((entry: any) => entry.type === "paperclip_runner" ? { ...entry, disabled: true } : entry));
});
await name();
expect(document.querySelector('[aria-label="Experimental harness"]')).toBeNull();
expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1);
});
it("moves Dot out of the harness picker into experimental external invitations", async () => {
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: false, enableOpenAiDot: true, enablePublicMcp: true }));
@@ -14,6 +14,7 @@ import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { SelectPopover } from "../ui/select";
import {
Dialog,
DialogContent,
@@ -57,7 +58,7 @@ export function AgentBasicsDialog({
});
const [name, setName] = useState("");
const [adapterType, setAdapterType] = useState(initialAdapter);
const runnerProvider = "codex";
const [runnerProvider, setRunnerProvider] = useState("codex");
const [step, setStep] = useState<"name" | "adapter">("name");
const {
data: adapters,
@@ -80,7 +81,10 @@ export function AgentBasicsDialog({
!["process", "http"].includes(adapter.type) &&
!getAdapterDisplay(adapter.type).comingSoon,
);
const validAdapter = choices.some((adapter) => adapter.type === adapterType);
const experimentalRunnerAvailable = experimental.data?.enableNativeRunner === true
&& adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
const validAdapter = choices.some((adapter) => adapter.type === adapterType)
|| (experimentalRunnerAvailable && adapterType === "paperclip_runner");
return (
<Dialog
open={open}
@@ -119,7 +123,7 @@ export function AgentBasicsDialog({
else if (validAdapter)
onContinue({ name: name.trim(),
adapterType: adapterType === "openai_dot" ? "paperclip_runner" : adapterType,
runnerProvider: adapterType === "openai_dot" ? "openai_dot" : runnerProvider });
runnerProvider: adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex" });
}}
>
<div className="flex min-h-0 flex-col gap-7 overflow-y-auto px-6 pb-8 sm:px-10">
@@ -212,6 +216,22 @@ export function AgentBasicsDialog({
);
})}
</div>
{experimentalRunnerAvailable && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<div className="space-y-2">
<label className="text-sm text-muted-foreground">Experimental harness</label>
<SelectPopover aria-label="Experimental harness"
value={adapterType === "paperclip_runner" ? runnerProvider : ""}
placeholder="Choose a native harness…"
onValueChange={provider => { setRunnerProvider(provider); setAdapterType("paperclip_runner"); }}
options={[
{ value: "codex", label: "Codex (Paperclip Runner)" },
{ value: "claude", label: "Claude Code (Paperclip Runner)" },
{ value: "grok", label: "Grok Build (Paperclip Runner)" },
{ value: "opencode", label: "OpenCode (Paperclip Runner)" },
]} />
</div>
</details>}
</fieldset>
)}
</div>
+23 -1
View File
@@ -1,5 +1,8 @@
import { beforeEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testAgentSetup } from "./test-agent-setup";
import { adaptersApi } from "../api/adapters";
import { api } from "../api/client";
import { queryKeys } from "./queryKeys";
const testEnvironment = vi.hoisted(() => vi.fn());
vi.mock("../api/agents", () => ({ agentsApi: { testEnvironment } }));
const input = {
@@ -27,6 +30,7 @@ const ready = {
checks: [{ code: "runtime", level: "info", message: "Ready" }],
};
beforeEach(() => testEnvironment.mockReset());
afterEach(() => vi.restoreAllMocks());
it("does not launch an ambient provider hello test for a pool preview", async () => {
testEnvironment.mockResolvedValue({ ...ready, status: "warn", checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Run a task" }] });
await testAgentSetup({ ...input, aiConnection: { mode: "router", connectionId: "pool-id" } });
@@ -107,3 +111,21 @@ it("keeps the resolved native identity and explicitly bounds the supplementary C
expect(testEnvironment.mock.calls[0][2].runner).toBe("paperclip");
expect(testEnvironment.mock.calls[1][2].runner).toBe("legacy");
});
describe("adapter availability discovery", () => {
it("keeps inventory unscoped and sends the selected company and target", async () => {
const get = vi.spyOn(api, "get").mockResolvedValue([]);
await adaptersApi.list();
await adaptersApi.list({ companyId: "company-1", environmentId: "linux ssh" });
await adaptersApi.list({ companyId: "company-2", environmentId: null });
expect(get.mock.calls.map(([path]) => path)).toEqual([
"/adapters", "/adapters?companyId=company-1&environmentId=linux+ssh", "/adapters?companyId=company-2",
]);
});
it("isolates target availability from inventory, other targets, and other companies", () => {
const selected = queryKeys.adapters.availability("company-1", "linux");
expect(selected).not.toEqual(queryKeys.adapters.all);
expect(selected).not.toEqual(queryKeys.adapters.availability("company-1", "mac"));
expect(selected).not.toEqual(queryKeys.adapters.availability("company-2", "linux"));
});
});
+2 -1
View File
@@ -98,7 +98,8 @@ vi.mock("../api/resourceMemberships", () => ({
resourceMembershipsApi: mockResourceMembershipsApi,
}));
vi.mock("../adapters/adapter-display-registry", () => ({
vi.mock("../adapters/adapter-display-registry", async (importOriginal) => ({
...await importOriginal<typeof import("../adapters/adapter-display-registry")>(),
getAdapterLabel: (type: string) => type,
}));
@@ -77,9 +77,13 @@ export function AgentSettingsPreview({
/** Storybook-only extension point for reviewing proposed runtime controls in the existing page. */
runtimeContent?: ReactNode;
}) {
const [fixtures] = useState(() =>
createSettingsFixtures(adapterType, testOutcome, saveFails),
);
const [fixtures] = useState(() => {
const fixtures = createSettingsFixtures(adapterType, testOutcome, saveFails);
if (adapterType === "codex_local" || adapterType === "paperclip_runner") {
fixtures.agent.adapterConfig = { ...fixtures.agent.adapterConfig, model: "gpt-5.6-sol", modelReasoningEffort: "high", ...(adapterType === "paperclip_runner" ? { provider: "codex" } : {}) };
}
return fixtures;
});
const [ready, setReady] = useState(false);
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const navigate = useNavigate();
@@ -98,7 +98,6 @@ export function createSettingsFixtures(
adapterType: adapterType as AgentDetail["adapterType"],
adapterConfig: {
"access.MODEL_API": { type: "secret_ref", secretId: "secret-openai" },
...(adapterType === "paperclip_runner" ? { provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high" } : adapterType === "codex_local" ? { model: "gpt-5.6-sol", modelReasoningEffort: "high" } : {}),
},
runtimeConfig: {
heartbeat: {
@@ -1,12 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { NewAgent } from "@/pages/NewAgent";
import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog";
import { models } from "@paperclipai/adapter-codex-local";
import { storybookHiredAgent } from "../../fixtures/paperclipData";
import { resetOnboardingFixtureState, setOnboardingFixtureState } from "../../fixtures/onboardingEnvironment";
/** Real creation screen; existing Storybook API fixtures provide isolated accounts. */
function installCodexCreationFixture({ setupFailure = false }: { setupFailure?: boolean } = {}) {
function installCodexCreationFixture({ setupFailure = false, nativeRunnerEnabled = false }: { setupFailure?: boolean; nativeRunnerEnabled?: boolean } = {}) {
resetOnboardingFixtureState();
setOnboardingFixtureState({ environments: "local", authSignal: "present", localLoginStatus: "ready", savedManagedSubscription: "openai" });
const previous = window.fetch;
@@ -16,7 +17,11 @@ function installCodexCreationFixture({ setupFailure = false }: { setupFailure?:
const body = () => JSON.parse(typeof init?.body === "string" ? init.body : "{}");
if (url.pathname === "/api/instance/settings/experimental") {
const response = await previous(input, init);
return Response.json({ ...await response.json(), enableNativeRunner: false });
return Response.json({ ...await response.json(), enableNativeRunner: nativeRunnerEnabled });
}
if (nativeRunnerEnabled && url.pathname === "/api/adapters") {
const response = await previous(input, init);
return Response.json([...await response.json(), { type: "paperclip_runner", label: "Paperclip Runner", source: "builtin", loaded: true, disabled: false, modelsCount: 0 }]);
}
if (url.pathname.endsWith("/adapters/codex_local/models")) return Response.json(models);
if (url.pathname.endsWith("/adapters/codex_local/test-environment")) {
@@ -78,3 +83,19 @@ export const TaskReady: Story = {
await expect(await canvas.findByRole("heading", { name: /is ready/ })).toBeVisible();
},
};
export const ExperimentalNativeHarnesses: Story = {
parameters: { codexCreationFixture: { nativeRunnerEnabled: true } },
render: () => <AgentBasicsDialog open onClose={() => {}} onContinue={() => {}} />,
play: async () => {
const dialog = within(document.body);
await userEvent.type(await dialog.findByRole("textbox", { name: "Agent name" }), "Nova");
await userEvent.click(await dialog.findByRole("button", { name: "Choose adapter" }));
await userEvent.click(await dialog.findByText("Advanced", { exact: true, selector: "summary" }));
await userEvent.click(await dialog.findByRole("button", { name: "Experimental harness" }));
await expect(await dialog.findByRole("option", { name: "Grok Build (Paperclip Runner)" })).toBeVisible();
await expect(await dialog.findByRole("option", { name: "OpenCode (Paperclip Runner)" })).toBeVisible();
await userEvent.click(await dialog.findByRole("option", { name: "Claude Code (Paperclip Runner)" }));
await expect(await dialog.findByRole("button", { name: "Experimental harness" })).toHaveTextContent("Claude Code");
await expect(await dialog.findByRole("button", { name: "Configure agent" })).toBeEnabled();
},
};