From 38199ea8e6a276eecd35084ad84d691322aba9f4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 10:12:13 -0500 Subject: [PATCH] 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 --- doc/plans/2026-10-06-default-agent-runner.md | 39 +++++++++- packages/shared/src/agent-runner.test.ts | 26 ------- .../agent-hire-ai-connections.test.ts | 4 +- .../agent-permissions-routes.test.ts | 73 ++++++++++++++++--- .../src/__tests__/agent-skills-routes.test.ts | 8 +- server/src/__tests__/ai-connections.test.ts | 2 +- .../native-status-arbiter-corpus.test.ts | 12 ++- server/src/routes/agents.ts | 20 ++++- .../services/agent-runner-selection.test.ts | 30 ++++++++ server/src/services/agent-runner-selection.ts | 2 +- .../native-runtime/setup-readiness.test.ts | 29 +++++++- .../native-runtime/setup-readiness.ts | 30 ++++++-- skills/paperclip/references/api-reference.md | 9 +-- tests/e2e/new-task-picker-touch.spec.ts | 4 +- .../storybook-visual/storybook-visual.spec.ts | 3 +- ui/src/api/adapters.test.ts | 24 ------ .../AgentConfigForm.render.test.tsx | 29 ++++++++ ui/src/components/AgentConfigForm.tsx | 9 ++- ui/src/components/NewAgentDialog.test.tsx | 19 +++++ .../new-agent/AgentBasicsDialog.tsx | 26 ++++++- ui/src/lib/test-agent-setup.test.ts | 24 +++++- ui/src/pages/Agents.test.tsx | 3 +- .../agent-settings/AgentSettingsPreview.tsx | 10 ++- .../prototypes/agent-settings/fixtures.ts | 1 - .../stories/codex-runner/creation.stories.tsx | 25 ++++++- 25 files changed, 354 insertions(+), 107 deletions(-) delete mode 100644 packages/shared/src/agent-runner.test.ts delete mode 100644 ui/src/api/adapters.test.ts diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index a97a47a7a32..a92ed1329ce 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -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. diff --git a/packages/shared/src/agent-runner.test.ts b/packages/shared/src/agent-runner.test.ts deleted file mode 100644 index 6ce2bd8eb19..00000000000 --- a/packages/shared/src/agent-runner.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index e1be98a92b0..957ff989380 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -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(); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 72570a9e9dc..9af83e814a3 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -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()), + runSshCommand: mockRunSshCommand, + })); vi.doMock("@paperclipai/adapter-opencode-local/server", async () => { const actual = await vi.importActual("@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(); + } }); } diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index ae267e9fd43..1ec8d87b110 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -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, diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 610d3c9bc98..ca7ca50cfc1 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -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)); diff --git a/server/src/__tests__/native-status-arbiter-corpus.test.ts b/server/src/__tests__/native-status-arbiter-corpus.test.ts index 246b4e19f92..fdf5956dc19 100644 --- a/server/src/__tests__/native-status-arbiter-corpus.test.ts +++ b/server/src/__tests__/native-status-arbiter-corpus.test.ts @@ -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" }, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 37526243861..acc708639e7 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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; + 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; + 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; @@ -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, diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index b16896d94de..bd564e7355e 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -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(), settings: {} as Record, 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); + }); +}); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index 2ad24c39ac1..b4d20f6b41d 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -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" }); diff --git a/server/src/services/native-runtime/setup-readiness.test.ts b/server/src/services/native-runtime/setup-readiness.test.ts index 0773717a499..8ef4e4076eb 100644 --- a/server/src/services/native-runtime/setup-readiness.test.ts +++ b/server/src/services/native-runtime/setup-readiness.test.ts @@ -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 }, } }; diff --git a/server/src/services/native-runtime/setup-readiness.ts b/server/src/services/native-runtime/setup-readiness.ts index dab78465fa3..78fb15ddf34 100644 --- a/server/src/services/native-runtime/setup-readiness.ts +++ b/server/src/services/native-runtime/setup-readiness.ts @@ -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; 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>; 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)) }); } } : {}), }); } diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 61094b5e96a..c068ccd057f 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -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. diff --git a/tests/e2e/new-task-picker-touch.spec.ts b/tests/e2e/new-task-picker-touch.spec.ts index b48d7084d67..aca21920613 100644 --- a/tests/e2e/new-task-picker-touch.spec.ts +++ b/tests/e2e/new-task-picker-touch.spec.ts @@ -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 } }, }, })); diff --git a/tests/storybook-visual/storybook-visual.spec.ts b/tests/storybook-visual/storybook-visual.spec.ts index 219e6580726..0e0160878f9 100644 --- a/tests/storybook-visual/storybook-visual.spec.ts +++ b/tests/storybook-visual/storybook-visual.spec.ts @@ -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(); } diff --git a/ui/src/api/adapters.test.ts b/ui/src/api/adapters.test.ts deleted file mode 100644 index c4b5bb4bbe2..00000000000 --- a/ui/src/api/adapters.test.ts +++ /dev/null @@ -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")); - }); -}); diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 318edd08cf2..5d8d41bf2c9 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -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('[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); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index e2d282c31ee..09f6c144a02 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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; 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 @@ -3718,6 +3722,7 @@ export function AdapterTypeDropdown({ {selectedDisplay.experimental && } } options={adapterList.map(item => ({ value: item.value, disabled: item.comingSoon, + group: item.value === "paperclip_runner" ? "Advanced" : undefined, label: {item.label}{item.experimental && }, suffix: item.comingSoon ? Coming soon : undefined, }))} />; diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx index 45365d91920..186deb48dea 100644 --- a/ui/src/components/NewAgentDialog.test.tsx +++ b/ui/src/components/NewAgentDialog.test.tsx @@ -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('[aria-label="Experimental harness"]')!.click()); + await act(async () => document.querySelector(`[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 })); diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index d90bed04ea8..b84b2390958 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -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 (
@@ -212,6 +216,22 @@ export function AgentBasicsDialog({ ); })}
+ {experimentalRunnerAvailable &&
+ Advanced +
+ + { 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)" }, + ]} /> +
+
} )} diff --git a/ui/src/lib/test-agent-setup.test.ts b/ui/src/lib/test-agent-setup.test.ts index 79d3522a50c..50af21a4f9e 100644 --- a/ui/src/lib/test-agent-setup.test.ts +++ b/ui/src/lib/test-agent-setup.test.ts @@ -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")); + }); +}); diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index b0586dea5a1..246c65c3c32 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -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(), getAdapterLabel: (type: string) => type, })); diff --git a/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx b/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx index a942b97b6ba..2238915c2da 100644 --- a/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx +++ b/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx @@ -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(); diff --git a/ui/storybook/prototypes/agent-settings/fixtures.ts b/ui/storybook/prototypes/agent-settings/fixtures.ts index 43b6ddcf568..c5de3920027 100644 --- a/ui/storybook/prototypes/agent-settings/fixtures.ts +++ b/ui/storybook/prototypes/agent-settings/fixtures.ts @@ -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: { diff --git a/ui/storybook/stories/codex-runner/creation.stories.tsx b/ui/storybook/stories/codex-runner/creation.stories.tsx index 8c454e07e72..cd988fddfa2 100644 --- a/ui/storybook/stories/codex-runner/creation.stories.tsx +++ b/ui/storybook/stories/codex-runner/creation.stories.tsx @@ -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: () => {}} 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(); + }, +};