Files
PaperClipAI/packages/plugins/sdk/src/environment-tasks.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

185 lines
12 KiB
TypeScript

import { z } from "zod";
import type { PluginEnvironmentDriverBaseParams, PluginEnvironmentLease } from "./protocol.js";
export const MAX_ENVIRONMENT_TASK_FILE_BYTES = 10 * 1024 * 1024;
const taskFilePath = z.string().min(1).max(2000).refine(value =>
!/[\\\u0000-\u001f\u007f]/u.test(value) && !/^[a-z][a-z0-9+.-]*:/iu.test(value) &&
value.replace(/^\//u, "").split("/").every(part => part !== "" && part !== "." && part !== ".."), "Expected a canonical task file path");
// Provider IDs are opaque; providers validate their own addressing constraints.
const providerId = z.string().min(1);
// PRP identity.schema.json stableId.
const runnerIdentity = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/);
const durableIdentity = z.object({ runnerInstanceId: runnerIdentity, environmentLeaseId: runnerIdentity,
runId: runnerIdentity, normalizedSessionId: runnerIdentity, turnId: runnerIdentity, itemId: runnerIdentity }).strict();
const identifier = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}$/);
const secureUrl = z.string().url().refine(value => {
const url = new URL(value);
return url.protocol === "wss:" && !url.username && !url.password && !url.search && !url.hash;
}, "Expected a credential-free WSS URL");
const credentialValue = z.string().min(1).max(65_536).refine(value => !value.includes("\0"));
/** Only provider authentication is transferred. Paths and executable settings remain provider-owned. */
export const environmentTaskCredentialSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("codex-auth-json"), authJson: credentialValue }).strict(),
z.object({ type: z.literal("claude-env"), environment: z.object({
CLAUDE_CODE_OAUTH_TOKEN: credentialValue.optional(), ANTHROPIC_API_KEY: credentialValue.optional(),
}).strict().refine(env => Object.values(env).filter(Boolean).length === 1, "Select one Claude credential") }).strict(),
]);
export type PluginEnvironmentTaskCredential = z.infer<typeof environmentTaskCredentialSchema>;
/** Runner owns this versioned data contract. The plugin resolves its execution-host locations. */
export const environmentRunnerLaunchRequirementsSchema = z.object({
schema: z.literal("paperclip.runner.launch-requirements.v1"),
identity: durableIdentity,
provider: z.enum(["codex", "opencode", "acpx", "claude_managed", "aws_agentcore"]),
model: z.string().min(1).nullable(), instructions: z.string().max(1024 * 1024),
runtimeContext: z.record(z.string(), z.unknown()).nullable(),
settings: z.object({ acpxAgent: identifier.optional(), acpxMode: z.string().optional(),
acpxPermissionMode: z.enum(["approve-all", "approve-paperclip", "approve-reads", "deny-all"]).optional(),
acpxPermissionModePinned: z.boolean().optional(), piThinkingLevel: z.enum(["off", "low", "high", "max"]).optional(),
claudeEffort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(),
opencodePermissionMode: z.enum(["allow", "ask", "deny"]).optional(),
disableCodexWebSearch: z.literal(true).optional(),
managedProfile: z.record(z.string(), z.unknown()).optional(), agentCoreProfile: z.record(z.string(), z.unknown()).optional(),
}).strict(),
approvalPolicy: z.enum(["never", "on-request", "untrusted"]), readOnly: z.boolean(),
preparedConversation: z.boolean(), includeCollaborationModeInstructions: z.boolean(),
}).strict();
const preparedLaunch = z.object({ configurationId: providerId.max(1024),
requirementsDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/),
provider: z.record(z.string(), z.unknown()),
}).strict();
/** One plugin-provided Runner execution attempt with a PRP connection. Secrets are transient RPC input. */
export const environmentTaskOperationSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("describe") }).strict(),
z.object({ kind: z.literal("copilot_metadata"), token: credentialValue, model: z.string().min(1).max(1024).optional() }).strict(),
z.object({ kind: z.literal("prepare"), requirements: environmentRunnerLaunchRequirementsSchema,
recovery: preparedLaunch.optional(), resumeFrom: providerId.optional(),
priorIdentity: durableIdentity.optional(), accountIdentity: z.string().min(1).max(1024).optional(),
projectIds: z.array(z.string().uuid()).max(64).refine(ids => new Set(ids).size === ids.length, "Duplicate project IDs"),
}).strict(),
z.object({
kind: z.literal("submit"),
projectIds: z.array(z.string().uuid()).max(64).refine(ids => new Set(ids).size === ids.length, "Duplicate project IDs"),
runner: z.object({
/** Inclusive PRP version range supported by the submitting client. */
protocolMin: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
protocolMax: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
harness: identifier,
runnerId: runnerIdentity, leaseId: runnerIdentity, runId: runnerIdentity,
sessionId: runnerIdentity, turnId: runnerIdentity, itemId: runnerIdentity,
}).strict().refine(value => value.protocolMin <= value.protocolMax, "Invalid PRP version range"),
bootstrapTicket: z.string().min(1).max(65_536),
/** Opaque prior provider task; Core authorizes the saved session before supplying it. */
resumeFrom: z.string().min(1).optional(),
preparedLaunchId: providerId.max(1024).optional(),
/** Require the externally installed account to match this Core-selected credential revision. */
credentialIdentity: z.string().min(1).max(1024).optional(),
/** Stable account selection; distinct from its current credential revision. */
accountIdentity: z.string().min(1).max(1024).optional(),
credential: environmentTaskCredentialSchema.optional(),
/** Core supplies these protected launch inputs for native agent runs. */
launch: z.object({
runnerVersion: z.string().min(1), runnerDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/),
maxOutboxBytes: z.number().int().positive(), p0ReserveBytes: z.number().int().positive(),
maxRuntimeMs: z.number().int().nonnegative().optional(), maxLifetimeMs: z.number().int().nonnegative().optional(),
reconnectGraceMs: z.number().int().nonnegative().optional(),
lifecyclePolicy: z.discriminatedUnion("mode", [
z.object({ mode: z.literal("per_turn"), idleTimeoutMs: z.null() }).strict(),
z.object({ mode: z.literal("warm"), idleTimeoutMs: z.number().int().positive().max(86_400_000) }).strict(),
]),
}).strict().optional(),
}).strict(),
z.object({ kind: z.literal("check_project_auth"), configurationId: providerId.max(1024),
provider: identifier, projectIds: z.array(z.string().uuid()).max(64)
.refine(ids => new Set(ids).size === ids.length, "Duplicate project IDs") }).strict(),
z.object({ kind: z.literal("read_file"), identity: durableIdentity,
path: taskFilePath, maxBytes: z.number().int().positive().max(MAX_ENVIRONMENT_TASK_FILE_BYTES),
projectIds: z.array(z.string().uuid()).max(64).refine(ids => new Set(ids).size === ids.length, "Duplicate project IDs"),
}).strict(),
z.object({ kind: z.literal("read_state"), identity: durableIdentity, resumeFrom: providerId.optional() }).strict(),
z.object({ kind: z.literal("archive_state"), priorIdentity: durableIdentity,
archiveKey: z.string().regex(/^[a-f0-9]{24}$/), resumeFrom: providerId }).strict(),
z.object({ kind: z.literal("credentials"), accountIdentity: z.string().min(1).max(1024) }).strict(),
z.object({ kind: z.literal("status") }).strict(),
z.object({ kind: z.literal("connection") }).strict(),
z.object({ kind: z.literal("complete") }).strict(),
z.object({ kind: z.literal("stop") }).strict(),
]);
export const environmentTaskResultSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("accepted"), taskId: providerId }).strict(),
z.object({ kind: z.literal("copilot_metadata"), taskId: providerId, metadata: z.record(z.string(), z.unknown()),
executionStopped: z.literal(true) }).strict(),
z.object({ kind: z.literal("project_auth"), taskId: providerId, configurationId: providerId.max(1024),
provider: identifier, allowed: z.boolean() }).strict(),
z.object({ kind: z.literal("file"), taskId: providerId,
base64: z.string().max(4 * Math.ceil(MAX_ENVIRONMENT_TASK_FILE_BYTES / 3)),
byteSize: z.number().int().nonnegative().max(MAX_ENVIRONMENT_TASK_FILE_BYTES),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
}).strict(),
z.object({ kind: z.literal("state"), taskId: providerId, state: z.record(z.string(), z.unknown()) }).strict(),
z.object({ kind: z.literal("prepared"), taskId: providerId, launch: preparedLaunch, projectAuthCheckAtSubmit: z.literal(true).optional() }).strict(),
z.object({ kind: z.literal("credentials"), taskId: providerId, /** Null proves that cancellation fenced admission before the task could start. It must not mean an unavailable export. */
credential: environmentTaskCredentialSchema.nullable() }).strict(),
z.object({ kind: z.literal("description"), taskId: providerId,
runner: z.object({ version: z.string().min(1), digest: z.string().regex(/^sha256:[a-f0-9]{64}$/) }).strict(),
warmTaskVersion: z.literal(1).optional(),
launchPreparationVersion: z.literal(1).optional(),
freshWorkspaceVersion: z.literal(1).optional(),
projectAuthCheckVersion: z.literal(1).optional(),
workspaceFileReadVersion: z.literal(1).optional(),
}).strict(),
z.object({
kind: z.literal("status"), taskId: providerId,
phase: z.enum(["preparing", "running", "completed", "failed", "cancelled", "interrupted"]),
exitCode: z.number().int().optional(),
/** Provider observed the task and all descendants stopped; terminal phase alone is insufficient. */
executionStopped: z.boolean().optional(),
/** The provider retains this task's recoverable session state for a later launch. */
sessionStateAvailable: z.boolean().optional(),
}).strict(),
z.object({
kind: z.literal("connection"), taskId: providerId,
endpoint: z.object({
kind: z.literal("authenticated_websocket"), websocketUrl: secureUrl,
generation: providerId,
secretHeaders: z.array(z.object({
name: z.string().regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/),
value: z.string().min(1).max(65_536).regex(/^[^\r\n]+$/),
}).strict()).max(16),
}).strict(),
}).strict(),
]);
export type PluginEnvironmentRunnerLaunchRequirements = z.infer<typeof environmentRunnerLaunchRequirementsSchema>;
export type PluginEnvironmentPreparedLaunch = z.infer<typeof preparedLaunch>;
export type PluginEnvironmentTaskOperation = z.infer<typeof environmentTaskOperationSchema>;
export type PluginEnvironmentTaskResult = z.infer<typeof environmentTaskResultSchema>;
export interface PluginEnvironmentTaskParams extends Omit<PluginEnvironmentDriverBaseParams, "environmentId"> {
/** Null during cleanup after the environment is deleted. */
environmentId: string | null;
lease: PluginEnvironmentLease;
/** Provider-issued task identifier persisted in the lease, stable across ambiguous submission retries. */
taskId: string;
/** Null during cleanup after the associated run is deleted. */
runId: string | null;
agentId: string | null;
/** Company-validated projects to prepare on submit; empty during cleanup. */
projectIds: string[];
workspace?: import("@paperclipai/shared").WorkspaceProviderBinding;
fresh?: import("./workspace-provider.js").PluginFreshWorkspace;
operation: PluginEnvironmentTaskOperation;
}
/** An accepted stop is not proof of process termination. */
export function parseEnvironmentTaskResult(operation: PluginEnvironmentTaskOperation, taskId: string, value: unknown): PluginEnvironmentTaskResult {
const result = environmentTaskResultSchema.parse(value);
const expected = operation.kind === "read_file" ? "file" : operation.kind === "describe" ? "description" : operation.kind === "prepare" ? "prepared" : operation.kind === "check_project_auth" ? "project_auth" : ["read_state", "archive_state"].includes(operation.kind) ? "state" : ["status", "connection", "credentials", "copilot_metadata"].includes(operation.kind) ? operation.kind : "accepted";
if (result.taskId !== taskId || result.kind !== expected) throw new Error("Invalid plugin-provided Runner execution response");
return result;
}