mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native Codex agents use the saved agent settings to prepare each provider launch. > - Onboarding saves web search as off, but native configuration conversion removes that setting. > - Codex 0.160.0 includes a hosted web-search tool by default, even in an authentication probe that asks it not to use tools. > - A gateway that permits only priced function tools can reject that request before budget admission. > - This change preserves the explicit restriction through native execution and disables web search for authentication probes. ## Linked Issues or Issue Description **What happened?** A native Codex authentication probe can include hosted web search. Normal task execution also loses a saved `search: false` setting. A restricted Responses gateway can reject these requests before it makes a model call. Related repairs: Refs #17445, Refs #17529. The roadmap and related PRs were checked; no duplicate repair was found. **Expected behavior** Authentication probes check the selected account and model without hosted web search. A saved search-off setting remains effective in native task launches, prepared launches, and session recovery. Existing behavior for an absent setting remains unchanged. **Steps to reproduce** 1. Create a Codex agent with search off and choose Paperclip Runner. 2. Use a Responses gateway that rejects hosted tools. 3. Inspect the request from the native authentication probe or task launch. Before this fix, the pinned Codex SDK includes `web_search` despite the saved setting. ## What Changed - Preserve an explicit search-off setting when native agent configuration is built and converted. - Carry the restriction in the durable native input and provider launch requirements. - Apply the restriction to authentication probes and normal launch paths, including custom arguments and recovery. - Reject prepared launches that discard the required restriction. Keep gateway billing rules unchanged. - Admit only the restrictive literal value in the strict plugin task API. Exercise the real plugin setup path. - Add regression coverage and document the policy. - Add the immutable Copilot v60 source identity required by the changed shared files. Preserve v59 and all original live evidence. Copilot authentication probes keep their prior launch requirements. ## Verification - Regression cases were run against the unfixed source and failed at the configuration and launch boundaries. - An isolated capture using the real Codex 0.160.0 app-server reproduced hosted-tool rejection. Disabling web search reached a mocked budget reservation. The helper and child processes permitted only loopback network access and used synthetic credentials. It made zero upstream calls. - Focused checks passed: 284 server cases, 61 native form cases, 198 backend/security/launch/parser cases, and 36 authentication/launch cases. A separate complete transport/launch/parser run passed 393 cases. These groups overlap. - Head `617b98f` passed workspace typecheck/build and 28/28 plugin contract and actual Runner probe tests with no skips. Final rebased head `20ff0c1` passed all current-head CI checks, including full isolated test lanes, and fresh Greptile and Paperclip reviews at 5/5. - The additive Copilot identity update passed 134 compatibility/policy/protocol checks and nine authentication probe checks. The real pinned Codex SDK capture was repeated after the final policy change: all five cases passed with zero upstream calls. - Two local full-suite attempts stopped after native PostgreSQL loader failures. Seven local dependency aliases were restored, and an isolated cluster initialized successfully. A third run later encountered host PostgreSQL semaphore exhaustion and was interrupted before source-profile regeneration. Its zero exit code after SIGINT is explicitly not a full-suite pass. Final full-suite verification runs in isolated CI; the local attempts remain recorded. - The first CI run exposed a missing strict plugin API field. Its five failures were reproduced locally, then passed after repair. The next run passed every underlying job except the source-identity guard. That guard now binds an additive identity with v59 retained. Final-head CI and both fresh reviews passed; addressed review threads are resolved. - The offline capture runs on Darwin ARM64. It does not prove the contents of a prior unretained Linux request or complete Cloud onboarding acceptance. ## Risks - The restriction must survive prepared launches and resumed sessions. Regression tests cover those boundaries. - Existing absent settings retain their prior behavior. This change does not add native search-on support or loosen gateway admission. - Older durable inputs without the optional restriction must remain valid. - Copilot now uses profile v60 for exact launch admission. Historical v59 inputs remain readable. The new declaration adds no live Copilot qualification and does not change its executable or admission flags. - A deployed Cloud acceptance run is still required after this source repair. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, code execution, and delegated review. The exact backend model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
850 lines
35 KiB
TypeScript
850 lines
35 KiB
TypeScript
import { agentHarnessType, normalizeClaudeRunnerOptions } from "@paperclipai/shared";
|
|
import { resolveClaudeEffort, resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js";
|
|
import {
|
|
isPaperclipRunnerProvider,
|
|
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
|
|
PAPERCLIP_RUNNER_ACPX_PROFILES,
|
|
resolvePaperclipRunnerPermissionMode,
|
|
resolvePaperclipRunnerCursorMode,
|
|
resolvePaperclipRunnerPiThinkingLevel,
|
|
type PaperclipRunnerProvider,
|
|
} from "@paperclipai/adapter-utils";
|
|
import {
|
|
codexLocalReasoningEffortsForModel,
|
|
isCodexLocalKnownModel,
|
|
} from "@paperclipai/adapter-codex-local";
|
|
import {
|
|
AGENTCORE_QUALIFIED_MODEL,
|
|
CLAUDE_MANAGED_QUALIFIED_MODEL,
|
|
} from "../provider-profile-qualification.js";
|
|
import { resolveAcpxQualification, type AcpxQualificationCandidate } from "./acpx-qualification.js";
|
|
import { compatibleCodexModel } from "./codex-model-fallback.js";
|
|
|
|
export const QUALIFIED_OPENCODE_RUNNER_VERSION = "1.18.34" as const;
|
|
export const DEFAULT_OPENCODE_RUNNER_MODEL =
|
|
"openrouter/deepseek/deepseek-v4-flash-0731" as const;
|
|
export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const;
|
|
|
|
export const DEFAULT_ACPX_RUNNER_MODELS = {
|
|
grok: "grok-4.7",
|
|
claude: "claude-opus-5-5",
|
|
// These profiles require explicit configuration. Admission belongs to the runner.
|
|
codex: null,
|
|
cursor: null,
|
|
copilot: null,
|
|
pi: null,
|
|
} as const;
|
|
|
|
export type QualifiedPaperclipRunnerAcpxAgent =
|
|
keyof typeof DEFAULT_ACPX_RUNNER_MODELS;
|
|
type AdmittedPaperclipRunnerAcpxAgent = QualifiedPaperclipRunnerAcpxAgent | AcpxQualificationCandidate;
|
|
|
|
export type PaperclipRunnerProviderProfile =
|
|
| { provider: "muse"; backend: "muse_external"; model: null; museBindingId: string }
|
|
| { provider: "grok_bot"; backend: "grok_bot_mcp"; model: null; externalBindingId: string }
|
|
| { provider: "openai_dot"; backend: "openai_dot_mcp"; model: null; dotBindingId: string }
|
|
| {
|
|
provider: "codex";
|
|
backend: "codex_app_server";
|
|
model: string | null;
|
|
}
|
|
| {
|
|
provider: "opencode";
|
|
backend: "opencode_server";
|
|
model: string;
|
|
}
|
|
| {
|
|
provider: "claude_managed";
|
|
backend: "claude_managed_agents_api";
|
|
managedProfileId: string;
|
|
model: string | null;
|
|
maxSessionListCostUsd: number | null;
|
|
}
|
|
| {
|
|
provider: "aws_agentcore";
|
|
backend: "aws_agentcore_harness_api";
|
|
agentCoreProfileId: string;
|
|
model: string | null;
|
|
maxEstimatedSessionCostUsd: number | null;
|
|
}
|
|
| {
|
|
provider: "acpx";
|
|
backend: "acpx_runtime";
|
|
model: string;
|
|
acpxAgent: AdmittedPaperclipRunnerAcpxAgent;
|
|
};
|
|
|
|
export type PaperclipRunnerNativeProviderInput =
|
|
| { provider: "muse"; model: null; museBinding: import("../../vendor/paperclip-runner/index.js").MuseBindingSnapshot }
|
|
| { provider: "grok_bot"; model: null; dotBinding: import("../../vendor/paperclip-runner/index.js").DotBindingSnapshot }
|
|
| { provider: "openai_dot"; model: null; dotBinding: import("../../vendor/paperclip-runner/index.js").DotBindingSnapshot }
|
|
| {
|
|
provider: "codex";
|
|
model: string | null;
|
|
homeMode?: "isolated" | "preconfigured";
|
|
codexApprovalPolicy: "never" | "on-request" | "untrusted";
|
|
codexReasoningEffort?: string;
|
|
codexWebSearch?: "disabled";
|
|
}
|
|
| {
|
|
provider: "opencode";
|
|
model: string;
|
|
opencodePermissionMode: "allow" | "ask" | "deny";
|
|
}
|
|
| {
|
|
provider: "claude_managed";
|
|
model: string;
|
|
managedProfile: {
|
|
profileId: string;
|
|
anthropicAgentId: string;
|
|
agentVersion: string;
|
|
environmentId: string;
|
|
betaVersion: typeof CLAUDE_MANAGED_BETA_VERSION;
|
|
};
|
|
maxSessionListCostUsd: number;
|
|
}
|
|
| {
|
|
provider: "aws_agentcore";
|
|
model: string;
|
|
agentCoreProfile: {
|
|
profileId: string;
|
|
region: string;
|
|
accountId: string;
|
|
harnessArn: string;
|
|
harnessVersion: string;
|
|
endpointArn: string;
|
|
endpointQualifier: string;
|
|
agentRuntimeArn: string;
|
|
memoryArn: string;
|
|
memoryId: string;
|
|
invocationRoleArn: string;
|
|
contextBucket: string;
|
|
contextPrefix: string;
|
|
contextKmsKeyArn: string;
|
|
qualificationRevision: string;
|
|
eventExpiryDays: 90;
|
|
};
|
|
maxEstimatedSessionCostUsd: number;
|
|
invocationLimits: {
|
|
maxIterations: number;
|
|
maxOutputTokens: number;
|
|
timeoutSeconds: number;
|
|
};
|
|
}
|
|
| {
|
|
provider: "acpx";
|
|
model: string;
|
|
acpxAgent: AdmittedPaperclipRunnerAcpxAgent;
|
|
homeMode?: "isolated" | "preconfigured";
|
|
acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
|
|
acpxSessionMode?: "agent" | "plan" | "ask";
|
|
piThinkingLevel?: "off" | "low" | "high" | "max";
|
|
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
};
|
|
|
|
export class PaperclipRunnerProviderProfileError extends Error {
|
|
constructor(
|
|
readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "PaperclipRunnerProviderProfileError";
|
|
}
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
function optionalString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0
|
|
? value.trim()
|
|
: null;
|
|
}
|
|
|
|
/** Configuration can be saved before pairing; task admission requires a binding. */
|
|
export function validatePaperclipRunnerDotConfig(config: Record<string, unknown>, requireBinding = true): string | null {
|
|
const bindingId = optionalString(config.dotBindingId);
|
|
if ((requireBinding && !bindingId) || (bindingId && !/^[0-9a-f-]{36}$/i.test(bindingId))
|
|
|| config.allowUnmeteredProvider !== true || (config.lifecycleMode && config.lifecycleMode !== "per_turn")
|
|
|| optionalString(config.model)) {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_dot_config_invalid", "Dot requires per-turn lifecycle and explicit externally billed, unmetered-provider acknowledgement. Pair a binding before assigning work.");
|
|
}
|
|
return bindingId;
|
|
}
|
|
|
|
/** Closed personal runtime configuration; model and workspace grants are excluded. */
|
|
export function validatePaperclipRunnerMuseConfig(config: Record<string, unknown>, requireBinding = true): string | null {
|
|
const bindingId = optionalString(config.museBindingId);
|
|
if ((requireBinding && !bindingId) || (bindingId && !/^[0-9a-f-]{36}$/i.test(bindingId))
|
|
|| config.allowUnmeteredProvider !== true || (config.lifecycleMode && config.lifecycleMode !== "per_turn")
|
|
|| optionalString(config.model) || config.dotWorkspaceAccess === true || config.dotAttachmentAccess === true) {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_muse_config_invalid", "Muse requires a paired personal-agent binding, per-turn lifecycle and unavailable-usage acknowledgement.");
|
|
}
|
|
return bindingId;
|
|
}
|
|
|
|
export function validatePaperclipRunnerGrokConfig(config: Record<string, unknown>, requireBinding = true): string | null {
|
|
const bindingId = optionalString(config.externalBindingId);
|
|
if ((requireBinding && !bindingId) || (bindingId && !/^[0-9a-f-]{36}$/i.test(bindingId))
|
|
|| config.allowUnmeteredProvider !== true || (config.lifecycleMode && config.lifecycleMode !== "per_turn")
|
|
|| optionalString(config.model) || config.dotWorkspaceAccess === true || config.dotAttachmentAccess === true
|
|
|| optionalString(config.cwd) || optionalString(config.command)) {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_grok_config_invalid", "Grok Bot requires a bound per-turn work session and an explicit acknowledgement of unreported provider spending. Paperclip workspace, shell and third-party app tools are unavailable.");
|
|
}
|
|
return bindingId;
|
|
}
|
|
|
|
/** A task may change a local Runner model (and Codex effort), but not provider identity. */
|
|
export function projectPaperclipRunnerTaskConfig(
|
|
backend: "codex_app_server" | "opencode_server" | "acpx_runtime",
|
|
agentConfig: unknown,
|
|
taskOverrides: unknown,
|
|
managedModel?: string,
|
|
): Record<string, unknown> {
|
|
const base = asRecord(agentConfig);
|
|
const task = asRecord(taskOverrides);
|
|
const config = { ...base };
|
|
// The server resolves a connection's model namespace after task overrides.
|
|
// Carry that model into durable input without allowing routing to change the harness.
|
|
const model = optionalString(managedModel) ?? optionalString(task.model);
|
|
const supportsEffort = backend === "codex_app_server" || (backend === "acpx_runtime" && agentHarnessType("paperclip_runner", base) === "claude_local");
|
|
const effortKey = supportsEffort
|
|
? ["modelReasoningEffort", "reasoningEffort", "effort"].find((key) => key in task)
|
|
: undefined;
|
|
if (supportsEffort && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) {
|
|
delete config.modelReasoningEffort;
|
|
delete config.reasoningEffort;
|
|
delete config.effort;
|
|
}
|
|
if (model !== null) config.model = model;
|
|
if (effortKey !== undefined) config.modelReasoningEffort = task[effortKey];
|
|
return config;
|
|
}
|
|
|
|
function positiveNumberOrNull(
|
|
value: unknown,
|
|
code: string,
|
|
message: string,
|
|
): number | null {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
throw new PaperclipRunnerProviderProfileError(code, message);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function boundedPositiveInteger(
|
|
value: unknown,
|
|
fallback: number,
|
|
maximum: number,
|
|
code: string,
|
|
label: string,
|
|
): number {
|
|
if (value === undefined || value === null || value === "") return fallback;
|
|
if (
|
|
typeof value !== "number"
|
|
|| !Number.isSafeInteger(value)
|
|
|| value <= 0
|
|
|| value > maximum
|
|
) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
code,
|
|
`${label} must be an integer between 1 and ${maximum}.`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertPermissionMode(
|
|
provider: PaperclipRunnerProvider,
|
|
config: Record<string, unknown>,
|
|
): void {
|
|
const capability = PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider];
|
|
if (!capability.configurable) return;
|
|
const configured = config[capability.configKey];
|
|
if (
|
|
configured !== undefined
|
|
&& resolvePaperclipRunnerPermissionMode(provider, configured) !== configured
|
|
) {
|
|
if (provider === "codex") {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_codex_permission_mode_unqualified",
|
|
"Paperclip Runner currently supports Codex only with codexPermissionMode set to never. Update the agent configuration before starting a new native run.",
|
|
);
|
|
}
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"runner_permission_mode_invalid",
|
|
`${capability.configKey} is not supported by ${provider}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rebind a persisted Claude Managed run only to the still-qualified profile
|
|
* and the profile's current company secret. The secret itself may rotate, but
|
|
* the agent configuration must rotate its binding to the same profile-owned
|
|
* secret before recovery can continue.
|
|
*/
|
|
export function assertManagedProfileRecoveryBinding(input: {
|
|
adapterConfig: unknown;
|
|
snapshot: {
|
|
profileId: string;
|
|
anthropicAgentId: string;
|
|
agentVersion: string;
|
|
environmentId: string;
|
|
betaVersion: string;
|
|
};
|
|
stored: {
|
|
id: string;
|
|
anthropicAgentId: string;
|
|
agentVersion: string;
|
|
environmentId: string;
|
|
betaVersion: string;
|
|
apiKeySecretId: string;
|
|
};
|
|
}): void {
|
|
const { snapshot, stored } = input;
|
|
if (
|
|
snapshot.profileId !== stored.id
|
|
|| snapshot.anthropicAgentId !== stored.anthropicAgentId
|
|
|| snapshot.agentVersion !== stored.agentVersion
|
|
|| snapshot.environmentId !== stored.environmentId
|
|
|| snapshot.betaVersion !== stored.betaVersion
|
|
) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_recovery_identity_mismatch",
|
|
"The persisted Claude Managed identity no longer matches its qualified profile.",
|
|
);
|
|
}
|
|
const rawBinding = asRecord(asRecord(input.adapterConfig).env).ANTHROPIC_API_KEY;
|
|
const boundSecretId = asRecord(rawBinding).secretId;
|
|
if (boundSecretId !== stored.apiKeySecretId) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_recovery_secret_mismatch",
|
|
"The persisted Claude Managed run is not bound to its profile's current API-key secret.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Revalidate the operator's AWS profile revocation and immutable identity on recovery. */
|
|
export function assertAgentCoreProfileRecoveryBinding(input: {
|
|
snapshot: {
|
|
profileId: string;
|
|
region: string;
|
|
accountId: string;
|
|
harnessArn: string;
|
|
harnessVersion: string;
|
|
endpointArn: string;
|
|
endpointQualifier: string;
|
|
agentRuntimeArn: string;
|
|
memoryArn: string;
|
|
memoryId: string;
|
|
invocationRoleArn: string;
|
|
contextBucket: string;
|
|
contextPrefix: string;
|
|
contextKmsKeyArn: string;
|
|
qualificationRevision: string;
|
|
eventExpiryDays: number;
|
|
};
|
|
stored: {
|
|
id: string;
|
|
configuration: Record<string, unknown>;
|
|
};
|
|
}): void {
|
|
const { snapshot, stored } = input;
|
|
const configuration = asRecord(stored.configuration);
|
|
const fields = [
|
|
"region",
|
|
"accountId",
|
|
"harnessArn",
|
|
"harnessVersion",
|
|
"endpointArn",
|
|
"endpointQualifier",
|
|
"agentRuntimeArn",
|
|
"memoryArn",
|
|
"memoryId",
|
|
"invocationRoleArn",
|
|
"contextBucket",
|
|
"contextPrefix",
|
|
"contextKmsKeyArn",
|
|
"qualificationRevision",
|
|
"eventExpiryDays",
|
|
] as const;
|
|
if (
|
|
snapshot.profileId !== stored.id
|
|
|| fields.some((field) => snapshot[field] !== configuration[field])
|
|
) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_recovery_identity_mismatch",
|
|
"The persisted AWS AgentCore identity no longer matches its qualified profile.",
|
|
);
|
|
}
|
|
}
|
|
|
|
export function claudeRunnerUnsupportedSettings(config: Record<string, unknown>): string[] {
|
|
config = normalizeClaudeRunnerOptions(config).config;
|
|
const unsupported = ["chrome", "agentCommand", "acpAgentCommand", "stateDir", "acpStateDir", "warmHandleIdleMs", "acpWarmHandleIdleMs", "extraArgs", "args", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "maxTurnsPerRun", "permissionMode", "acpPermissionMode"]
|
|
.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 !== "claude") unsupported.push("command");
|
|
if (config.mode && config.mode !== "persistent") unsupported.push("mode");
|
|
if (config.acpMode && config.acpMode !== "persistent") unsupported.push("acpMode");
|
|
if (config.engine && !["auto", "cli", "acp"].includes(String(config.engine))) unsupported.push("engine");
|
|
if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions");
|
|
if (config.acpNonInteractivePermissions && config.acpNonInteractivePermissions !== "deny") unsupported.push("acpNonInteractivePermissions");
|
|
if (config.dangerouslySkipPermissions === false) unsupported.push("dangerouslySkipPermissions");
|
|
return unsupported;
|
|
}
|
|
|
|
/**
|
|
* Resolve the immutable provider identity used for a fresh Paperclip Runner
|
|
* selection. The persisted adapterConfig is the authority; runtimeConfig is
|
|
* deliberately not consulted so model-profile or migration metadata cannot
|
|
* silently switch the harness selected for a run.
|
|
*/
|
|
export function resolvePaperclipRunnerProviderProfile(
|
|
adapterConfig: unknown,
|
|
): PaperclipRunnerProviderProfile {
|
|
const config = asRecord(adapterConfig);
|
|
const candidate = config.provider ?? "codex";
|
|
if (!isPaperclipRunnerProvider(candidate)) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_provider_unsupported",
|
|
"Paperclip Runner provider must be Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX.",
|
|
);
|
|
}
|
|
|
|
if (candidate === "muse") {
|
|
const bindingId = validatePaperclipRunnerMuseConfig(config)!;
|
|
return { provider: "muse", backend: "muse_external", model: null, museBindingId: bindingId };
|
|
}
|
|
if (candidate === "grok_bot") {
|
|
return { provider: "grok_bot", backend: "grok_bot_mcp", model: null, externalBindingId: validatePaperclipRunnerGrokConfig(config)! };
|
|
}
|
|
if (candidate === "openai_dot") {
|
|
const bindingId = validatePaperclipRunnerDotConfig(config)!;
|
|
return { provider: "openai_dot", backend: "openai_dot_mcp", model: null, dotBindingId: bindingId };
|
|
}
|
|
if (candidate === "acpx" && agentHarnessType("paperclip_runner", config) === "claude_local") {
|
|
const unsupported = claudeRunnerUnsupportedSettings(config);
|
|
if (unsupported.length) throw new PaperclipRunnerProviderProfileError("agent_runner_config_incompatible",
|
|
`Paperclip Runner cannot honor these settings: ${unsupported.join(", ")}. Remove them or select Legacy runner in Advanced.`);
|
|
}
|
|
assertPermissionMode(candidate, config);
|
|
try {
|
|
resolvePaperclipRunnerCursorMode(candidate, config.acpxAgent, config.acpxSessionMode);
|
|
} catch (error) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_mode_invalid",
|
|
error instanceof Error ? error.message : "Invalid Cursor session mode",
|
|
);
|
|
}
|
|
try { if (candidate === "acpx" && agentHarnessType("paperclip_runner", config) === "claude_local") resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort); } catch (error) {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_claude_effort_invalid", error instanceof Error ? error.message : "Invalid Claude effort");
|
|
}
|
|
try { resolvePaperclipRunnerPiThinkingLevel(candidate, config.acpxAgent, config.piThinkingLevel); } catch (error) {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_pi_thinking_invalid", error instanceof Error ? error.message : "Invalid Pi thinking level");
|
|
}
|
|
const model = optionalString(config.model);
|
|
if (candidate === "codex") {
|
|
return {
|
|
provider: "codex",
|
|
backend: "codex_app_server",
|
|
model,
|
|
};
|
|
}
|
|
|
|
if (candidate === "opencode") {
|
|
if (!model || !model.includes("/") || model.endsWith("/")) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_opencode_model_invalid",
|
|
"Paperclip Runner OpenCode requires model in provider/model form.",
|
|
);
|
|
}
|
|
return {
|
|
provider: "opencode",
|
|
backend: "opencode_server",
|
|
model,
|
|
};
|
|
}
|
|
|
|
if (candidate === "claude_managed") {
|
|
const managedProfileId = optionalString(config.managedProfileId);
|
|
if (!managedProfileId) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_profile_required",
|
|
"Paperclip Runner Claude Managed requires a company managed-agent profile.",
|
|
);
|
|
}
|
|
if (config.managedAgentsRetentionAcknowledged !== true) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_retention_required",
|
|
"Paperclip Runner Claude Managed requires acknowledgement of stateful beta retention.",
|
|
);
|
|
}
|
|
if (model !== null && model !== CLAUDE_MANAGED_QUALIFIED_MODEL) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_model_unqualified",
|
|
`The Claude Managed profile requires exact model ${CLAUDE_MANAGED_QUALIFIED_MODEL}.`,
|
|
);
|
|
}
|
|
return {
|
|
provider: "claude_managed",
|
|
backend: "claude_managed_agents_api",
|
|
managedProfileId,
|
|
model,
|
|
maxSessionListCostUsd: positiveNumberOrNull(
|
|
config.maxSessionListCostUsd,
|
|
"paperclip_runner_claude_managed_spend_cap_invalid",
|
|
"Paperclip Runner Claude Managed requires a positive session spend ceiling when overridden.",
|
|
),
|
|
};
|
|
}
|
|
|
|
if (candidate === "aws_agentcore") {
|
|
const agentCoreProfileId = optionalString(config.agentCoreProfileId);
|
|
if (!agentCoreProfileId) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_profile_required",
|
|
"Paperclip Runner AWS AgentCore requires a company remote-agent profile.",
|
|
);
|
|
}
|
|
if (config.agentCoreRetentionAcknowledged !== true) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_retention_required",
|
|
"Paperclip Runner AWS AgentCore requires acknowledgement of 90-day Memory retention.",
|
|
);
|
|
}
|
|
if (model !== null && model !== AGENTCORE_QUALIFIED_MODEL) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_model_unqualified",
|
|
`The AWS AgentCore profile requires exact model ${AGENTCORE_QUALIFIED_MODEL}.`,
|
|
);
|
|
}
|
|
return {
|
|
provider: "aws_agentcore",
|
|
backend: "aws_agentcore_harness_api",
|
|
agentCoreProfileId,
|
|
model,
|
|
maxEstimatedSessionCostUsd: positiveNumberOrNull(
|
|
config.maxEstimatedSessionCostUsd,
|
|
"paperclip_runner_aws_agentcore_spend_cap_invalid",
|
|
"Paperclip Runner AWS AgentCore requires a positive estimated session spend ceiling when overridden.",
|
|
),
|
|
};
|
|
}
|
|
|
|
const acpxAgent = config.acpxAgent ?? "claude";
|
|
if (acpxAgent === "copilot" && (!model || ["auto", "default"].includes(model.toLowerCase()))) {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "GitHub Copilot requires an explicit model ID; there is no default model.");
|
|
}
|
|
const pendingAcpxProfile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === acpxAgent && !profile.qualified);
|
|
if (pendingAcpxProfile) {
|
|
if (!model) throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", `${pendingAcpxProfile.label} requires an explicit model ID; there is no default model.`);
|
|
// Qualification authority belongs to the isolated server operator. Never
|
|
// read this allowlist from adapter config, credential refs, or run env.
|
|
let qualification: AcpxQualificationCandidate | undefined;
|
|
try {
|
|
qualification = resolveAcpxQualification({ kind: "acpx", agent: pendingAcpxProfile.value, model }, process.env);
|
|
} catch {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_qualification_invalid", "ACPX qualification requires an exact host-authorized candidate and model.");
|
|
}
|
|
if (qualification) {
|
|
return { provider: "acpx", backend: "acpx_runtime", model, acpxAgent: qualification };
|
|
}
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_agent_unavailable", `${pendingAcpxProfile.label} is awaiting local and Daytona qualification. Its profile is not enabled for production runs.`);
|
|
}
|
|
if ((acpxAgent === "cursor" || acpxAgent === "pi") && !model) {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "This ACPX agent requires an explicit model ID; there is no default model.");
|
|
}
|
|
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "copilot" && acpxAgent !== "pi") {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_acpx_agent_unavailable",
|
|
"Paperclip Runner ACPX requires a qualified agent profile.",
|
|
);
|
|
}
|
|
const selectedModel = model || DEFAULT_ACPX_RUNNER_MODELS[acpxAgent] || "";
|
|
try {
|
|
resolveQualifiedAcpxProfile(acpxAgent, selectedModel);
|
|
} catch (error) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_acpx_model_unqualified",
|
|
error instanceof Error ? error.message : "Paperclip Runner ACPX requires an admitted model.",
|
|
);
|
|
}
|
|
return {
|
|
provider: "acpx",
|
|
backend: "acpx_runtime",
|
|
model: selectedModel,
|
|
acpxAgent,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Project the operator-owned adapter configuration and a qualified stored
|
|
* profile into the closed native execution descriptor. Editable adapter
|
|
* configuration can select a stored profile, but it cannot replace that
|
|
* profile's immutable remote resource identity.
|
|
*/
|
|
export function resolvePaperclipRunnerNativeProviderInput(input: {
|
|
backend: PaperclipRunnerProviderProfile["backend"];
|
|
adapterConfig: unknown;
|
|
/** Verified image CLI version for a fresh remote Codex run only. */
|
|
codexCliVersion?: string | null;
|
|
museBinding?: import("../../vendor/paperclip-runner/index.js").MuseBindingSnapshot;
|
|
dotBinding?: import("../../vendor/paperclip-runner/index.js").DotBindingSnapshot;
|
|
managedProfile?: {
|
|
id: string;
|
|
profileKey: string;
|
|
anthropicAgentId: string;
|
|
agentVersion: string;
|
|
environmentId: string;
|
|
betaVersion: string;
|
|
defaultModel: string;
|
|
defaultMaxListCostCents: number;
|
|
} | null;
|
|
agentCoreProfile?: {
|
|
id: string;
|
|
profileKey: string;
|
|
configuration: Record<string, unknown>;
|
|
} | null;
|
|
}): PaperclipRunnerNativeProviderInput {
|
|
const config = asRecord(input.adapterConfig);
|
|
const profile = resolvePaperclipRunnerProviderProfile(config);
|
|
if (config.homeMode !== undefined && config.homeMode !== "isolated" && config.homeMode !== "preconfigured") {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_home_mode_invalid", "homeMode must be isolated or preconfigured");
|
|
}
|
|
const preconfigured = config.homeMode === "preconfigured";
|
|
if (preconfigured && profile.provider !== "codex" && !(profile.provider === "acpx" && ["claude", "codex"].includes(profile.acpxAgent))) {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_home_mode_unsupported", "Preconfigured home supports native Codex and ACPX Claude only");
|
|
}
|
|
if (preconfigured && profile.provider === "acpx" && config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_home_permission_mode_unsupported", "Preconfigured home currently requires ACPX approve-all; local permission rules still apply");
|
|
}
|
|
if (preconfigured && profile.provider === "acpx" && profile.acpxAgent === "codex") {
|
|
throw new PaperclipRunnerProviderProfileError("paperclip_runner_home_state_boundary_unsupported", "preconfigured ACPX Codex cannot protect Runner state; use native Codex or isolated mode");
|
|
}
|
|
if (profile.backend !== input.backend) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_provider_changed",
|
|
"Paperclip Runner provider changed after this run selected its native backend.",
|
|
);
|
|
}
|
|
if (profile.provider === "muse") {
|
|
if (!input.museBinding || input.museBinding.bindingId !== profile.museBindingId) throw new PaperclipRunnerProviderProfileError("paperclip_runner_muse_binding_unavailable", "Muse binding must be resolved by the server after normal admission.");
|
|
return { provider: "muse", model: null, museBinding: input.museBinding };
|
|
}
|
|
if (profile.provider === "grok_bot") {
|
|
if (!input.dotBinding || input.dotBinding.bindingId !== profile.externalBindingId) throw new PaperclipRunnerProviderProfileError("paperclip_runner_grok_binding_unavailable", "The Grok binding must be resolved by the server after normal admission.");
|
|
return { provider: "grok_bot", model: null, dotBinding: input.dotBinding };
|
|
}
|
|
if (profile.provider === "openai_dot") {
|
|
if (!input.dotBinding || input.dotBinding.bindingId !== profile.dotBindingId) throw new PaperclipRunnerProviderProfileError("paperclip_runner_dot_binding_unavailable", "Dot binding must be resolved by the server after normal admission.");
|
|
return { provider: "openai_dot", model: null, dotBinding: input.dotBinding };
|
|
}
|
|
if (profile.provider === "opencode") {
|
|
return {
|
|
provider: "opencode",
|
|
model: profile.model,
|
|
opencodePermissionMode: resolvePaperclipRunnerPermissionMode(
|
|
"opencode",
|
|
config.opencodePermissionMode,
|
|
) as "allow" | "ask" | "deny",
|
|
};
|
|
}
|
|
if (profile.provider === "acpx") {
|
|
return {
|
|
provider: "acpx",
|
|
model: profile.model,
|
|
acpxAgent: profile.acpxAgent,
|
|
...(profile.acpxAgent === "claude" ? { claudeEffort: resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort) } : {}),
|
|
...(profile.acpxAgent === "pi" ? { piThinkingLevel: resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", config.piThinkingLevel) } : {}),
|
|
...(profile.acpxAgent === "cursor" ? {
|
|
acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode),
|
|
} : {}),
|
|
...(preconfigured ? { homeMode: "preconfigured" as const } : {}),
|
|
acpxPermissionMode: resolvePaperclipRunnerPermissionMode(
|
|
"acpx",
|
|
config.acpxPermissionMode,
|
|
) as "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all",
|
|
};
|
|
}
|
|
if (profile.provider === "claude_managed") {
|
|
const stored = input.managedProfile;
|
|
if (
|
|
!stored
|
|
|| (
|
|
profile.managedProfileId !== stored.id
|
|
&& profile.managedProfileId !== stored.profileKey
|
|
)
|
|
) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_profile_mismatch",
|
|
"The qualified Claude Managed profile does not match the adapter selection.",
|
|
);
|
|
}
|
|
if (stored.betaVersion !== CLAUDE_MANAGED_BETA_VERSION) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_beta_unqualified",
|
|
"The Claude Managed profile beta version is not qualified.",
|
|
);
|
|
}
|
|
const model = profile.model ?? optionalString(stored.defaultModel);
|
|
const maxSessionListCostUsd = profile.maxSessionListCostUsd
|
|
?? stored.defaultMaxListCostCents / 100;
|
|
if (!model) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_model_invalid",
|
|
"The Claude Managed profile requires a model.",
|
|
);
|
|
}
|
|
if (model !== CLAUDE_MANAGED_QUALIFIED_MODEL) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_model_unqualified",
|
|
`The Claude Managed profile requires exact model ${CLAUDE_MANAGED_QUALIFIED_MODEL}.`,
|
|
);
|
|
}
|
|
if (!Number.isFinite(maxSessionListCostUsd) || maxSessionListCostUsd <= 0) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_claude_managed_spend_cap_invalid",
|
|
"The Claude Managed profile requires a positive session spend ceiling.",
|
|
);
|
|
}
|
|
return {
|
|
provider: "claude_managed",
|
|
model,
|
|
managedProfile: {
|
|
profileId: stored.id,
|
|
anthropicAgentId: stored.anthropicAgentId,
|
|
agentVersion: stored.agentVersion,
|
|
environmentId: stored.environmentId,
|
|
betaVersion: CLAUDE_MANAGED_BETA_VERSION,
|
|
},
|
|
maxSessionListCostUsd,
|
|
};
|
|
}
|
|
if (profile.provider === "aws_agentcore") {
|
|
const stored = input.agentCoreProfile;
|
|
if (
|
|
!stored
|
|
|| (
|
|
profile.agentCoreProfileId !== stored.id
|
|
&& profile.agentCoreProfileId !== stored.profileKey
|
|
)
|
|
) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_profile_mismatch",
|
|
"The qualified AWS AgentCore profile does not match the adapter selection.",
|
|
);
|
|
}
|
|
const remote = asRecord(stored.configuration);
|
|
const required = (key: string): string => {
|
|
const value = optionalString(remote[key]);
|
|
if (!value) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_profile_invalid",
|
|
`The qualified AWS AgentCore profile is missing ${key}.`,
|
|
);
|
|
}
|
|
return value;
|
|
};
|
|
if (remote.eventExpiryDays !== 90) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_retention_unqualified",
|
|
"The qualified AWS AgentCore profile must retain Memory events for exactly 90 days.",
|
|
);
|
|
}
|
|
const maxEstimatedSessionCostUsd = profile.maxEstimatedSessionCostUsd
|
|
?? positiveNumberOrNull(
|
|
remote.defaultMaxEstimatedSessionCostUsd,
|
|
"paperclip_runner_aws_agentcore_spend_cap_invalid",
|
|
"The AWS AgentCore profile requires a positive estimated session spend ceiling.",
|
|
);
|
|
if (maxEstimatedSessionCostUsd === null) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_spend_cap_invalid",
|
|
"The AWS AgentCore profile requires a positive estimated session spend ceiling.",
|
|
);
|
|
}
|
|
const model = profile.model ?? required("defaultModel");
|
|
if (model !== AGENTCORE_QUALIFIED_MODEL) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_aws_agentcore_model_unqualified",
|
|
`The AWS AgentCore profile requires exact model ${AGENTCORE_QUALIFIED_MODEL}.`,
|
|
);
|
|
}
|
|
return {
|
|
provider: "aws_agentcore",
|
|
model,
|
|
agentCoreProfile: {
|
|
profileId: stored.id,
|
|
region: required("region"),
|
|
accountId: required("accountId"),
|
|
harnessArn: required("harnessArn"),
|
|
harnessVersion: required("harnessVersion"),
|
|
endpointArn: required("endpointArn"),
|
|
endpointQualifier: required("endpointQualifier"),
|
|
agentRuntimeArn: required("agentRuntimeArn"),
|
|
memoryArn: required("memoryArn"),
|
|
memoryId: required("memoryId"),
|
|
invocationRoleArn: required("invocationRoleArn"),
|
|
contextBucket: required("contextBucket"),
|
|
contextPrefix: required("contextPrefix"),
|
|
contextKmsKeyArn: required("contextKmsKeyArn"),
|
|
qualificationRevision: required("qualificationRevision"),
|
|
eventExpiryDays: 90,
|
|
},
|
|
maxEstimatedSessionCostUsd,
|
|
invocationLimits: {
|
|
maxIterations: boundedPositiveInteger(
|
|
config.maxIterations,
|
|
8,
|
|
8,
|
|
"paperclip_runner_aws_agentcore_max_iterations_invalid",
|
|
"AWS AgentCore maxIterations",
|
|
),
|
|
maxOutputTokens: boundedPositiveInteger(
|
|
config.maxOutputTokens,
|
|
4_096,
|
|
4_096,
|
|
"paperclip_runner_aws_agentcore_max_output_tokens_invalid",
|
|
"AWS AgentCore maxOutputTokens",
|
|
),
|
|
timeoutSeconds: boundedPositiveInteger(
|
|
config.timeoutSeconds,
|
|
300,
|
|
300,
|
|
"paperclip_runner_aws_agentcore_timeout_invalid",
|
|
"AWS AgentCore timeoutSeconds",
|
|
),
|
|
},
|
|
};
|
|
}
|
|
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
|
const allowedEfforts = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
|
|
if (effort !== undefined && effort !== null && effort !== "" && (
|
|
typeof effort !== "string" || !allowedEfforts.includes(effort)
|
|
|| (profile.model && isCodexLocalKnownModel(profile.model)
|
|
&& !codexLocalReasoningEffortsForModel(profile.model).includes(effort as "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"))
|
|
)) {
|
|
throw new PaperclipRunnerProviderProfileError(
|
|
"paperclip_runner_codex_effort_invalid",
|
|
"Paperclip Runner Codex reasoning effort is not supported for this model.",
|
|
);
|
|
}
|
|
return {
|
|
provider: "codex",
|
|
model: compatibleCodexModel(profile.model, input.codexCliVersion ?? null),
|
|
...(preconfigured ? { homeMode: "preconfigured" as const } : {}),
|
|
codexApprovalPolicy: resolvePaperclipRunnerPermissionMode(
|
|
"codex",
|
|
config.codexPermissionMode,
|
|
) as "never" | "on-request" | "untrusted",
|
|
...(typeof effort === "string" && effort ? { codexReasoningEffort: effort } : {}),
|
|
...(config.search === false ? { codexWebSearch: "disabled" as const } : {}),
|
|
};
|
|
}
|