Files
PaperClipAI/server/src/services/native-runtime/native-execution-input.ts
T
DottaandPaperclip 5823e9a664 fix(runner): preserve disabled Codex web search (#17744)
## 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>
2026-10-26 10:29:51 -05:00

412 lines
20 KiB
TypeScript

import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils";
import { resolvePaperclipRunnerCursorMode, resolvePaperclipRunnerPiThinkingLevel } from "@paperclipai/adapter-utils";
import { createHash } from "node:crypto";
import { buildNativeContinuationPrompt } from "./native-continuation.js";
import {
caseExecutionMarkdown,
caseProjectionBootstrapPrefix,
caseWakeWithInstructionReference,
projectCaseContinuation,
readCaseProjectionMarker,
type CaseExecutionContext,
} from "../case-context.js";
import type {
NativeAcpxAgent,
NativeAcpxPermissionMode,
NativeCodexApprovalPolicy,
NativeExecutionInputV5,
NativeExecutionInputV6,
NativeExecutionInputV8,
NativeExecutionInputV9,
MuseBindingSnapshot,
DotBindingSnapshot,
NativeExecutionInputV7,
NativeCompletionSource,
NativeInteractionResponseEnvelope,
NativeOpenCodePermissionMode,
NativePlanningContext,
NativeRuntimeContextSnapshot,
StrictCompletionContractInput,
} from "../../vendor/paperclip-runner/index.js";
import {
parseNativeExecutionInput,
resolveQualifiedAcpxProfile,
} from "../../vendor/paperclip-runner/index.js";
import {
isPaperclipExternalChatContractTurn,
isPaperclipExternalChatQuestionResponseTurn,
selectPaperclipPromptSections,
} from "@paperclipai/adapter-utils/server-utils";
const NATIVE_GITHUB_ATTACHMENT_RECOVERY_GUIDANCE = [
"## GitHub attachment recovery navigation",
"Paperclip owns recovery navigation for unavailable GitHub attachments. It may append an authenticated task link after an accepted response, only when the current source remains authorized and a safe configured Board URL is available. The model does not select or authorize that link.",
"A task URL missing from your prompt or tool results is not evidence that no task link can be provided; do not claim that a link is unavailable merely because you cannot see its URL. Do not invent a URL or promise that a link will appear. Briefly explain the unavailable input and ask the user to attach it directly to this Paperclip task or paste the needed text. Never infer the file's contents or substitute an older file.",
].join("\n");
/** Closed constructor: callers cannot spread legacy context or environment data. */
export interface BuildNativeExecutionInput {
companyId: string;
runId: string;
issue: {
id: string;
identifier: string | null;
title: string;
description: string | null;
workMode: string;
};
taskPrompt: string;
initialCommunicationGuidance?: string | null;
/** Bounded, redacted background restored only after a fresh provider bootstrap. */
freshSessionHandoff?: string | null;
/**
* The already-sanitized Paperclip wake envelope for this run. Native drivers
* receive a closed execution input rather than the legacy adapter context,
* so the constructor must deliberately project the same bounded wake delta
* that legacy adapters place in their provider prompt.
*/
wakePayload?: unknown;
/** Additive source ownership emitted by the server task/wake builders. */
turnContext?: unknown;
/** Skill keys selected from the admitted, manager-authored GitHub configuration. */
githubInstructionSkillKeys?: readonly string[];
resumedSession?: boolean;
previousTurn?: {
runId: string;
task: { title: string; description: string | null };
githubReviewConfigurationKeys?: string[];
nativeExecutionInput?: {
binding: { companyId: string; runId: string; issueId: string; agentId: string };
session: { normalizedSessionId: string | null };
task: { prompt: string };
continuationPrompt?: string | null;
};
} | null;
/** Fresh, authorized and redacted database context from task preparation. */
caseContext?: CaseExecutionContext | null;
conversationMode?: boolean;
agentId: string;
agentKeyId?: string;
workspace: {
id: string;
cwd: string;
repoUrl: string | null;
repoRef: string | null;
branchName: string | null;
};
normalizedSessionId: string | null;
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot" | "muse";
museBinding?: MuseBindingSnapshot;
dotBinding?: DotBindingSnapshot;
acpxAgent?: NativeAcpxAgent;
homeMode?: "isolated" | "preconfigured";
codexApprovalPolicy?: NativeCodexApprovalPolicy;
codexReasoningEffort?: string;
codexWebSearch?: "disabled";
opencodePermissionMode?: NativeOpenCodePermissionMode;
acpxPermissionMode?: NativeAcpxPermissionMode;
acpxSessionMode?: "agent" | "plan" | "ask";
piThinkingLevel?: "off" | "low" | "high" | "max";
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
model?: string | null;
managedProfile?: Extract<
NativeExecutionInputV5["provider"],
{ kind: "claude_managed" }
>["managedProfile"];
maxSessionListCostUsd?: number;
agentCoreProfile?: Extract<
NativeExecutionInputV5["provider"],
{ kind: "aws_agentcore" }
>["agentCoreProfile"];
maxEstimatedSessionCostUsd?: number;
invocationLimits?: Extract<
NativeExecutionInputV5["provider"],
{ kind: "aws_agentcore" }
>["invocationLimits"];
lifecyclePolicy?: NativeExecutionInputV5["session"]["lifecyclePolicy"];
executionMode?: "default" | "plan";
planningContext?: NativePlanningContext | null;
interactionResponses?: NativeInteractionResponseEnvelope[];
completionContract: {
id: string;
sha256: string;
schemaVersion: string;
contract: StrictCompletionContractInput;
sources?: Array<{ id: string; source: NativeCompletionSource }>;
};
runtimeContext: NativeRuntimeContextSnapshot;
}
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "muse"; museBinding: MuseBindingSnapshot }): NativeExecutionInputV8;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot" | "grok_bot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7 | NativeExecutionInputV9;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9 {
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
}
const mode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode);
const piThinkingLevel = resolvePaperclipRunnerPiThinkingLevel(input.provider, input.acpxAgent, input.piThinkingLevel);
const executionMode = input.executionMode
?? (input.issue.workMode === "planning" ? "plan" : "default");
const acpxProfile = input.provider === "acpx"
? resolveQualifiedAcpxProfile(
input.acpxAgent ?? "codex",
input.model ?? "",
)
: null;
// Answers are materialized from the authoritative interaction only for this
// invocation; do not persist a duplicate answer in the durable wake snapshot.
const wake =
input.wakePayload &&
typeof input.wakePayload === "object" &&
!Array.isArray(input.wakePayload)
? (input.wakePayload as Record<string, unknown>)
: null;
const question = wake?.externalChatQuestionResponse
? input.interactionResponses?.find(
(response) =>
response.interactionId === wake.interactionId &&
response.kind === "ask_user_questions" &&
response.response.status === "answered",
)
: null;
const answerResult = question?.response.result as
Record<string, unknown> | undefined;
// The server supplies only the revalidated answer chain, in source order.
// Keep prior choices available even when this continuation starts a fresh
// provider session; never recover them from model prose or a transcript.
const answerChain = question
? input.interactionResponses?.filter((response) =>
response.kind === "ask_user_questions" &&
response.response.status === "answered" &&
typeof (response.response.result as Record<string, unknown> | undefined)
?.summaryMarkdown === "string")
: null;
const answerSummary =
answerChain && answerChain.length > 1 &&
answerChain.at(-1)?.interactionId === question?.interactionId
? answerChain.map((response, index) => {
const label = index === answerChain.length - 1
? "Latest answered question" : `Earlier answer ${index + 1}`;
return `${label}:\n${(response.response.result as Record<string, unknown>).summaryMarkdown}`;
}).join("\n\n")
: answerResult?.summaryMarkdown;
const wakePayload =
question && typeof answerSummary === "string"
? {
...wake,
questionResponse: {
interactionId: question.interactionId,
summaryMarkdown: answerSummary,
},
}
: input.wakePayload;
const bootstrapWakePayload = caseWakeWithInstructionReference(wakePayload, input.caseContext);
// Build the full bootstrap through the same owner as legacy adapters.
// Verified native resume selection stays at the existing session boundary.
const caseMarkdown = caseExecutionMarkdown(input.caseContext ?? null);
const assignmentPrompt = caseMarkdown && !input.taskPrompt.includes(caseMarkdown)
? caseMarkdown + input.taskPrompt : input.taskPrompt;
const { taskContextNote, wakePrompt } = selectPaperclipPromptSections({
paperclipTaskMarkdownAssignment: assignmentPrompt,
paperclipWake: bootstrapWakePayload,
conversationMode: input.conversationMode,
}, {
resumedSession: false,
includeCommunicationGuidance: false,
nativeWakeReaderAvailable: true,
});
const externalChatTurn =
isPaperclipExternalChatContractTurn(wakePayload) ||
isPaperclipExternalChatQuestionResponseTurn(wakePayload);
const configuredGitHubSkillKeys = new Set(input.githubInstructionSkillKeys ?? []);
const configuredGitHubSkills = externalChatTurn && wake?.externalChatProvider === "github"
? input.runtimeContext.skills.filter((skill) => configuredGitHubSkillKeys.has(skill.key))
: [];
// Native skill invocation reads task.description. Project only verified skill
// names here; never restore the first provider message or scan untrusted text.
const externalChatSkillDescription = configuredGitHubSkills.length > 0
? `Configured GitHub instruction skills:\n${configuredGitHubSkills.map((skill) => `/${skill.runtimeName}`).join("\n")}`
: null;
const taskPrompt = caseProjectionBootstrapPrefix(input.caseContext) + [
wakePrompt,
// Durable task questions must survive the current provider turn.
// Keep routing visible before deferred tool discovery; usage belongs in the tool schema.
"Use Paperclip's request_human_input for durable task questions.",
externalChatTurn && wake?.externalChatProvider === "github"
? NATIVE_GITHUB_ATTACHMENT_RECOVERY_GUIDANCE
: "",
taskContextNote,
]
.filter((section) => section.length > 0)
.join("\n\n");
const completionSources = !externalChatTurn && !input.conversationMode && input.taskPrompt.trim()
? verifiedCompletionSources(input.turnContext, input.completionContract.sources ?? [])
: [];
const previousExecution = input.previousTurn?.nativeExecutionInput;
const previousCaseProjection = previousExecution && input.previousTurn && input.normalizedSessionId &&
previousExecution.binding.companyId === input.companyId &&
previousExecution.binding.agentId === input.agentId &&
previousExecution.binding.issueId === input.issue.id &&
previousExecution.binding.runId === input.previousTurn.runId &&
previousExecution.session.normalizedSessionId === input.normalizedSessionId
? readCaseProjectionMarker(previousExecution) : null;
const workflowCase = input.caseContext && previousCaseProjection
? projectCaseContinuation(input.caseContext, previousCaseProjection) : null;
const prepared = {
schema: input.claudeEffort !== undefined ? "paperclip.native-execution-input.v9"
: input.homeMode === "preconfigured" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5",
...(input.claudeEffort !== undefined || input.homeMode === "preconfigured" ? { homeMode: input.homeMode ?? "isolated" } : {}),
...((input.initialCommunicationGuidance || input.freshSessionHandoff) ? {
initialCommunicationGuidance: [input.initialCommunicationGuidance, input.freshSessionHandoff].filter(Boolean).join("\n\n"),
} : {}),
...(input.resumedSession && input.previousTurn && !input.conversationMode ? {
continuationPrompt: buildNativeContinuationPrompt({
wakePayload: input.wakePayload,
previousRunId: input.previousTurn.runId,
previousIssue: input.previousTurn.task,
previousGitHubReviewConfigurationKeys: input.previousTurn.githubReviewConfigurationKeys,
allowExternalChat: true,
issue: input.issue,
// A stored paperclipCase alone does not prove that the provider ever
// received it. Old/unknown projections take the full bootstrap path.
caseContext: input.caseContext,
workflowCase,
}),
} : {}),
executionMode,
planningContext: input.planningContext ?? null,
binding: {
companyId: input.companyId,
runId: input.runId,
issueId: input.issue.id,
agentId: input.agentId,
...(input.agentKeyId ? { agentKeyId: input.agentKeyId } : {}),
executionWorkspaceId: input.workspace.id,
},
task: {
identifier: input.issue.identifier ?? input.issue.id,
// The issue title is durable background context and may itself contain an
// exact-output instruction from the thread's first message. Repeating it
// as the native turn title can override a newer provider message in small
// models. Keep the canonical title and description in task.prompt as
// explicitly labeled background, but give authenticated external-chat
// turns neutral structured fields.
title: externalChatTurn ? "External chat follow-up" : input.issue.title,
description: externalChatTurn ? externalChatSkillDescription : input.issue.description,
prompt: taskPrompt,
workMode: input.issue.workMode,
},
workspace: {
cwd: input.workspace.cwd,
repoUrl: input.workspace.repoUrl,
repoRef: input.workspace.repoRef,
branchName: input.workspace.branchName,
},
session: {
normalizedSessionId: input.normalizedSessionId,
driverKind: input.provider === "opencode"
? "opencode_server"
: input.provider === "claude_managed"
? "claude_managed_agents_api"
: input.provider === "aws_agentcore"
? "aws_agentcore_harness_api"
: input.provider === "acpx"
? "acpx_runtime"
: "codex_app_server",
protocolVersion: 1,
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
},
provider: input.provider === "claude_managed"
? {
kind: "claude_managed",
model: input.model,
managedProfile: input.managedProfile,
maxSessionListCostUsd: input.maxSessionListCostUsd,
}
: input.provider === "aws_agentcore"
? {
kind: "aws_agentcore",
model: input.model,
agentCoreProfile: input.agentCoreProfile,
maxEstimatedSessionCostUsd: input.maxEstimatedSessionCostUsd,
invocationLimits: input.invocationLimits,
}
: input.provider === "acpx"
? {
kind: "acpx",
agent: acpxProfile!.agent,
model: input.model,
permissionMode: input.acpxPermissionMode ?? "approve-all",
...(mode === undefined ? {} : { mode }),
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
...(input.claudeEffort === undefined ? {} : { claudeEffort: input.claudeEffort }),
profile: {
driverKind: acpxProfile!.driverKind,
protocolVersion: acpxProfile!.protocolVersion,
acpxVersion: acpxProfile!.acpxVersion,
agent: acpxProfile!.agent,
agentProfileVersion: acpxProfile!.agentProfileVersion,
agentServerPackage: acpxProfile!.agentServerPackage,
agentServerVersion: acpxProfile!.agentServerVersion,
agentRuntimePackage: acpxProfile!.agentRuntimePackage,
agentRuntimeVersion: acpxProfile!.agentRuntimeVersion,
commandDigest: acpxProfile!.commandDigest,
},
}
: input.provider === "opencode"
? {
kind: "opencode",
model: input.model,
permissionMode: input.opencodePermissionMode ?? "allow",
}
: {
kind: "codex",
model: input.model ?? null,
approvalPolicy: input.codexApprovalPolicy ?? "never",
...(input.codexReasoningEffort ? { reasoningEffort: input.codexReasoningEffort } : {}),
...(input.codexWebSearch ? { webSearch: input.codexWebSearch } : {}),
},
completionContract: {
id: input.completionContract.id,
sha256: input.completionContract.sha256,
schemaVersion: input.completionContract.schemaVersion,
contract: input.completionContract.contract,
},
...(completionSources.length ? { completionSources: {
promptSha256: createHash("sha256").update(taskPrompt).digest("hex"),
contractRevision: input.completionContract.contract.revision,
criteria: completionSources,
} } : {}),
interactionResponses: input.interactionResponses ?? [],
credentialBindings: [],
runtimeContext: input.runtimeContext,
};
return parseNativeExecutionInput(input.provider === "muse" ? { ...prepared,
schema: "paperclip.native-execution-input.v8", provider: { kind: "muse", model: null, binding: input.museBinding, bridgeRevision: "muse-v1" },
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
session: { ...prepared.session, driverKind: "muse_external", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
} : ["openai_dot", "grok_bot"].includes(input.provider ?? "") ? { ...prepared,
schema: "paperclip.native-execution-input.v6", provider: { kind: input.provider, model: null, binding: input.dotBinding },
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
session: { ...prepared.session, driverKind: input.provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9;
}
/** Verify source identities and revisions, never guess provenance from requirement text. */
function verifiedCompletionSources(
value: unknown,
sources: Array<{ id: string; source: NativeCompletionSource }>,
): Array<{ id: string; source: NativeCompletionSource }> {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const context = value as Partial<PaperclipTurnContext>;
if (context.version !== 1) return [];
return sources.filter(({ source }) => {
if (source.kind === "description") {
return context.assignment?.owner === "task_markdown" && context.assignment.description?.id === source.id && context.assignment.description.revision === source.revision;
}
return context.events?.owner === "wake_prompt" && Array.isArray(context.events.comments) && context.events.comments.some((comment) => comment.id === source.id && comment.revision === source.revision);
});
}