Merge pull request #1831 from paperclipai/pr/pap-891-opencode-headless-prompts

fix(opencode): support headless permission prompt configuration
This commit is contained in:
Dotta 2026-03-26 11:43:01 -05:00 committed by GitHub
commit aa5b2be907
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 652 additions and 430 deletions

View file

@ -22,6 +22,7 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5) - model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max) - variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config that allows \`external_directory\` access without interactive prompts; defaults to true for unattended Paperclip runs
- promptTemplate (string, optional): run prompt template - promptTemplate (string, optional): run prompt template
- command (string, optional): defaults to "opencode" - command (string, optional): defaults to "opencode"
- extraArgs (string[], optional): additional CLI args - extraArgs (string[], optional): additional CLI args
@ -40,4 +41,7 @@ Notes:
- The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \ - The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \
writing an opencode.json config file into the project working directory. Model \ writing an opencode.json config file into the project working directory. Model \
selection is passed via the --model CLI flag instead. selection is passed via the --model CLI flag instead.
- When \`dangerouslySkipPermissions\` is enabled, Paperclip injects a temporary \
runtime config with \`permission.external_directory=allow\` so headless runs do \
not stall on approval prompts.
`; `;

View file

@ -23,6 +23,7 @@ import {
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -177,8 +178,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) { if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken; env.PAPERCLIP_API_KEY = authToken;
} }
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
try {
const runtimeEnv = Object.fromEntries( const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string", (entry): entry is [string, string] => typeof entry[1] === "string",
), ),
); );
@ -236,16 +239,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
} }
const commandNotes = (() => { const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[]; const notes = [...preparedRuntimeConfig.notes];
if (!resolvedInstructionsFilePath) return notes;
if (instructionsPrefix.length > 0) { if (instructionsPrefix.length > 0) {
return [ notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`);
`Loaded agent instructions from ${resolvedInstructionsFilePath}`, notes.push(
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
]; );
return notes;
} }
return [ notes.push(
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
]; );
return notes;
})(); })();
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
@ -296,7 +302,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
cwd, cwd,
commandNotes, commandNotes,
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`], commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(env), env: redactEnvForLogs(preparedRuntimeConfig.env),
prompt, prompt,
promptMetrics, promptMetrics,
context, context,
@ -404,4 +410,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
} }
return toResult(initial); return toResult(initial);
} finally {
await preparedRuntimeConfig.cleanup();
}
} }

View file

@ -0,0 +1,79 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
const cleanupPaths = new Set<string>();
afterEach(async () => {
await Promise.all(
[...cleanupPaths].map(async (filepath) => {
await fs.rm(filepath, { recursive: true, force: true });
cleanupPaths.delete(filepath);
}),
);
});
async function makeConfigHome(initialConfig?: Record<string, unknown>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-"));
cleanupPaths.add(root);
const configDir = path.join(root, "opencode");
await fs.mkdir(configDir, { recursive: true });
if (initialConfig) {
await fs.writeFile(
path.join(configDir, "opencode.json"),
`${JSON.stringify(initialConfig, null, 2)}\n`,
"utf8",
);
}
return root;
}
describe("prepareOpenCodeRuntimeConfig", () => {
it("injects an external_directory allow rule by default", async () => {
const configHome = await makeConfigHome({
permission: {
read: "allow",
},
theme: "system",
});
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome },
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
expect(prepared.env.XDG_CONFIG_HOME).not.toBe(configHome);
const runtimeConfig = JSON.parse(
await fs.readFile(
path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"),
"utf8",
),
) as Record<string, unknown>;
expect(runtimeConfig).toMatchObject({
theme: "system",
permission: {
read: "allow",
external_directory: "allow",
},
});
await prepared.cleanup();
cleanupPaths.delete(prepared.env.XDG_CONFIG_HOME);
await expect(fs.access(prepared.env.XDG_CONFIG_HOME)).rejects.toThrow();
});
it("respects explicit opt-out", async () => {
const configHome = await makeConfigHome();
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome },
config: { dangerouslySkipPermissions: false },
});
expect(prepared.env).toEqual({ XDG_CONFIG_HOME: configHome });
expect(prepared.notes).toEqual([]);
await prepared.cleanup();
});
});

View file

@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { asBoolean } from "@paperclipai/adapter-utils/server-utils";
type PreparedOpenCodeRuntimeConfig = {
env: Record<string, string>;
notes: string[];
cleanup: () => Promise<void>;
};
function resolveXdgConfigHome(env: Record<string, string>): string {
return (
(typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()) ||
(typeof process.env.XDG_CONFIG_HOME === "string" && process.env.XDG_CONFIG_HOME.trim()) ||
path.join(os.homedir(), ".config")
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readJsonObject(filepath: string): Promise<Record<string, unknown>> {
try {
const raw = await fs.readFile(filepath, "utf8");
const parsed = JSON.parse(raw);
return isPlainObject(parsed) ? parsed : {};
} catch {
return {};
}
}
export async function prepareOpenCodeRuntimeConfig(input: {
env: Record<string, string>;
config: Record<string, unknown>;
}): Promise<PreparedOpenCodeRuntimeConfig> {
const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true);
if (!skipPermissions) {
return {
env: input.env,
notes: [],
cleanup: async () => {},
};
}
const sourceConfigDir = path.join(resolveXdgConfigHome(input.env), "opencode");
const runtimeConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-config-"));
const runtimeConfigDir = path.join(runtimeConfigHome, "opencode");
const runtimeConfigPath = path.join(runtimeConfigDir, "opencode.json");
await fs.mkdir(runtimeConfigDir, { recursive: true });
try {
await fs.cp(sourceConfigDir, runtimeConfigDir, {
recursive: true,
force: true,
errorOnExist: false,
dereference: false,
});
} catch (err) {
if ((err as NodeJS.ErrnoException | null)?.code !== "ENOENT") {
throw err;
}
}
const existingConfig = await readJsonObject(runtimeConfigPath);
const existingPermission = isPlainObject(existingConfig.permission)
? existingConfig.permission
: {};
const nextConfig = {
...existingConfig,
permission: {
...existingPermission,
external_directory: "allow",
},
};
await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
return {
env: {
...input.env,
XDG_CONFIG_HOME: runtimeConfigHome,
},
notes: [
"Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.",
],
cleanup: async () => {
await fs.rm(runtimeConfigHome, { recursive: true, force: true });
},
};
}

View file

@ -4,6 +4,7 @@ import type {
AdapterEnvironmentTestResult, AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils"; } from "@paperclipai/adapter-utils";
import { import {
asBoolean,
asString, asString,
asStringArray, asStringArray,
parseObject, parseObject,
@ -14,6 +15,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js"; import { parseOpenCodeJsonl } from "./parse.js";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail"; if (checks.some((check) => check.level === "error")) return "fail";
@ -92,7 +94,16 @@ export async function testEnvironment(
// Prevent OpenCode from writing an opencode.json into the working directory. // Prevent OpenCode from writing an opencode.json into the working directory.
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
if (asBoolean(config.dangerouslySkipPermissions, true)) {
checks.push({
code: "opencode_headless_permissions_enabled",
level: "info",
message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.",
});
}
try {
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env }));
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
if (cwdInvalid) { if (cwdInvalid) {
@ -311,6 +322,9 @@ export async function testEnvironment(
}); });
} }
} }
} finally {
await preparedRuntimeConfig.cleanup();
}
return { return {
adapterType: ctx.adapterType, adapterType: ctx.adapterType,

View file

@ -58,6 +58,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model; if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort; if (v.thinkingEffort) ac.variant = v.thinkingEffort;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0) // OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
// and rely on graceSec for termination handling when a timeout is configured elsewhere. // and rely on graceSec for termination handling when a timeout is configured elsewhere.
ac.timeoutSec = 0; ac.timeoutSec = 0;

View file

@ -1,7 +1,9 @@
import type { AdapterConfigFieldsProps } from "../types"; import type { AdapterConfigFieldsProps } from "../types";
import { import {
Field, Field,
ToggleField,
DraftInput, DraftInput,
help,
} from "../../components/agent-config-primitives"; } from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal"; import { ChoosePathButton } from "../../components/PathInstructionsModal";
@ -19,8 +21,9 @@ export function OpenCodeLocalConfigFields({
mark, mark,
hideInstructionsFile, hideInstructionsFile,
}: AdapterConfigFieldsProps) { }: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return ( return (
<>
{!hideInstructionsFile && (
<Field label="Agent instructions file" hint={instructionsFileHint}> <Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DraftInput <DraftInput
@ -45,5 +48,25 @@ export function OpenCodeLocalConfigFields({
<ChoosePathButton /> <ChoosePathButton />
</div> </div>
</Field> </Field>
)}
<ToggleField
label="Skip permissions"
hint={help.dangerouslySkipPermissions}
checked={
isCreate
? values!.dangerouslySkipPermissions
: eff(
"adapterConfig",
"dangerouslySkipPermissions",
config.dangerouslySkipPermissions !== false,
)
}
onChange={(v) =>
isCreate
? set!({ dangerouslySkipPermissions: v })
: mark("adapterConfig", "dangerouslySkipPermissions", v)
}
/>
</>
); );
} }

View file

@ -325,7 +325,8 @@ export function OnboardingWizard() {
command, command,
args, args,
url, url,
dangerouslySkipPermissions: adapterType === "claude_local", dangerouslySkipPermissions:
adapterType === "claude_local" || adapterType === "opencode_local",
dangerouslyBypassSandbox: dangerouslyBypassSandbox:
adapterType === "codex_local" adapterType === "codex_local"
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX ? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX

View file

@ -30,7 +30,7 @@ export const help: Record<string, string> = {
model: "Override the default model used by the adapter.", model: "Override the default model used by the adapter.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.", thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
chrome: "Enable Claude's Chrome integration by passing --chrome.", chrome: "Enable Claude's Chrome integration by passing --chrome.",
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.", search: "Enable Codex web search capability during runs.",
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.", workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",