028c5aa00a
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The OpenCode adapter runs against local, SSH, and sandbox execution targets > - The Test path's hello probe spreads the Paperclip host's `process.env` into the remote process env, which over SSH gets exported on the remote shell > - On a Linux SSH target, `HOME=/Users/...` and a host XDG_CONFIG_HOME pointing at a macOS `/var/folders/...` temp dir cause OpenCode to walk a host-only path and fail with `EACCES: permission denied, mkdir '/Users'` > - This pull request stops the leak by passing only user-configured adapter env to the probe when the target is remote, matching the pattern already used by claude-local, codex-local, and gemini-local > - The benefit is the OpenCode hello probe now passes end-to-end against an SSH target without spurious filesystem errors ## What Changed - `prepareOpenCodeRuntimeConfig` short-circuits when the target is remote — the host-fs temp config dir is meaningless and harmful for a remote target - `test.ts` passes only the user-configured adapter env (no host `process.env` spread) to `runAdapterExecutionTargetProcess` when `targetIsRemote` - Local probes still get the full `runtimeEnv` so headless permission injection keeps working ## Verification - `pnpm vitest run --no-coverage --project @paperclipai/adapter-opencode-local` - `pnpm typecheck` clean - Manual: SSH OpenCode hello probe goes from `EACCES … mkdir '/Users'` to `opencode_hello_probe_passed` ## Risks Low risk — local probe behavior is unchanged; the change only narrows the env passed to remote targets, matching the pattern already shipped in sibling adapters. ## Model Used Claude Opus 4.7 (1M context) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable — pattern mirrors existing sibling tests - [x] If this change affects the UI, I have included before/after screenshots — N/A (no UI) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
106 lines
3.2 KiB
TypeScript
106 lines
3.2 KiB
TypeScript
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>;
|
|
targetIsRemote?: boolean;
|
|
}): Promise<PreparedOpenCodeRuntimeConfig> {
|
|
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 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 });
|
|
},
|
|
};
|
|
}
|