Files
PaperClipAI/packages/adapters/opencode-local/src/server/runtime-config.test.ts
T
DottaandPaperclip 7d8552f22b Preserve sandbox home in managed OpenCode runtime configuration
Move the optional HOME helper and focused coverage ahead of its caller so the runtime hardening layer remains under the review file limit.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-12 18:29:56 -05:00

358 lines
15 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { prepareManagedOpenCodeRemoteHomes, prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
const cleanupPaths = new Set<string>();
describe("prepareManagedOpenCodeRemoteHomes", () => {
it("keeps the sandbox home while isolating managed credentials outside scoped folders", () => {
const env = { HOME: "/host/private", XDG_DATA_HOME: "/host/data" };
prepareManagedOpenCodeRemoteHomes({
env,
config: { managedAiConnection: true },
runtimeRootDir: "/home/daytona/.paperclip-runtime/opencode",
runId: "run-1",
configDir: "/home/daytona/.paperclip-runtime/opencode/config",
workFolderHome: "/home/daytona",
});
expect(env).toEqual({
HOME: "/home/daytona",
XDG_CONFIG_HOME: "/home/daytona/.paperclip-runtime/opencode/config",
XDG_DATA_HOME: "/home/daytona/.paperclip-runtime/opencode/managed-auth/run-1/data",
XDG_CACHE_HOME: "/home/daytona/.paperclip-runtime/opencode/managed-auth/run-1/cache",
XDG_STATE_HOME: "/home/daytona/.paperclip-runtime/opencode/managed-auth/run-1/state",
});
});
it("preserves the isolated remote home for execution without work folders", () => {
const env: Record<string, string> = { HOME: "/host/private" };
prepareManagedOpenCodeRemoteHomes({
env,
config: { managedAiConnection: true },
runtimeRootDir: "/remote/runtime",
runId: "run-1",
});
expect(env.HOME).toBe("/remote/runtime/managed-auth/run-1");
expect(env.XDG_DATA_HOME).toBe("/remote/runtime/managed-auth/run-1/data");
});
});
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("merges custom providers from PAPERCLIP_OPENCODE_PROVIDERS into the config", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const providers = {
bifrost: {
npm: "@ai-sdk/openai-compatible",
name: "Bifrost EU",
options: {
baseURL: "http://gateway.example.svc.cluster.local:8080/v1",
apiKey: "{env:ANTHROPIC_API_KEY}",
},
models: { "example/model-a": { name: "Model A" } },
},
};
const prepared = await prepareOpenCodeRuntimeConfig({
env: {
XDG_CONFIG_HOME: configHome,
PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers),
},
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
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({
permission: { read: "allow", external_directory: "allow" },
provider: providers,
});
expect(prepared.notes.some((n) => n.includes("bifrost"))).toBe(true);
await prepared.cleanup();
});
it("reads PAPERCLIP_OPENCODE_PROVIDERS from process.env when absent from the run env", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const providers = { bifrost: { npm: "@ai-sdk/openai-compatible", models: { "example/model-a": {} } } };
process.env.PAPERCLIP_OPENCODE_PROVIDERS = JSON.stringify(providers);
try {
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome },
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
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({ provider: providers });
await prepared.cleanup();
} finally {
delete process.env.PAPERCLIP_OPENCODE_PROVIDERS;
}
});
it("expands {env:VAR} placeholders in custom providers using the run/process env (bakes the literal vk)", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const providers = {
bifrost: {
npm: "@ai-sdk/openai-compatible",
options: { baseURL: "http://bifrost/v1", apiKey: "{env:ANTHROPIC_API_KEY}" },
models: { "example/model-a": {} },
},
};
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers), ANTHROPIC_API_KEY: "sk-bf-REALVK" },
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as { provider: { bifrost: { options: { apiKey: string } } } };
// The {env:...} placeholder must be replaced with the literal value, so OpenCode
// does not depend on its sandboxed process env carrying the key.
expect(runtimeConfig.provider.bifrost.options.apiKey).toBe("sk-bf-REALVK");
await prepared.cleanup();
});
it("leaves an unresolvable {env:VAR} placeholder intact", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const providers = { bifrost: { options: { apiKey: "{env:DEFINITELY_UNSET_VAR_XYZ}" }, models: { "x/y": {} } } };
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers) },
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as { provider: { bifrost: { options: { apiKey: string } } } };
expect(runtimeConfig.provider.bifrost.options.apiKey).toBe("{env:DEFINITELY_UNSET_VAR_XYZ}");
await prepared.cleanup();
});
it("pins small_model from PAPERCLIP_OPENCODE_SMALL_MODEL", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_SMALL_MODEL: "example/model-a" },
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as { small_model?: string };
expect(runtimeConfig.small_model).toBe("example/model-a");
await prepared.cleanup();
});
it("ignores malformed PAPERCLIP_OPENCODE_PROVIDERS without writing a provider block and surfaces a note", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: "not json" },
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as Record<string, unknown>;
expect(runtimeConfig.provider).toBeUndefined();
expect(prepared.notes).toContain(
"PAPERCLIP_OPENCODE_PROVIDERS contains invalid JSON; custom providers ignored.",
);
await prepared.cleanup();
});
it("surfaces a note when PAPERCLIP_OPENCODE_PROVIDERS is valid JSON but not an object", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: "[1,2,3]" },
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as Record<string, unknown>;
expect(runtimeConfig.provider).toBeUndefined();
expect(prepared.notes).toContain(
"PAPERCLIP_OPENCODE_PROVIDERS is set but is not a JSON object; custom providers ignored.",
);
await prepared.cleanup();
});
it("surfaces skipped provider entries with non-object values and keeps the usable ones", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const prepared = await prepareOpenCodeRuntimeConfig({
env: {
XDG_CONFIG_HOME: configHome,
PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify({
bifrost: "http://gateway.example/v1",
usable: { options: { baseURL: "http://gateway.example/v1" } },
}),
},
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as { provider?: Record<string, unknown> };
expect(runtimeConfig.provider?.usable).toBeDefined();
expect(runtimeConfig.provider?.bifrost).toBeUndefined();
expect(prepared.notes).toContain(
"PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: bifrost.",
);
await prepared.cleanup();
});
it("surfaces skipped provider entries when no usable entries remain", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const prepared = await prepareOpenCodeRuntimeConfig({
env: {
XDG_CONFIG_HOME: configHome,
PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify({ bifrost: "http://gateway.example/v1" }),
},
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as Record<string, unknown>;
expect(runtimeConfig.provider).toBeUndefined();
expect(prepared.notes).toContain(
"PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: bifrost.",
);
await prepared.cleanup();
});
it("registers a configured model missing from the catalog on its provider", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome },
config: { model: "openrouter/openai/gpt-oss-120b:nitro" },
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as { provider?: Record<string, { models?: Record<string, unknown> }> };
expect(runtimeConfig.provider?.openrouter?.models).toEqual({
"openai/gpt-oss-120b:nitro": {},
});
expect(prepared.notes).toContain(
"Registered configured model openrouter/openai/gpt-oss-120b:nitro in the runtime OpenCode config.",
);
await prepared.cleanup();
});
it("does not clobber an explicit model definition when registering the configured model", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const providers = {
openrouter: {
models: {
"openai/gpt-oss-120b:nitro": { name: "GPT-OSS 120B (nitro)" },
"example/other": {},
},
},
};
const prepared = await prepareOpenCodeRuntimeConfig({
env: {
XDG_CONFIG_HOME: configHome,
PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers),
},
config: { model: "openrouter/openai/gpt-oss-120b:nitro" },
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as { provider?: Record<string, { models?: Record<string, unknown> }> };
expect(runtimeConfig.provider?.openrouter?.models).toEqual(providers.openrouter.models);
expect(
prepared.notes.some((note) => note.startsWith("Registered configured model")),
).toBe(false);
await prepared.cleanup();
});
it("skips model registration when the configured model is not provider/model shaped", async () => {
const configHome = await makeConfigHome({ permission: { read: "allow" } });
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome },
config: { model: "not-a-provider-model" },
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
const runtimeConfig = JSON.parse(
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as Record<string, unknown>;
expect(runtimeConfig.provider).toBeUndefined();
await prepared.cleanup();
});
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();
});
});