diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 67fbdb4b..bbe75c26 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -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. `; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 788ad835..f3a5ae50 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -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,231 +178,239 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", - ), - ); - await ensureCommandResolvable(command, cwd, runtimeEnv); - - await ensureOpenCodeModelConfiguredAndAvailable({ - model, - command, - cwd, - env: runtimeEnv, - }); - - const timeoutSec = asNumber(config.timeoutSec, 0); - const graceSec = asNumber(config.graceSec, 20); - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - - const runtimeSessionParams = parseObject(runtime.sessionParams); - const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); - const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); - const canResumeSession = - runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); - const sessionId = canResumeSession ? runtimeSessionId : null; - if (runtimeSessionId && !canResumeSession) { - await onLog( - "stdout", - `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config }); + try { + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ); - } + await ensureCommandResolvable(command, cwd, runtimeEnv); - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const resolvedInstructionsFilePath = instructionsFilePath - ? path.resolve(cwd, instructionsFilePath) - : ""; - const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; - let instructionsPrefix = ""; - if (resolvedInstructionsFilePath) { - try { - const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); - instructionsPrefix = - `${instructionsContents}\n\n` + - `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + - `Resolve any relative file references from ${instructionsDir}.\n\n`; - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - await onLog( - "stdout", - `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, - ); - } - } - - const commandNotes = (() => { - if (!resolvedInstructionsFilePath) return [] as string[]; - if (instructionsPrefix.length > 0) { - return [ - `Loaded agent instructions from ${resolvedInstructionsFilePath}`, - `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, - ]; - } - return [ - `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, - ]; - })(); - - const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); - const templateData = { - agentId: agent.id, - companyId: agent.companyId, - runId, - company: { id: agent.companyId }, - agent, - run: { id: runId, source: "on_demand" }, - context, - }; - const renderedPrompt = renderTemplate(promptTemplate, templateData); - const renderedBootstrapPrompt = - !sessionId && bootstrapPromptTemplate.trim().length > 0 - ? renderTemplate(bootstrapPromptTemplate, templateData).trim() - : ""; - const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); - const prompt = joinPromptSections([ - instructionsPrefix, - renderedBootstrapPrompt, - sessionHandoffNote, - renderedPrompt, - ]); - const promptMetrics = { - promptChars: prompt.length, - instructionsChars: instructionsPrefix.length, - bootstrapPromptChars: renderedBootstrapPrompt.length, - sessionHandoffChars: sessionHandoffNote.length, - heartbeatPromptChars: renderedPrompt.length, - }; - - const buildArgs = (resumeSessionId: string | null) => { - const args = ["run", "--format", "json"]; - if (resumeSessionId) args.push("--session", resumeSessionId); - if (model) args.push("--model", model); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - return args; - }; - - const runAttempt = async (resumeSessionId: string | null) => { - const args = buildArgs(resumeSessionId); - if (onMeta) { - await onMeta({ - adapterType: "opencode_local", - command, - cwd, - commandNotes, - commandArgs: [...args, ``], - env: redactEnvForLogs(env), - prompt, - promptMetrics, - context, - }); - } - - const proc = await runChildProcess(runId, command, args, { + await ensureOpenCodeModelConfiguredAndAvailable({ + model, + command, cwd, env: runtimeEnv, - stdin: prompt, - timeoutSec, - graceSec, - onSpawn, - onLog, }); - return { - proc, - rawStderr: proc.stderr, - parsed: parseOpenCodeJsonl(proc.stdout), - }; - }; - const toResult = ( - attempt: { - proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; - rawStderr: string; - parsed: ReturnType; - }, - clearSessionOnMissingSession = false, - ): AdapterExecutionResult => { - if (attempt.proc.timedOut) { - return { - exitCode: attempt.proc.exitCode, - signal: attempt.proc.signal, - timedOut: true, - errorMessage: `Timed out after ${timeoutSec}s`, - clearSession: clearSessionOnMissingSession, - }; + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); } - const resolvedSessionId = - attempt.parsed.sessionId ?? - (clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null); - const resolvedSessionParams = resolvedSessionId - ? ({ - sessionId: resolvedSessionId, - cwd, - ...(workspaceId ? { workspaceId } : {}), - ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), - ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), - } as Record) - : null; + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const resolvedInstructionsFilePath = instructionsFilePath + ? path.resolve(cwd, instructionsFilePath) + : ""; + const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (resolvedInstructionsFilePath) { + try { + const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stdout", + `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, + ); + } + } - const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; - const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const rawExitCode = attempt.proc.exitCode; - const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; - const fallbackErrorMessage = - parsedError || - stderrLine || - `OpenCode exited with code ${synthesizedExitCode ?? -1}`; - const modelId = model || null; + const commandNotes = (() => { + const notes = [...preparedRuntimeConfig.notes]; + if (!resolvedInstructionsFilePath) return notes; + if (instructionsPrefix.length > 0) { + notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`); + notes.push( + `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, + ); + return notes; + } + notes.push( + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + })(); - return { - exitCode: synthesizedExitCode, - signal: attempt.proc.signal, - timedOut: false, - errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, - usage: { - inputTokens: attempt.parsed.usage.inputTokens, - outputTokens: attempt.parsed.usage.outputTokens, - cachedInputTokens: attempt.parsed.usage.cachedInputTokens, - }, - sessionId: resolvedSessionId, - sessionParams: resolvedSessionParams, - sessionDisplayId: resolvedSessionId, - provider: parseModelProvider(modelId), - biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)), - model: modelId, - billingType: "unknown", - costUsd: attempt.parsed.costUsd, - resultJson: { - stdout: attempt.proc.stdout, - stderr: attempt.proc.stderr, - }, - summary: attempt.parsed.summary, - clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedPrompt = renderTemplate(promptTemplate, templateData); + const renderedBootstrapPrompt = + !sessionId && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, }; - }; - const initial = await runAttempt(sessionId); - const initialFailed = - !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); - if ( - sessionId && - initialFailed && - isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) - ) { - await onLog( - "stdout", - `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, - ); - const retry = await runAttempt(null); - return toResult(retry, true); + const buildArgs = (resumeSessionId: string | null) => { + const args = ["run", "--format", "json"]; + if (resumeSessionId) args.push("--session", resumeSessionId); + if (model) args.push("--model", model); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "opencode_local", + command, + cwd, + commandNotes, + commandArgs: [...args, ``], + env: redactEnvForLogs(preparedRuntimeConfig.env), + prompt, + promptMetrics, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env: runtimeEnv, + stdin: prompt, + timeoutSec, + graceSec, + onSpawn, + onLog, + }); + return { + proc, + rawStderr: proc.stderr, + parsed: parseOpenCodeJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; + rawStderr: string; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const resolvedSessionId = + attempt.parsed.sessionId ?? + (clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : null; + + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const rawExitCode = attempt.proc.exitCode; + const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; + const fallbackErrorMessage = + parsedError || + stderrLine || + `OpenCode exited with code ${synthesizedExitCode ?? -1}`; + const modelId = model || null; + + return { + exitCode: synthesizedExitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + usage: { + inputTokens: attempt.parsed.usage.inputTokens, + outputTokens: attempt.parsed.usage.outputTokens, + cachedInputTokens: attempt.parsed.usage.cachedInputTokens, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: parseModelProvider(modelId), + biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)), + model: modelId, + billingType: "unknown", + costUsd: attempt.parsed.costUsd, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), + }; + }; + + const initial = await runAttempt(sessionId); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); + if ( + sessionId && + initialFailed && + isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stdout", + `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true); + } + + return toResult(initial); + } finally { + await preparedRuntimeConfig.cleanup(); } - - return toResult(initial); } diff --git a/packages/adapters/opencode-local/src/server/runtime-config.test.ts b/packages/adapters/opencode-local/src/server/runtime-config.test.ts new file mode 100644 index 00000000..c5c396ac --- /dev/null +++ b/packages/adapters/opencode-local/src/server/runtime-config.test.ts @@ -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(); + +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) { + 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; + 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(); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts new file mode 100644 index 00000000..bc903e83 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -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; + notes: string[]; + cleanup: () => Promise; +}; + +function resolveXdgConfigHome(env: Record): 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readJsonObject(filepath: string): Promise> { + 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; + config: Record; +}): Promise { + 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 }); + }, + }; +} diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index ad3957d1..1d6ef459 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -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,224 +94,236 @@ 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 cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); - if (cwdInvalid) { + const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config }); + if (asBoolean(config.dangerouslySkipPermissions, true)) { checks.push({ - code: "opencode_command_skipped", - level: "warn", - message: "Skipped command check because working directory validation failed.", - detail: command, + code: "opencode_headless_permissions_enabled", + level: "info", + message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.", }); - } else { - try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + } + try { + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })); + + const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); + if (cwdInvalid) { checks.push({ - code: "opencode_command_resolvable", - level: "info", - message: `Command is executable: ${command}`, - }); - } catch (err) { - checks.push({ - code: "opencode_command_unresolvable", - level: "error", - message: err instanceof Error ? err.message : "Command is not executable", + code: "opencode_command_skipped", + level: "warn", + message: "Skipped command check because working directory validation failed.", detail: command, }); - } - } - - const canRunProbe = - checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); - - let modelValidationPassed = false; - const configuredModel = asString(config.model, "").trim(); - - if (canRunProbe && configuredModel) { - try { - const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); - if (discovered.length > 0) { + } else { + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); checks.push({ - code: "opencode_models_discovered", + code: "opencode_command_resolvable", level: "info", - message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + message: `Command is executable: ${command}`, }); - } else { + } catch (err) { checks.push({ - code: "opencode_models_empty", + code: "opencode_command_unresolvable", level: "error", - message: "OpenCode returned no models.", - hint: "Run `opencode models` and verify provider authentication.", - }); - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - if (/ProviderModelNotFoundError/i.test(errMsg)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - detail: errMsg, - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else { - checks.push({ - code: "opencode_models_discovery_failed", - level: "error", - message: errMsg || "OpenCode model discovery failed.", - hint: "Run `opencode models` manually to verify provider auth and config.", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, }); } } - } else if (canRunProbe && !configuredModel) { - try { - const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); - if (discovered.length > 0) { - checks.push({ - code: "opencode_models_discovered", - level: "info", - message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, - }); + + const canRunProbe = + checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); + + let modelValidationPassed = false; + const configuredModel = asString(config.model, "").trim(); + + if (canRunProbe && configuredModel) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + }); + } else { + checks.push({ + code: "opencode_models_empty", + level: "error", + message: "OpenCode returned no models.", + hint: "Run `opencode models` and verify provider authentication.", + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "error", + message: errMsg || "OpenCode model discovery failed.", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - if (/ProviderModelNotFoundError/i.test(errMsg)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - detail: errMsg, - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else { - checks.push({ - code: "opencode_models_discovery_failed", - level: "warn", - message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", - hint: "Run `opencode models` manually to verify provider auth and config.", - }); + } else if (canRunProbe && !configuredModel) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "warn", + message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } } } - } - const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); - if (!configuredModel && !modelUnavailable) { - // No model configured – skip model requirement if no model-related checks exist - } else if (configuredModel && canRunProbe) { - try { - await ensureOpenCodeModelConfiguredAndAvailable({ - model: configuredModel, - command, - cwd, - env: runtimeEnv, - }); - checks.push({ - code: "opencode_model_configured", - level: "info", - message: `Configured model: ${configuredModel}`, - }); - modelValidationPassed = true; - } catch (err) { - checks.push({ - code: "opencode_model_invalid", - level: "error", - message: err instanceof Error ? err.message : "Configured model is unavailable.", - hint: "Run `opencode models` and choose a currently available provider/model ID.", - }); - } - } - - if (canRunProbe && modelValidationPassed) { - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - const variant = asString(config.variant, "").trim(); - const probeModel = configuredModel; - - const args = ["run", "--format", "json"]; - args.push("--model", probeModel); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - - try { - const probe = await runChildProcess( - `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, - command, - args, - { + const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); + if (!configuredModel && !modelUnavailable) { + // No model configured – skip model requirement if no model-related checks exist + } else if (configuredModel && canRunProbe) { + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: configuredModel, + command, cwd, env: runtimeEnv, - timeoutSec: 60, - graceSec: 5, - stdin: "Respond with hello.", - onLog: async () => {}, - }, - ); + }); + checks.push({ + code: "opencode_model_configured", + level: "info", + message: `Configured model: ${configuredModel}`, + }); + modelValidationPassed = true; + } catch (err) { + checks.push({ + code: "opencode_model_invalid", + level: "error", + message: err instanceof Error ? err.message : "Configured model is unavailable.", + hint: "Run `opencode models` and choose a currently available provider/model ID.", + }); + } + } - const parsed = parseOpenCodeJsonl(probe.stdout); - const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); - const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + if (canRunProbe && modelValidationPassed) { + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + const variant = asString(config.variant, "").trim(); + const probeModel = configuredModel; - if (probe.timedOut) { - checks.push({ - code: "opencode_hello_probe_timed_out", - level: "warn", - message: "OpenCode hello probe timed out.", - hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", - }); - } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { - const summary = parsed.summary.trim(); - const hasHello = /\bhello\b/i.test(summary); - checks.push({ - code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", - level: hasHello ? "info" : "warn", - message: hasHello - ? "OpenCode hello probe succeeded." - : "OpenCode probe ran but did not return `hello` as expected.", - ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), - ...(hasHello - ? {} - : { - hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", - }), - }); - } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - ...(detail ? { detail } : {}), - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_auth_required", - level: "warn", - message: "OpenCode is installed, but provider authentication is not ready.", - ...(detail ? { detail } : {}), - hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", - }); - } else { + const args = ["run", "--format", "json"]; + args.push("--model", probeModel); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + + try { + const probe = await runChildProcess( + `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env: runtimeEnv, + timeoutSec: 60, + graceSec: 5, + stdin: "Respond with hello.", + onLog: async () => {}, + }, + ); + + const parsed = parseOpenCodeJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + + if (probe.timedOut) { + checks.push({ + code: "opencode_hello_probe_timed_out", + level: "warn", + message: "OpenCode hello probe timed out.", + hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", + }); + } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "OpenCode hello probe succeeded." + : "OpenCode probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", + }), + }); + } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + ...(detail ? { detail } : {}), + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_auth_required", + level: "warn", + message: "OpenCode is installed, but provider authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", + }); + } else { + checks.push({ + code: "opencode_hello_probe_failed", + level: "error", + message: "OpenCode hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `opencode run --format json` manually in this working directory to debug.", + }); + } + } catch (err) { checks.push({ code: "opencode_hello_probe_failed", level: "error", message: "OpenCode hello probe failed.", - ...(detail ? { detail } : {}), + detail: err instanceof Error ? err.message : String(err), hint: "Run `opencode run --format json` manually in this working directory to debug.", }); } - } catch (err) { - checks.push({ - code: "opencode_hello_probe_failed", - level: "error", - message: "OpenCode hello probe failed.", - detail: err instanceof Error ? err.message : String(err), - hint: "Run `opencode run --format json` manually in this working directory to debug.", - }); } + } finally { + await preparedRuntimeConfig.cleanup(); } return { diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 0d425cf1..fa941ed2 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -58,6 +58,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
- + <> + {!hideInstructionsFile && ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ )} + + isCreate + ? set!({ dangerouslySkipPermissions: v }) + : mark("adapterConfig", "dangerouslySkipPermissions", v) + } + /> + ); } diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 06a053db..cd28af9f 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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 diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 3c0fd25b..70694f73 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -30,7 +30,7 @@ export const help: Record = { 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.",