Fix headless OpenCode permission prompts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c6364149b1
commit
ed62d58cb2
9 changed files with 652 additions and 430 deletions
|
|
@ -22,6 +22,7 @@ Core fields:
|
|||
- 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)
|
||||
- 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
|
||||
- command (string, optional): defaults to "opencode"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
|
|
@ -40,4 +41,7 @@ Notes:
|
|||
- The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \
|
||||
writing an opencode.json config file into the project working directory. Model \
|
||||
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.
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -177,8 +178,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
||||
try {
|
||||
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",
|
||||
),
|
||||
);
|
||||
|
|
@ -236,16 +239,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
}
|
||||
|
||||
const commandNotes = (() => {
|
||||
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||
const notes = [...preparedRuntimeConfig.notes];
|
||||
if (!resolvedInstructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
||||
notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`);
|
||||
notes.push(
|
||||
`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.`,
|
||||
];
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
|
|
@ -296,7 +302,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||
env: redactEnvForLogs(env),
|
||||
env: redactEnvForLogs(preparedRuntimeConfig.env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
|
|
@ -404,4 +410,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
}
|
||||
|
||||
return toResult(initial);
|
||||
} finally {
|
||||
await preparedRuntimeConfig.cleanup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asBoolean,
|
||||
asString,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import { parseOpenCodeJsonl } from "./parse.js";
|
||||
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
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.
|
||||
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");
|
||||
if (cwdInvalid) {
|
||||
|
|
@ -311,6 +322,9 @@ export async function testEnvironment(
|
|||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await preparedRuntimeConfig.cleanup();
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
|||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
// 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.
|
||||
ac.timeoutSec = 0;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
ToggleField,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
|
|
@ -19,8 +21,9 @@ export function OpenCodeLocalConfigFields({
|
|||
mark,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
if (hideInstructionsFile) return null;
|
||||
return (
|
||||
<>
|
||||
{!hideInstructionsFile && (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
|
|
@ -45,5 +48,25 @@ export function OpenCodeLocalConfigFields({
|
|||
<ChoosePathButton />
|
||||
</div>
|
||||
</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)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,7 +325,8 @@ export function OnboardingWizard() {
|
|||
command,
|
||||
args,
|
||||
url,
|
||||
dangerouslySkipPermissions: adapterType === "claude_local",
|
||||
dangerouslySkipPermissions:
|
||||
adapterType === "claude_local" || adapterType === "opencode_local",
|
||||
dangerouslyBypassSandbox:
|
||||
adapterType === "codex_local"
|
||||
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const help: Record<string, string> = {
|
|||
model: "Override the default model used by the adapter.",
|
||||
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
||||
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.",
|
||||
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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue