Allow authorized Paperclip actions with native provider defaults

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-19 12:05:39 -05:00
1 parent da31d69f2e
commit c0cb095f84
31 files changed
+176 -63

No files matched your search

+7 -5
View File
@@ -1022,7 +1022,7 @@ Native Codex is qualified only with `codexPermissionMode: "never"`. The create
and edit surfaces do not offer `on-request` or `untrusted`, and a persisted
unsupported value fails with remediation instead of being silently coerced.
OpenCode retains `allow`, `ask`, and `deny`; ACPX retains `approve-all`,
`approve-reads`, and `deny-all`. Codex conversion keeps a non-empty model and
`approve-paperclip` (the default), `approve-reads`, and `deny-all`. Codex conversion keeps a non-empty model and
otherwise stores the shared `gpt-5.6-sol` default. The native execution boundary
applies the same default to older runner rows whose model is missing or blank.
@@ -1030,10 +1030,12 @@ For an Agent Chat test drive, enable **Agent Chat** in Experimental settings and
configure two agents with Paperclip Runner: native Codex and ACPX Claude. Connect
the Claude account through the agent's **AI connection** section (or supply an
explicit supported provider credential); an ambient Claude CLI login alone is
not a credential source for its isolated runner home. The conservative ACPX
permission mode fails closed when no coordinator decision is available, including
for MCP tools. Use `approve-all` only for agents authorized to perform the test's
tool actions; the server still enforces company and action permissions.
not a credential source for its isolated runner home. The default
`approve-paperclip` setting lets assigned Paperclip actions run automatically;
company permissions and approval gates still apply. Explicit `approve-reads` or
`deny-all` settings remain restrictive. Unassigned, external, and provider-native
operations still require permission; do not select `approve-all` merely to save
plans, create tasks, or reassign work.
Test questions, saved plan revisions, approval before task handoff, status
lookups, and `/new` preserving chat history. Hiring additionally requires the
@@ -16,15 +16,19 @@ describe("Paperclip Runner permission defaults", () => {
expect(resolvePaperclipRunnerPermissionMode("codex", "untrusted")).toBe("never");
});
it("uses interactive defaults for dormant non-Codex providers", () => {
it("defaults Claude to authorized Paperclip actions and OpenCode to ask", () => {
expect(resolvePaperclipRunnerPermissionMode("opencode", undefined)).toBe(
"ask",
);
expect(resolvePaperclipRunnerPermissionMode("acpx", undefined)).toBe(
"approve-reads",
"approve-paperclip",
);
});
it.each(["approve-paperclip", "approve-reads", "deny-all", "approve-all"])("preserves explicit Claude %s settings", (mode) => {
expect(resolvePaperclipRunnerPermissionMode("acpx", mode)).toBe(mode);
});
it("recognizes only exact provider identifiers", () => {
expect(isPaperclipRunnerProvider("codex")).toBe(true);
expect(isPaperclipRunnerProvider("opencode")).toBe(true);
@@ -3,7 +3,7 @@ export type PaperclipRunnerProvider =
export type CodexPermissionMode = "never" | "on-request" | "untrusted";
export type OpenCodePermissionMode = "allow" | "ask" | "deny";
export type AcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all";
export type AcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
export type PaperclipRunnerPermissionMode =
CodexPermissionMode | OpenCodePermissionMode | AcpxPermissionMode;
@@ -106,7 +106,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
acpx: {
configurable: true,
configKey: "acpxPermissionMode",
defaultMode: "approve-reads",
defaultMode: "approve-paperclip",
description:
"Controls ACPX agent operations inside the assigned Paperclip environment.",
options: [
@@ -115,6 +115,12 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
label: "Full auto (approve all)",
description: "Approve ACPX operations without approval pauses.",
},
{
value: "approve-paperclip",
label: "Automatic Paperclip actions",
description:
"Automatically run assigned Paperclip tools, including plans, tasks, and reassignment. Company permissions and approval requirements still apply. Other operations require permission.",
},
{
value: "approve-reads",
label: "Allow Paperclip reads",
+15 -8
View File
@@ -69,14 +69,21 @@ alias during selection and verification. Users can keep selecting models from
the normal Claude catalog or entering custom IDs; unavailable models still fail
at the provider rather than silently falling back.
For ACPX Claude, `approve-reads` is shown as **Allow Paperclip reads**. The host
intersects the run's public tools with the implementation catalog's read effects
and writes exact MCP permission rules into the isolated Claude settings. The
`paperclip` connection is always the runner's authenticated tool bridge; ambient
MCP configuration is excluded. Tool hints and provider permission metadata cannot
grant access. Unassigned tools, writes, external tools, and provider-native
operations do not receive automatic read permission. Protocol completion and
task-delivery controls keep their existing separate allowance.
ACPX Claude defaults to `approve-paperclip`, shown as **Automatic Paperclip
actions**. The host intersects the run's assigned public tools with the known
Paperclip action catalog and writes exact MCP permission rules into isolated
Claude settings. Plans, task creation, reassignment, and other assigned actions
can execute without a second provider permission prompt. The controller still
checks company access, action claims, task modes, and governed approvals on every
call. A tool's presence does not grant permission to act on another company's data.
The `paperclip` connection is always the runner's authenticated tool bridge;
ambient MCP configuration is excluded. Tool hints and provider permission
metadata cannot grant access. Unassigned tools, external connections, and
provider-native shell or filesystem operations receive no automatic permission.
Explicit `approve-reads` (**Allow Paperclip reads**) and `deny-all` settings retain
their restrictive behavior. Existing saved settings are not migrated. Protocol
completion and task-delivery controls keep their separate allowance.
This runtime has no interactive permission handler. An operation that still
requires approval stops the turn with `approval_required`. The server marks the
@@ -11,7 +11,7 @@ identity, isolation behavior, and conformance coverage are defined.
|---|---|---|---|
| Codex | `codexPermissionMode` | `never`, `on-request`, `untrusted` | `never` |
| OpenCode | `opencodePermissionMode` | `allow`, `ask`, `deny` | `allow` |
| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-reads`, `deny-all` | `approve-reads` |
| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-paperclip`, `approve-reads`, `deny-all` | `approve-paperclip` |
The browser-safe source of truth for labels, defaults, and configuration
validation is `PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES` in
@@ -29,6 +29,7 @@ const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[serde(rename_all = "kebab-case")]
pub enum AcpxPermissionMode {
ApproveAll,
ApprovePaperclip,
ApproveReads,
DenyAll,
}
@@ -1306,3 +1307,28 @@ mod tests {
assert!(sidecar_tools[0].get("operationId").is_none());
}
}
#[cfg(test)]
mod permission_mode_tests {
use super::AcpxPermissionMode;
#[test]
fn paperclip_permission_mode_round_trips_without_widening_legacy_modes() {
for (name, mode) in [
("approve-paperclip", AcpxPermissionMode::ApprovePaperclip),
("approve-reads", AcpxPermissionMode::ApproveReads),
("approve-all", AcpxPermissionMode::ApproveAll),
("deny-all", AcpxPermissionMode::DenyAll),
] {
let value = serde_json::json!(name);
assert_eq!(
serde_json::from_value::<AcpxPermissionMode>(value.clone()).unwrap(),
mode
);
assert_eq!(serde_json::to_value(mode).unwrap(), value);
}
assert!(
serde_json::from_value::<AcpxPermissionMode>(serde_json::json!("unknown")).is_err()
);
}
}
@@ -1133,13 +1133,14 @@ function requiredPermissionMode(
): AcpxSidecarOpenParams["permissionMode"] {
if (
value === "approve-all" ||
value === "approve-paperclip" ||
value === "approve-reads" ||
value === "deny-all"
) {
return value;
}
throw new Error(
"permissionMode must be approve-all, approve-reads, or deny-all",
"permissionMode must be approve-all, approve-paperclip, approve-reads, or deny-all",
);
}
@@ -453,7 +453,7 @@ export interface AcpxSessionIdentity {
requestedModel: string;
effectiveModel: string;
/** Missing on legacy snapshots; those used the historical approve-reads behavior. */
permissionMode?: "approve-all" | "approve-reads" | "deny-all";
permissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
providerLifetimeFenceCandidates: readonly [number, number, number];
}
@@ -80,7 +80,7 @@ export interface NativeAwsAgentCoreProfileSnapshot {
export type NativeAcpxAgent = "pi" | "claude" | "codex";
export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted";
export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny";
export type NativeAcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all";
export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
export interface NativeAcpxProfileSnapshot {
driverKind: "acpx_runtime";
@@ -534,8 +534,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, or codex");
}
if (isV4) {
if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") {
throw new NativeExecutionInputError("input.provider.permissionMode must be approve-all, approve-reads, or deny-all");
if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-paperclip" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") {
throw new NativeExecutionInputError("input.provider.permissionMode must be approve-all, approve-paperclip, approve-reads, or deny-all");
}
} else if (provider.permissionPolicy !== "interactive") {
throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive");
@@ -435,7 +435,7 @@ export class CodexAcpxDriver implements HarnessDriver {
workingDirectory: input.workingDirectory,
agent: this.#options.agent ?? "codex",
model: this.#options.model,
permissionMode: this.#options.permissionMode ?? "approve-reads",
permissionMode: this.#options.permissionMode ?? "approve-paperclip",
systemInstructions: this.#options.systemInstructions,
environment: this.#options.environment,
managedCodexCredentialSourcePath:
@@ -1903,7 +1903,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void {
!/^sha256:[a-f0-9]{64}$/.test(identity.profileDigest) ||
!/^sha256:[a-f0-9]{64}$/.test(identity.workspaceDigest) ||
(identity.permissionMode !== undefined &&
!["approve-all", "approve-reads", "deny-all"].includes(
!["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(
identity.permissionMode,
)) ||
!validProviderLifetimeFenceCandidates(
@@ -28,7 +28,7 @@ const HANDLE: AcpRuntimeHandle = {
};
describe("Codex ACPX runtime adapter", () => {
it("stops a turn with an actionable error when approval has no handler", async () => {
it.each(["approve-reads", "approve-paperclip"] as const)("%s stops unassigned operations when approval has no handler", async (permissionMode) => {
const runtime = fakeRuntime();
let runtimeOptions: AcpRuntimeOptions | undefined;
let signal: AbortSignal | undefined;
@@ -47,12 +47,14 @@ describe("Codex ACPX runtime adapter", () => {
} as ReturnType<AcpRuntime["startTurn"]>;
});
const options = openOptions(fakeCommand());
options.permissionMode = "approve-reads";
options.permissionMode = permissionMode;
const port = await openCodexAcpxRuntime(options, {
createRegistry: () => registry(),
createStore: () => store(),
createRuntime: (created) => { runtimeOptions = created; return runtime; },
});
expect(runtimeOptions!.permissionMode).toBe("approve-reads");
expect(runtimeOptions!.permissionPolicy).toMatchObject({ defaultAction: "escalate" });
const turn = port.startTurn({ text: "Attempt a write.", requestId: "permission-test" });
try {
await runtimeOptions!.onPermissionRequest!({
@@ -1635,6 +1637,7 @@ describe("Codex ACPX runtime adapter", () => {
["codex", "gpt-5.6-sol", "deny-all", { outcome: "reject_once" }],
["claude", "claude-sonnet-5", "approve-all", { outcome: "allow_once" }],
["claude", "claude-sonnet-5", "approve-reads", { outcome: "reject_once" }],
["claude", "claude-sonnet-5", "approve-paperclip", { outcome: "reject_once" }],
["claude", "claude-sonnet-5", "deny-all", { outcome: "reject_once" }],
] as const)(
"applies the %s/%s ACPX profile's %s mode without an implicit prompt bridge",
@@ -277,7 +277,11 @@ export async function openQualifiedAcpxRuntime(
? "/paperclip-verified/claude-agent-acp"
: VERIFIED_COMMAND_SENTINEL] },
}),
permissionMode: options.permissionMode,
// ACPX does not know the Paperclip-specific mode. Exact SDK rules allow
// admitted actions; all remaining requests keep its closed read policy.
permissionMode: options.permissionMode === "approve-paperclip"
? "approve-reads"
: options.permissionMode,
elicitationModes: ["form"],
nonInteractivePermissions: "fail",
permissionPolicy: {
@@ -51,7 +51,7 @@ describe("ACPX driver profile", () => {
] as const)("accepts the exact qualified %s model", (agent, model) => {
expect(validateAcpxDriverConfig({ agent, model })).toEqual({
ok: true,
config: { agent, model, permissionMode: "approve-all" },
config: { agent, model, permissionMode: "approve-paperclip" },
issues: [],
});
});
@@ -15,6 +15,7 @@ import {
const ACPX_AGENTS = ["claude", "codex"] as const;
const ACPX_PERMISSION_MODES = [
"approve-all",
"approve-paperclip",
"approve-reads",
"deny-all",
] as const;
@@ -109,12 +110,12 @@ export function validateAcpxDriverConfig(
"permissionMode",
)
? text(config.permissionMode)
: "approve-all";
: "approve-paperclip";
if (!isPermissionMode(permissionMode)) {
return invalid(
"permissionMode",
"invalid_permission_mode",
"ACPX permission mode must be approve-all, approve-reads, or deny-all.",
"ACPX permission mode must be approve-all, approve-paperclip, approve-reads, or deny-all.",
);
}
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
acpxRuntimePermissionPolicy,
claudeReadPermissionRules,
claudePaperclipPermissionRules,
decideAcpxPermission,
} from "./permission-policy.js";
@@ -12,10 +12,22 @@ describe("ACPX permission policy", () => {
"paperclip__get_task_context", "read_document", "write_document",
"call_api", "request_approval", "unknown_read", "mcp__other__get_task_context",
].map((name) => ({ name, annotations: { readOnlyHint: true, effect: "read" } }));
expect(claudeReadPermissionRules(tools)).toEqual([
expect(claudePaperclipPermissionRules(tools, "approve-reads")).toEqual([
"mcp__paperclip__get_task_context", "mcp__paperclip__read_document",
]);
expect(claudeReadPermissionRules([])).toEqual([]);
expect(claudePaperclipPermissionRules([], "approve-reads")).toEqual([]);
});
it("allows assigned Paperclip mutations without admitting unknown or external tools", () => {
const tools = ["paperclip__write_document", "create_task", "reassign_task", "request_approval",
"write_document", "mcp__other__create_task", "Bash", "unknown_write", "mcp__paperclip__create_task"]
.map(name => ({ name, annotations: { readOnlyHint: true } }));
expect(claudePaperclipPermissionRules(tools, "approve-paperclip")).toEqual([
"mcp__paperclip__create_task", "mcp__paperclip__reassign_task",
"mcp__paperclip__request_approval", "mcp__paperclip__write_document",
]);
expect(claudePaperclipPermissionRules([], "approve-paperclip")).toEqual([]);
expect(claudePaperclipPermissionRules(tools, "deny-all")).toEqual([]);
});
it("maps each configured mode to a closed ACP runtime policy", () => {
@@ -25,6 +37,7 @@ describe("ACPX permission policy", () => {
expect(acpxRuntimePermissionPolicy("deny-all")).toEqual({
defaultAction: "deny",
});
expect(acpxRuntimePermissionPolicy("approve-paperclip")).toEqual({ defaultAction: "escalate" });
expect(acpxRuntimePermissionPolicy("approve-reads")).toEqual({
defaultAction: "escalate",
});
@@ -35,6 +48,9 @@ describe("ACPX permission policy", () => {
["approve-reads", "read", "delegate"],
["approve-reads", "search", "delegate"],
["approve-reads", "execute", "delegate"],
["approve-paperclip", "read", "delegate"],
["approve-paperclip", "write", "delegate"],
["approve-paperclip", "execute", "delegate"],
["deny-all", "read", "reject_once"],
] as const)("%s maps %s to %s", (mode, inferredKind, expected) => {
expect(
@@ -65,7 +81,7 @@ describe("ACPX permission policy", () => {
}
});
it("does not let provider metadata widen approve-reads", () => {
it.each(["approve-reads", "approve-paperclip"] as const)("does not let provider metadata widen %s", (mode) => {
for (const [agent, inferredKind, raw, options] of [
[
"codex",
@@ -104,7 +120,7 @@ describe("ACPX permission policy", () => {
expect(
decideAcpxPermission(
agent,
"approve-reads",
mode,
{ inferredKind, raw },
options,
),
@@ -7,7 +7,7 @@ export class AcpxApprovalRequiredError extends Error {
readonly code = "approval_required";
constructor() {
super(
"Approval required. This operation is not an automatically allowed Paperclip read, " +
"Approval required. This operation is not an automatically allowed Paperclip action, " +
"and this runner has no interactive approval handler. Review the operation and " +
"update the agent's permission setting before retrying.",
);
@@ -16,16 +16,22 @@ export class AcpxApprovalRequiredError extends Error {
}
/** Exact SDK rules for the run's runner-owned Paperclip MCP connection. */
export function claudeReadPermissionRules(
export function claudePaperclipPermissionRules(
tools: readonly Readonly<Record<string, unknown>>[],
mode: NativeAcpxPermissionMode,
): string[] {
if (mode !== "approve-reads" && mode !== "approve-paperclip") return [];
const names = tools.flatMap((tool) => {
if (typeof tool.name !== "string") return [];
const name = canonicalRunnerToolName(tool.name);
// Effects come from Paperclip's implementation catalog, never tool hints
// or the provider's permission-request metadata. Unknown operations stay
// subject to approval even when their names or annotations claim a read.
return paperclipSemanticAction(name)?.effect === "read"
const action = paperclipSemanticAction(name);
// This only bypasses the provider's redundant permission prompt. The
// authenticated bridge and controller still validate run authority,
// company scope, claims, task mode, and governed-action approvals.
return action && (mode === "approve-paperclip" || action.effect === "read")
? [`mcp__paperclip__${name}`]
: [];
});
@@ -60,7 +66,7 @@ export function acpxRuntimePermissionPolicy(
if (mode === "deny-all") return { defaultAction: "deny" };
// ACPX derives permission kinds from provider-originated requests. Until the
// host can bind a request to independent authority, no kind is safe to
// auto-approve here. Verified Paperclip reads are allowed earlier at the
// auto-approve here. Assigned Paperclip actions are allowed earlier at the
// Claude SDK dispatch boundary. Other requests require an approval handler.
return { defaultAction: "escalate" };
}
@@ -78,8 +84,8 @@ export function decideAcpxPermission(
if (mode === "deny-all") return "reject_once";
if (mode === "approve-all") return "allow_once";
// inferredKind and raw semantic/MCP metadata both originate outside the
// runner trust boundary. Neither can grant local read or semantic authority.
// Verified Paperclip reads use exact SDK rules on the runner-owned MCP
// runner trust boundary. Neither can grant local semantic authority.
// Assigned Paperclip actions use exact SDK rules on the runner-owned MCP
// connection. Anything reaching this fallback still requires approval.
return "delegate";
}
@@ -351,7 +351,7 @@ function isDigest(value: unknown): value is string {
function isPermissionMode(value: unknown): value is NativeAcpxPermissionMode {
return (
typeof value === "string" &&
["approve-all", "approve-reads", "deny-all"].includes(value)
["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(value)
);
}
@@ -209,7 +209,11 @@ describe("ACPX runtime host", () => {
} finally { await host.close({ reason: "gateway test complete" }); }
});
it("automatically permits only admitted Paperclip reads in the Claude SDK", async () => {
it.each([
["approve-reads", ["mcp__paperclip__get_task_context"]],
["approve-paperclip", ["mcp__paperclip__create_task", "mcp__paperclip__get_task_context", "mcp__paperclip__reassign_task", "mcp__paperclip__write_document"]],
["deny-all", undefined],
] as const)("binds exact assigned Claude tools to %s before provider launch", async (permissionMode, allow) => {
const fixture = await hostFixture();
const dependencies = fixture.dependencies({
openRuntime: async (options) => {
@@ -217,9 +221,7 @@ describe("ACPX runtime host", () => {
join(options.launchEnvironment.CLAUDE_CONFIG_DIR!, "settings.json"),
"utf8",
));
expect(settings.permissions).toEqual({
allow: ["mcp__paperclip__get_task_context"],
});
expect(settings.permissions).toEqual(allow ? { allow } : undefined);
expect(options.mcpServers).toMatchObject([
{ name: "paperclip", runnerOwned: true },
]);
@@ -232,9 +234,9 @@ describe("ACPX runtime host", () => {
...fixture.options,
agent: "claude",
model: "claude-sonnet-5",
permissionMode: "approve-reads",
permissionMode,
semanticTools: {
tools: ["get_task_context", "write_document", "unknown_read"].map((name) => ({
tools: ["get_task_context", "write_document", "create_task", "reassign_task", "unknown_read"].map((name) => ({
name,
inputSchema: { type: "object" },
// Supplied hints cannot grant write or unknown operations read access.
@@ -28,7 +28,7 @@ import {
} from "node:path";
import { createSanitizedAcpxSpawnInput } from "./environment.js";
import { claudeReadPermissionRules } from "./permission-policy.js";
import { claudePaperclipPermissionRules } from "./permission-policy.js";
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
import {
resolveAcpxRuntimeRoot,
@@ -378,8 +378,8 @@ export async function prepareAcpxRuntimeSandbox(input: {
`${JSON.stringify({
model: input.binding.requestedModel,
availableModels: [input.binding.requestedModel],
...(input.binding.permissionMode === "approve-reads"
? { permissions: { allow: claudeReadPermissionRules(input.tools ?? []) } }
...((input.binding.permissionMode === "approve-reads" || input.binding.permissionMode === "approve-paperclip")
? { permissions: { allow: claudePaperclipPermissionRules(input.tools ?? [], input.binding.permissionMode) } }
: {}),
})}\n`,
);
@@ -66,6 +66,7 @@ export function parseProviderIdentity(
if (
permissionMode !== undefined &&
permissionMode !== "approve-all" &&
permissionMode !== "approve-paperclip" &&
permissionMode !== "approve-reads" &&
permissionMode !== "deny-all"
) {
@@ -1258,8 +1258,8 @@ it("preserves answer and internal wait descriptions in the serialized native too
).toContain("not in the top-level user-facing summary");
});
it("defaults runnerd ACPX permissions to approve reads", () => {
expect(resolveRunnerdAcpxPermissionMode(undefined)).toBe("approve-reads");
it("defaults runnerd ACPX permissions to authorized Paperclip actions", () => {
expect(resolveRunnerdAcpxPermissionMode(undefined)).toBe("approve-paperclip");
expect(resolveRunnerdAcpxPermissionMode("deny-all")).toBe("deny-all");
});
@@ -3155,7 +3155,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
export function resolveRunnerdAcpxPermissionMode(
configured: CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"],
): NonNullable<CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"]> {
return configured ?? "approve-reads";
return configured ?? "approve-paperclip";
}
const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([
+1 -1
View File
@@ -362,7 +362,7 @@ describe("adapter routes", () => {
}),
expect.objectContaining({
key: "acpxPermissionMode",
default: "approve-reads",
default: "approve-paperclip",
meta: { visibleWhen: { key: "provider", value: "acpx" } },
}),
expect.objectContaining({
@@ -469,6 +469,9 @@ describe("native execution input external-chat framing", () => {
},
runtimeContext: nativeRuntimeContextFixture(),
});
expect(input.provider).toMatchObject(provider === "acpx"
? { kind: "acpx", permissionMode: "approve-paperclip" }
: { kind: "codex", approvalPolicy: "never" });
expect(input.task.prompt).not.toContain("## Questions that need a user response");
expect(input.task.prompt).toContain("Use Paperclip's request_human_input for durable task questions.");
expect(input.task.prompt).not.toContain("payload.questionSet");
@@ -240,7 +240,7 @@ export function buildNativeExecutionInput(input: {
kind: "acpx",
agent: acpxProfile!.agent,
model: input.model,
permissionMode: input.acpxPermissionMode ?? "approve-reads",
permissionMode: input.acpxPermissionMode ?? "approve-paperclip",
profile: {
driverKind: acpxProfile!.driverKind,
protocolVersion: acpxProfile!.protocolVersion,
@@ -82,7 +82,7 @@ type RunnerTransportOptions = {
provider?: "codex" | "opencode" | "acpx";
opencodePermissionMode?: "allow" | "ask" | "deny";
acpxAgent?: "claude" | "codex";
acpxPermissionMode?: "approve-all" | "approve-reads" | "deny-all";
acpxPermissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
resumeActiveTurnId?: string | null;
resumeProviderSession?: {
driverSessionId: string;
@@ -5167,7 +5167,7 @@ export function providerSessionIdentityFromDurableProviderState(input: {
identity.requestedModel !== expectedModel ||
identity.effectiveModel !== expectedModel ||
identity.permissionMode !== input.execution.provider.permissionMode ||
!["approve-all", "approve-reads", "deny-all"].includes(
!["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(
String(identity.permissionMode),
) ||
!Array.isArray(identity.providerLifetimeFenceCandidates) ||
@@ -109,7 +109,7 @@ export type PaperclipRunnerNativeProviderInput =
provider: "acpx";
model: string;
acpxAgent: QualifiedPaperclipRunnerAcpxAgent;
acpxPermissionMode: "approve-all" | "approve-reads" | "deny-all";
acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
};
export class PaperclipRunnerProviderProfileError extends Error {
@@ -476,7 +476,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
acpxPermissionMode: resolvePaperclipRunnerPermissionMode(
"acpx",
config.acpxPermissionMode,
) as "approve-all" | "approve-reads" | "deny-all",
) as "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all",
};
}
if (profile.provider === "claude_managed") {
+2
View File
@@ -144,6 +144,8 @@ suite. Cancelled turns and execution-task runs remain included in billing and
cleanup. The production chat directive is injected normally; fixtures do not
replace it with completion instructions. Daytona is excluded.
The native chat profiles use production provider permission defaults, rather than
full-auto overrides, for plan handoff, task creation, and reassignment.
The native Codex and Claude profiles also cover reassignment of existing ready
and backlog tasks. The oracle verifies stable task IDs, preserved descriptions,
assignment audit evidence, exactly one successful successor run and its output
+16
View File
@@ -33,6 +33,22 @@ describe("runner E2E catalog", () => {
expect(connectionReviewSuite.tasks.every(task => task.flow === "governed_tool_review")).toBe(true);
});
it("tests native chat plans, tasks, and reassignment with production permission defaults", () => {
const suite = runnerSuites.find(suite => suite.id === "agent-chat")!;
for (const id of ["runner-codex", "runner-acpx-claude"]) {
const profile = suite.profiles.find(profile => profile.id === id)!;
const payload = profile.buildAgent({
executionId: "default-permissions", workspacePath: "/workspace", environmentId: "env-1", environmentFixtureId: "local",
secretRefs: {
[profile.credential]: { type: "secret_ref", secretId: "22222222-2222-4222-8222-222222222222", version: "latest" },
},
});
expect(payload.adapterConfig).not.toHaveProperty("acpxPermissionMode");
expect(payload.adapterConfig).not.toHaveProperty("codexPermissionMode");
expect(suite.tasks.map(task => task.id)).toEqual(expect.arrayContaining(["plan-handoff", "reassign-task"]));
}
});
it("validates the core, local-integrity, breadth, and warm suites", () => {
expect(runnerProfiles).toHaveLength(7);
expect(openRouterBreadthProfiles).toHaveLength(4);
+15 -2
View File
@@ -197,6 +197,19 @@ function nativeProfile(input: {
};
}
/** Chat acceptance exercises the shipped provider defaults, not full-auto fixtures. */
function defaultPermissionProfile(profile: RunnerProfileFixture): RunnerProfileFixture {
if (profile.generation !== "native") return profile;
return {
...profile,
buildAgent(input) {
const payload = profile.buildAgent(input);
const { codexPermissionMode: _codex, acpxPermissionMode: _acpx, ...adapterConfig } = payload.adapterConfig as Record<string, unknown>;
return { ...payload, adapterConfig };
},
};
}
const claudeLegacyModel = "claude-sonnet-4-6";
if (!claudeModels.some((model) => model.id === claudeLegacyModel)) {
throw new Error(
@@ -928,10 +941,10 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
id: "agent-chat", label: "Persistent Agent Chat",
description: "Task-backed conversations, session resets, and project plan handoff.",
groups: ["chat"],
profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)),
profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)).map(defaultPermissionProfile),
environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 26,
excludedExecutionIds: ["legacy-codex", "legacy-claude"].map(profile => `agent-chat.${profile}.local.reassign-task`),
definitionMetadata: { version: 2, resetRunsCountedSeparately: true },
definitionMetadata: { version: 3, resetRunsCountedSeparately: true, permissions: "production-defaults" },
},
...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []),
{