diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 7a38872d2d1..c122edb74bf 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -53,6 +53,19 @@ Worked examples: `credentials.json` from the sandbox image's own `$HOME/.claude`. The snapshot's Claude login is the credential source for the run. +### OpenCode sandbox memory pressure + +On managed Linux sandboxes, the Legacy OpenCode adapter adds a local plugin through +OpenCode's [`shell.env` hook](https://opencode.ai/docs/plugins/#inject-environment-variables). +The hook supplies a Bash startup file only to shell subprocesses. It sets their +Linux `oom_score_adj` to `1000`, so the kernel prefers to kill memory-heavy tool +commands before OpenCode when the sandbox runs out of memory. + +An existing `BASH_ENV` takes precedence. Local and SSH execution keep their +existing behavior. The guard does not increase the sandbox memory limit, apply +to other shells, or prevent OpenCode itself from exhausting memory. If the +runtime cannot set the process preference, the command still runs. + ### Hermes local vs gateway Use `hermes_local` when Paperclip should start the local `hermes` CLI on the diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index b03352698e6..cdbd1510f11 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -178,6 +178,17 @@ Automated: [live run routes](../../server/src/__tests__/agent-live-run-routes.te Manual: Start a disposable task, open its running transcript, and follow it to a terminal outcome. Reload and compare task/run IDs, status, output, and cost. Verify a failed run remains distinguishable from a completed run with useful output. +For Legacy OpenCode on a disposable Linux sandbox, run a Bash tool that reads +`/proc/self/oom_score_adj` and its parent's score. The tool should report `1000`; +OpenCode should retain its original score. Repeat with an operator `BASH_ENV` +and confirm that it takes precedence. Repeat with a linked operator plugins +directory; it must remain unchanged, and the uploaded runtime must contain a +real plugins directory with the guard and existing plugins. Under controlled memory pressure, record +the cgroup OOM counters and verify that a killed tool can return an error while +the agent continues. Local and SSH execution must keep their original shell +environment. See the [adapter contract](../../docs/adapters/overview.md#opencode-sandbox-memory-pressure). +These controls do not guarantee that every memory exhaustion preserves the agent. + An explicit runner change starts a configuration revision and invalidates the eligible session. Active runs, restart recovery and historical transcript parsing retain their recorded execution configuration; do not infer their runner from a diff --git a/packages/adapters/opencode-local/src/server/execute.timeout.test.ts b/packages/adapters/opencode-local/src/server/execute.timeout.test.ts index eac949db088..1d1b98f8871 100644 --- a/packages/adapters/opencode-local/src/server/execute.timeout.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.timeout.test.ts @@ -21,6 +21,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => runAdapterExecutionTargetProcess: mocks.process, })); import { execute } from "./execute.js"; +import { prepareAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target"; const rawOutput = "private-output-canary"; const rawError = "504 Gateway Time-out private-provider-token-canary"; @@ -80,6 +81,21 @@ describe("OpenCode timeout reporting", () => { expect(mocks.stop).toHaveBeenCalledTimes(1); }); + it("uploads the sandbox shell plugin and launches with its remote config home", async () => { + vi.mocked(prepareAdapterExecutionTargetRuntime).mockImplementationOnce(async (input) => { + const asset = input.assets?.find(asset => asset.key === "xdgConfig"); + expect(asset).toBeDefined(); + const plugins = await fs.readdir(path.join(asset!.localDir, "opencode", "plugins")); + expect(plugins).toHaveLength(1); + return { target: input.target!, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, + runtimeRootDir: "/workspace/runtime", workspaceRemoteDir: "/workspace", + assetDirs: { xdgConfig: "/workspace/runtime/config" }, restoreWorkspace: mocks.restore }; + }); + await execute(context(0)); + expect(mocks.process.mock.calls[0]![4].env.XDG_CONFIG_HOME).toBe("/workspace/runtime/config"); + expect(mocks.process.mock.calls[0]![4].env).not.toHaveProperty("BASH_ENV"); + }); + it("runs the sandbox CLI through the actual process seam with session dispatch and incremental output", async () => { const actual = await vi.importActual("@paperclipai/adapter-utils/execution-target"); mocks.process.mockImplementation(actual.runAdapterExecutionTargetProcess); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index e9cbac64618..17c1799ca22 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -330,7 +330,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? preparedRuntimeConfig.env.XDG_CONFIG_HOME : ""; try { diff --git a/packages/adapters/opencode-local/src/server/runtime-config.test.ts b/packages/adapters/opencode-local/src/server/runtime-config.test.ts index b75a9fa5b72..e5ed0fb4c6a 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.test.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.test.ts @@ -1,7 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/adapter-utils/openrouter-attribution"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; @@ -32,6 +34,115 @@ async function makeConfigHome(initialConfig?: Record) { } describe("prepareOpenCodeRuntimeConfig", () => { + it("stages a relocatable sandbox shell hook without changing the parent environment", async () => { + const configHome = await makeConfigHome({ plugin: ["operator-plugin"], permission: { bash: "ask" } }); + const input = { env: { XDG_CONFIG_HOME: configHome }, config: { dangerouslySkipPermissions: false }, sandboxToolOomPreference: true }; + const prepared = await prepareOpenCodeRuntimeConfig(input); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const copiedHome = await makeConfigHome(); + const relocated = path.join(copiedHome, "remote config 'with spaces'"); + await fs.cp(prepared.env.XDG_CONFIG_HOME, relocated, { recursive: true }); + await prepared.cleanup(); + const pluginDir = path.join(relocated, "opencode", "plugins"); + const plugins = await fs.readdir(pluginDir); + expect(plugins).toHaveLength(1); + const pluginUrl = pathToFileURL(path.join(pluginDir, plugins[0]!)).href; + const hookEnv = JSON.parse(execFileSync(process.execPath, ["--input-type=module", "-e", ` + Object.defineProperty(process, "platform", { value: "linux" }); + delete process.env.BASH_ENV; + const hooks = await Object.values(await import(${JSON.stringify(pluginUrl)}))[0](); + const output = { env: {} }; + await hooks["shell.env"]({}, output); + console.log(JSON.stringify({ child: output.env, parent: process.env.BASH_ENV ?? null })); + `], { encoding: "utf8" })); + expect(hookEnv.parent).toBeNull(); + expect(hookEnv.child.BASH_ENV).toContain(relocated); + const command = spawnSync("bash", ["-c", 'printf "%s" "$1"; exit 7', "bash", "literal ' $() value"], { + env: { ...process.env, ...hookEnv.child }, encoding: "utf8", + }); + expect(command.stdout).toBe("literal ' $() value"); + expect(command.status).toBe(7); + if (process.platform === "linux") { + const parentScore = await fs.readFile("/proc/self/oom_score_adj", "utf8"); + expect(execFileSync("bash", ["-c", "cat /proc/self/oom_score_adj"], { + env: { ...process.env, ...hookEnv.child }, encoding: "utf8", + }).trim()).toBe("1000"); + expect(await fs.readFile("/proc/self/oom_score_adj", "utf8")).toBe(parentScore); + } + const config = JSON.parse(await fs.readFile(path.join(relocated, "opencode", "opencode.json"), "utf8")); + expect(config).toMatchObject({ plugin: ["operator-plugin"], permission: { bash: "ask" } }); + expect(prepared.env).not.toHaveProperty("BASH_ENV"); + }); + + it("preserves operator shell setup and leaves other platforms and local runs alone", async () => { + const configHome = await makeConfigHome(); + const input = { env: { XDG_CONFIG_HOME: configHome }, config: {}, sandboxToolOomPreference: true }; + const prepared = await prepareOpenCodeRuntimeConfig(input); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const pluginDir = path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "plugins"); + const pluginUrl = pathToFileURL(path.join(pluginDir, (await fs.readdir(pluginDir))[0]!)).href; + for (const scenario of [ + { platform: "linux", parent: "/operator/setup.sh", child: {} }, + { platform: "linux", parent: "", child: {} }, + { platform: "linux", parent: null, child: { BASH_ENV: "/other-plugin/setup.sh" } }, + { platform: "darwin", parent: null, child: {} }, + ]) { + const result = JSON.parse(execFileSync(process.execPath, ["--input-type=module", "-e", ` + const scenario = ${JSON.stringify(scenario)}; + Object.defineProperty(process, "platform", { value: scenario.platform }); + if (scenario.parent === null) delete process.env.BASH_ENV; + else process.env.BASH_ENV = scenario.parent; + const hooks = await Object.values(await import(${JSON.stringify(pluginUrl)}))[0](); + const output = { env: scenario.child }; + await hooks["shell.env"]({}, output); + console.log(JSON.stringify(output.env)); + `], { encoding: "utf8" })); + expect(result).toEqual(scenario.child); + } + const local = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome }, config: {} }); + cleanupPaths.add(local.env.XDG_CONFIG_HOME); + expect(await fs.readdir(path.join(local.env.XDG_CONFIG_HOME, "opencode"))).toEqual(["opencode.json"]); + }); + + it("keeps the existing runtime usable when the optional plugin cannot be installed", async () => { + const configHome = await makeConfigHome({ theme: "system" }); + const rename = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("fixture write denied")); + try { + const input = { env: { XDG_CONFIG_HOME: configHome }, config: {}, sandboxToolOomPreference: true }; + const prepared = await prepareOpenCodeRuntimeConfig(input); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const dir = path.join(prepared.env.XDG_CONFIG_HOME, "opencode"); + expect(await fs.readdir(path.join(dir, "plugins"))).toEqual([]); + expect(await fs.readdir(dir)).toEqual(["opencode.json", "plugins"]); + expect(prepared.notes.some(note => note.startsWith("Could not prepare"))).toBe(true); + expect(JSON.parse(await fs.readFile(path.join(dir, "opencode.json"), "utf8"))).toMatchObject({ theme: "system" }); + } finally { + rename.mockRestore(); + } + }); + + it("materializes linked plugin directories without writing into the operator configuration", async () => { + const configHome = await makeConfigHome(); + const operatorRoot = await makeConfigHome(); + const operatorPlugins = path.join(operatorRoot, "plugins"); + await fs.mkdir(operatorPlugins); + const operatorPlugin = "export const Operator = async () => ({});\n"; + await fs.writeFile(path.join(operatorPlugins, "operator.js"), operatorPlugin); + await fs.symlink(operatorPlugins, path.join(configHome, "opencode", "plugins"), "dir"); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome }, config: {}, sandboxToolOomPreference: true, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + expect(await fs.readdir(operatorPlugins)).toEqual(["operator.js"]); + const plugins = path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "plugins"); + expect((await fs.lstat(plugins)).isDirectory()).toBe(true); + expect(await fs.readdir(plugins)).toHaveLength(2); + expect(await fs.readFile(path.join(plugins, "operator.js"), "utf8")).toBe(operatorPlugin); + await prepared.cleanup(); + expect(await fs.readdir(operatorPlugins)).toEqual(["operator.js"]); + expect((await fs.lstat(path.join(configHome, "opencode", "plugins"))).isSymbolicLink()).toBe(true); + }); + it("attributes custom OpenRouter aliases and inline overrides without changing other endpoints", async () => { const other = { options: { baseURL: "https://other.example/v1", headers: { "X-Title": "Other" } } }; const configHome = await makeConfigHome({ provider: { other, openrouter: other } }); diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts index d8fe0fb4602..9dc6ec4208b 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { asBoolean } from "@paperclipai/adapter-utils/server-utils"; +import { installSandboxToolMemoryPreference } from "./sandbox-tool-memory.js"; type PreparedOpenCodeRuntimeConfig = { env: Record; @@ -107,6 +108,7 @@ export async function prepareOpenCodeRuntimeConfig(input: { env: Record; config: Record; targetIsRemote?: boolean; + sandboxToolOomPreference?: boolean; }): Promise { const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true); @@ -228,6 +230,14 @@ export async function prepareOpenCodeRuntimeConfig(input: { notes.push(`Pinned OpenCode small_model to ${smallModel}.`); } await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8"); + if (input.sandboxToolOomPreference) { + try { + await installSandboxToolMemoryPreference(runtimeConfigDir); + notes.push("Prepared sandbox Bash tool memory preference; shell commands are preferred OOM victims over OpenCode."); + } catch { + notes.push("Could not prepare sandbox Bash tool memory preference; continuing with the existing shell behavior."); + } + } return { env: { diff --git a/packages/adapters/opencode-local/src/server/sandbox-tool-memory.ts b/packages/adapters/opencode-local/src/server/sandbox-tool-memory.ts new file mode 100644 index 00000000000..94a8f9192a7 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/sandbox-tool-memory.ts @@ -0,0 +1,53 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +async function preparePluginDirectory(configDir: string, pluginDir: string): Promise { + const stat = await fs.lstat(pluginDir).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!stat?.isSymbolicLink()) { + await fs.mkdir(pluginDir, { recursive: true, mode: 0o700 }); + return; + } + const linkTarget = await fs.readlink(pluginDir); + const detached = await fs.mkdtemp(path.join(configDir, "paperclip-plugins-")); + try { + await fs.cp(await fs.realpath(pluginDir), detached, { recursive: true, dereference: false }); + await fs.unlink(pluginDir); + try { + await fs.rename(detached, pluginDir); + } catch (error) { + await fs.symlink(linkTarget, pluginDir, "dir"); + throw error; + } + } finally { + await fs.rm(detached, { recursive: true, force: true }); + } +} + +export async function installSandboxToolMemoryPreference(configDir: string): Promise { + const supportDir = await fs.mkdtemp(path.join(configDir, "paperclip-tool-memory-")); + const pluginDir = path.join(configDir, "plugins"); + const pluginPath = path.join(pluginDir, `${path.basename(supportDir)}.js`); + const partialPath = `${pluginPath}.partial`; + try { + await fs.writeFile(path.join(supportDir, "bash-env.sh"), + "{ printf '1000\\n' > /proc/self/oom_score_adj; } 2>/dev/null || true\n", { mode: 0o600 }); + await preparePluginDirectory(configDir, pluginDir); + const scriptUrl = `../${path.basename(supportDir)}/bash-env.sh`; + await fs.writeFile(partialPath, `import { fileURLToPath } from "node:url"; +export const PaperclipSandboxToolMemory = async () => ({ + "shell.env": async (_input, output) => { + if (process.platform !== "linux" || "BASH_ENV" in process.env || "BASH_ENV" in output.env) return; + output.env.BASH_ENV = fileURLToPath(new URL(${JSON.stringify(scriptUrl)}, import.meta.url)); + }, +}); +`, { mode: 0o600 }); + await fs.rename(partialPath, pluginPath); + } catch (error) { + await fs.rm(partialPath, { force: true }).catch(() => {}); + await fs.rm(supportDir, { recursive: true, force: true }).catch(() => {}); + throw error; + } +}