mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 08:40:01 +02:00
fix(agents): preserve requested controls and imported native settings
Apply Legacy ACP alias precedence before native Codex compatibility checks. Project actual import edits onto the saved native configuration, preserving hidden runtime and credential settings while honoring explicit removals and harness changes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9c84f19365
commit
b63b779fe1
5 files changed
+209
-5
No files matched your search
@@ -9,6 +9,7 @@ vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: sta
|
||||
vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment }));
|
||||
vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh }));
|
||||
import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||||
import { buildCodexAcpConfig } from "@paperclipai/adapter-codex-local/server";
|
||||
import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js";
|
||||
const target = { driver: "local", platform: "linux", architecture: "x64" };
|
||||
const qualifiedTargets = [target, { driver: "local", platform: "linux", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }];
|
||||
@@ -117,6 +118,71 @@ describe("server-owned Codex runner selection", () => {
|
||||
try { resolveNewAgentRunner({ adapterType: "codex_local", target, adapterConfig: { [field]: true } }); }
|
||||
catch (error) { expect(error).toMatchObject({ status: 422, details: { fields: [field] } }); }
|
||||
});
|
||||
it.each([
|
||||
["agentCommand", "acpAgentCommand", "custom-codex-acp"],
|
||||
["stateDir", "acpStateDir", "/custom/acp-state"],
|
||||
["mode", "acpMode", "oneshot"],
|
||||
["permissionMode", "acpPermissionMode", "deny-all"],
|
||||
["nonInteractivePermissions", "acpNonInteractivePermissions", "fail"],
|
||||
["warmHandleIdleMs", "acpWarmHandleIdleMs", 30_000],
|
||||
])("rejects effective Legacy ACP alias %s/%s before automatic native translation", (canonical, alias, value) => {
|
||||
const adapterConfig = { engine: "acp", [alias]: value };
|
||||
expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty(canonical, value);
|
||||
for (const runner of ["auto", "paperclip"] as const) {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner }))
|
||||
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: [alias] } }));
|
||||
}
|
||||
expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" }))
|
||||
.toEqual({ adapterType: "codex_local", adapterConfig });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agentCommand", "acpAgentCommand", "canonical-command", "alias-command"],
|
||||
["stateDir", "acpStateDir", "/canonical-state", "/alias-state"],
|
||||
["permissionMode", "acpPermissionMode", "approve-all", "deny-all"],
|
||||
])("checks the effective canonical ACP %s ahead of its alias", (canonical, alias, value, aliasedValue) => {
|
||||
const adapterConfig = { engine: "acp", [canonical]: value, [alias]: aliasedValue };
|
||||
expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty(canonical, value);
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }))
|
||||
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: [canonical] } }));
|
||||
});
|
||||
|
||||
it("uses Legacy ACP canonical precedence and removes compatible or shadowed aliases idempotently", () => {
|
||||
const adapterConfig = { engine: "acp", mode: "persistent", acpMode: "oneshot",
|
||||
nonInteractivePermissions: "deny", acpNonInteractivePermissions: "fail",
|
||||
warmHandleIdleMs: 0, acpWarmHandleIdleMs: 30_000 };
|
||||
expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ mode: "persistent", nonInteractivePermissions: "deny", warmHandleIdleMs: 0 });
|
||||
const resolved = resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target });
|
||||
expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { codexPermissionMode: "never" } });
|
||||
for (const field of Object.keys(adapterConfig)) expect(resolved.adapterConfig).not.toHaveProperty(field);
|
||||
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
|
||||
expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" }))
|
||||
.toEqual({ adapterType: "codex_local", adapterConfig });
|
||||
});
|
||||
|
||||
it("accepts equivalent ACP default aliases without persisting legacy-only controls", () => {
|
||||
const adapterConfig = { engine: "acp", acpMode: " persistent ", acpNonInteractivePermissions: " deny ", acpWarmHandleIdleMs: 0 };
|
||||
expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ mode: "persistent", nonInteractivePermissions: "deny", warmHandleIdleMs: 0 });
|
||||
const resolved = resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target });
|
||||
expect(resolved.adapterType).toBe("paperclip_runner");
|
||||
for (const field of Object.keys(adapterConfig)) expect(resolved.adapterConfig).not.toHaveProperty(field);
|
||||
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
|
||||
});
|
||||
|
||||
it("uses nullish ACP idle precedence instead of treating a canonical zero as absent", () => {
|
||||
const adapterConfig = { engine: "acp", warmHandleIdleMs: null, acpWarmHandleIdleMs: 30_000 };
|
||||
expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty("warmHandleIdleMs", 30_000);
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }))
|
||||
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["acpWarmHandleIdleMs"] } }));
|
||||
});
|
||||
|
||||
it("uses a nonempty ACP alias when its canonical string is blank", () => {
|
||||
const adapterConfig = { engine: "acp", permissionMode: " ", acpPermissionMode: " deny-all " };
|
||||
expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty("permissionMode", "deny-all");
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }))
|
||||
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["acpPermissionMode"] } }));
|
||||
});
|
||||
|
||||
it("preserves explicit historical native profiles, including managed configurations", () => {
|
||||
for (const config of [{ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, { provider: "claude_managed", managedProfileId: "profile", managedAgentsRetentionAcknowledged: true }, { provider: "aws_agentcore", agentCoreProfileId: "profile" }]) expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", adapterConfig: config })).toEqual({ adapterType: "paperclip_runner", adapterConfig: config });
|
||||
});
|
||||
|
||||
@@ -61,6 +61,32 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced."
|
||||
: "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
|
||||
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
const aliasSources = new Map<string, string>();
|
||||
// Legacy ACP uses the first nonempty canonical/alias string, and nullish
|
||||
// precedence for its idle timeout. Validate that effective choice, not an
|
||||
// unchecked prefixed field, while retaining its name in actionable errors.
|
||||
for (const [canonical, alias] of [
|
||||
["agentCommand", "acpAgentCommand"], ["stateDir", "acpStateDir"],
|
||||
["mode", "acpMode"], ["permissionMode", "acpPermissionMode"],
|
||||
["nonInteractivePermissions", "acpNonInteractivePermissions"],
|
||||
]) {
|
||||
if (config[alias] === undefined) continue;
|
||||
const canonicalValue = typeof config[canonical] === "string" ? config[canonical].trim() : "";
|
||||
const aliasValue = typeof config[alias] === "string" ? config[alias].trim() : "";
|
||||
if (canonicalValue || aliasValue) {
|
||||
config[canonical] = canonicalValue || aliasValue;
|
||||
if (!canonicalValue) aliasSources.set(canonical, alias);
|
||||
} else if (config[alias] !== null && typeof config[alias] !== "string") incompatible([alias]);
|
||||
else if (typeof config[canonical] === "string") delete config[canonical];
|
||||
delete config[alias];
|
||||
}
|
||||
if (config.acpWarmHandleIdleMs !== undefined) {
|
||||
if (config.warmHandleIdleMs == null && config.acpWarmHandleIdleMs != null) {
|
||||
config.warmHandleIdleMs = config.acpWarmHandleIdleMs;
|
||||
aliasSources.set("warmHandleIdleMs", "acpWarmHandleIdleMs");
|
||||
}
|
||||
delete config.acpWarmHandleIdleMs;
|
||||
}
|
||||
const unsupported = ["agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "search", "fastMode", "variant", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "permissionMode", "maxTurns", "disableWebSearch", "outputInactivityTimeoutMs", "terminalResultCleanupGraceMs"]
|
||||
.filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0));
|
||||
if (config.command && config.command !== "codex") unsupported.push("command");
|
||||
@@ -69,7 +95,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions");
|
||||
if (config.dangerouslyBypassApprovalsAndSandbox === false) unsupported.push("dangerouslyBypassApprovalsAndSandbox");
|
||||
if (config.dangerouslyBypassSandbox === false) unsupported.push("dangerouslyBypassSandbox");
|
||||
if (unsupported.length) incompatible(unsupported);
|
||||
if (unsupported.length) incompatible(unsupported.map(field => aliasSources.get(field) ?? field));
|
||||
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
||||
for (const key of ["engine", "command", "extraArgs", "args", "agentCommand", "mode", "nonInteractivePermissions", "stateDir", "warmHandleIdleMs", "dangerouslyBypassApprovalsAndSandbox", "dangerouslyBypassSandbox", "search", "fastMode", "reasoningEffort", "effort"]) delete config[key];
|
||||
if (config.model !== undefined && typeof config.model !== "string") incompatible(["model"]);
|
||||
|
||||
Reference in new issue
Block a user