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); } // Recursively replace {env:VAR} placeholders with the resolved value. Used to bake // gateway provider secrets (e.g. the LLM-gateway virtual key) into opencode.json // SERVER-SIDE, where the value is reliably present. OpenCode's own {env:...} // resolution happens inside the (possibly sandboxed) run process, whose env // plumbing is not guaranteed to carry the key to OpenCode's spawned server -- so // we resolve it here. Unresolvable placeholders are left intact for OpenCode to try. function expandEnvPlaceholders(value: T, resolve: (name: string) => string | undefined): T { if (typeof value === "string") { return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => { const resolved = resolve(name); return resolved !== undefined && resolved.length > 0 ? resolved : match; }) as unknown as T; } if (Array.isArray(value)) { return value.map((entry) => expandEnvPlaceholders(entry, resolve)) as unknown as T; } if (isPlainObject(value)) { const out: Record = {}; for (const [key, entry] of Object.entries(value)) { out[key] = expandEnvPlaceholders(entry, resolve); } return out as unknown as T; } return value; } function parseProviderConfig( raw: unknown, resolveEnv: (name: string) => string | undefined, notes: string[], ): Record | null { if (typeof raw !== "string" || raw.trim().length === 0) return null; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { // Surface the misconfiguration instead of silently dropping the provider // block; an unparseable value would otherwise be undiagnosable. notes.push("PAPERCLIP_OPENCODE_PROVIDERS contains invalid JSON; custom providers ignored."); return null; } if (!isPlainObject(parsed)) { notes.push( "PAPERCLIP_OPENCODE_PROVIDERS is set but is not a JSON object; custom providers ignored.", ); return null; } // Only keep provider entries that are themselves objects; surface the ones // we drop so a malformed entry is just as diagnosable as malformed JSON. const providers: Record = {}; const skipped: string[] = []; for (const [key, value] of Object.entries(parsed)) { if (isPlainObject(value)) providers[key] = expandEnvPlaceholders(value, resolveEnv); else skipped.push(key); } if (skipped.length > 0) { notes.push( `PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: ${skipped.join(", ")}.`, ); } return Object.keys(providers).length > 0 ? providers : null; } function parseConfiguredModelRef(raw: unknown): { provider: string; model: string } | null { if (typeof raw !== "string") return null; const trimmed = raw.trim(); const slash = trimmed.indexOf("/"); if (slash <= 0 || slash === trimmed.length - 1) return null; return { provider: trimmed.slice(0, slash), model: trimmed.slice(slash + 1) }; } 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; targetIsRemote?: boolean; }): Promise { const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true); if (!skipPermissions) { return { env: input.env, notes: [], cleanup: async () => {}, }; } // For remote execution targets the host XDG_CONFIG_HOME path is meaningless // (and actively harmful — it leaks a macOS-only path into the remote Linux // env). Callers that need to ship a runtime opencode config to the remote // box do that via prepareAdapterExecutionTargetRuntime in execute.ts; this // host-fs helper is local-only. if (input.targetIsRemote) { 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 notes = [ "Injected runtime OpenCode config with permission=allow for all tools and connections.", ]; // Merge gateway/custom provider definitions supplied via PAPERCLIP_OPENCODE_PROVIDERS // (a JSON object in OpenCode's `provider` shape). OpenCode resolves a `--model // provider/model` only when that model exists in a provider's `models` map, and // OPENCODE_ALLOW_ALL_MODELS does NOT bypass its internal getModel(). So routing a // gateway model (e.g. an EU LLM gateway exposing OpenAI-compatible /v1) requires a // custom provider with an explicit models map. We accept it as config (not // hard-coded) so the gateway URL, key env, and model list stay declarative. const resolveEnv = (name: string): string | undefined => input.env[name] ?? process.env[name]; const gatewayProviders = parseProviderConfig( input.env.PAPERCLIP_OPENCODE_PROVIDERS ?? process.env.PAPERCLIP_OPENCODE_PROVIDERS, resolveEnv, notes, ); const existingProvider = isPlainObject(existingConfig.provider) ? existingConfig.provider : {}; let nextProvider = gatewayProviders ? { ...existingProvider, ...gatewayProviders } : existingProvider; if (gatewayProviders) { notes.push( `Injected ${Object.keys(gatewayProviders).length} custom OpenCode provider(s) from PAPERCLIP_OPENCODE_PROVIDERS: ${Object.keys(gatewayProviders).join(", ")}.`, ); } // Register the configured model on its provider's models map. OpenCode resolves // `--model provider/model` only when the model id exists in that map, so ids the // models.dev catalog does not carry — OpenRouter routing variants such as // `openai/gpt-oss-120b:nitro`, or models newer than the bundled catalog — are // otherwise rejected with "Model not found" even though the provider serves them. // An empty entry deep-merges with catalog metadata, so this is a no-op for models // the catalog already knows, and we never clobber an explicit definition from the // user config or PAPERCLIP_OPENCODE_PROVIDERS. const configuredModel = parseConfiguredModelRef(input.config.model); if (configuredModel) { const providerEntry = isPlainObject(nextProvider[configuredModel.provider]) ? { ...(nextProvider[configuredModel.provider] as Record) } : {}; const providerModels = isPlainObject(providerEntry.models) ? { ...(providerEntry.models as Record) } : {}; if (!isPlainObject(providerModels[configuredModel.model])) { providerModels[configuredModel.model] = {}; providerEntry.models = providerModels; nextProvider = { ...nextProvider, [configuredModel.provider]: providerEntry }; notes.push( `Registered configured model ${configuredModel.provider}/${configuredModel.model} in the runtime OpenCode config.`, ); } } const nextConfig: Record = { ...existingConfig, permission: "allow", }; if (Object.keys(nextProvider).length > 0) { nextConfig.provider = nextProvider; } // Pin OpenCode's auxiliary "small" model (used for session-title generation and // other helper tasks) via PAPERCLIP_OPENCODE_SMALL_MODEL. OpenCode otherwise // defaults the small model to a built-in provider default (e.g. a claude-* model // for the anthropic provider); when that provider is repointed at a gateway that // does not serve that exact model, the title-gen call fails and aborts the run. // Setting small_model to a gateway-served model keeps every call on supported models. const smallModel = (input.env.PAPERCLIP_OPENCODE_SMALL_MODEL ?? process.env.PAPERCLIP_OPENCODE_SMALL_MODEL)?.trim(); if (smallModel) { nextConfig.small_model = smallModel; notes.push(`Pinned OpenCode small_model to ${smallModel}.`); } await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8"); return { env: { ...input.env, XDG_CONFIG_HOME: runtimeConfigHome, }, notes, cleanup: async () => { await fs.rm(runtimeConfigHome, { recursive: true, force: true }); }, }; } /** Managed credentials must never leave host-only homes in a remote process. */ export function prepareManagedOpenCodeRemoteHomes(input: { env: Record; config: Record; runtimeRootDir: string | null | undefined; runId: string; configDir?: string; }): void { if (!input.config.managedAiConnection) return; if (!input.runtimeRootDir) throw new Error("Managed OpenCode authentication requires an isolated remote runtime directory."); const home = path.posix.join(input.runtimeRootDir, "managed-auth", input.runId); Object.assign(input.env, { HOME: home, XDG_CONFIG_HOME: input.configDir ?? path.posix.join(home, "config"), XDG_DATA_HOME: path.posix.join(home, "data"), XDG_CACHE_HOME: path.posix.join(home, "cache"), XDG_STATE_HOME: path.posix.join(home, "state"), }); }