mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
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:
1 parent
eaabc5c624
commit
38199ea8e6
25 files changed
+354
-107
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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)) });
|
||||
} } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}))} />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user