summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorShantur Rathore <[email protected]>2026-02-04 20:45:59 +0000
committerGitHub <[email protected]>2026-02-04 14:45:59 -0600
commit0d38e69038c9a79e53159a747bf277748d5d79c5 (patch)
treefa12a952f5770037f8c9db2a7abeb73f4b615d8e
parent9679e0c59cd7682412e35046b0fd1754476aa5ec (diff)
downloadopencode-0d38e69038c9a79e53159a747bf277748d5d79c5.tar.gz
opencode-0d38e69038c9a79e53159a747bf277748d5d79c5.zip
fix(core): skip dependency install in read-only config dirs (#12128)
-rw-r--r--packages/opencode/src/config/config.ts19
-rw-r--r--packages/opencode/test/config/config.test.ts61
2 files changed, 79 insertions, 1 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 5fde2aed8..436e82983 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -24,7 +24,7 @@ import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
-import { existsSync } from "fs"
+import { constants, existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
@@ -273,7 +273,24 @@ export namespace Config {
).catch(() => {})
}
+ async function isWritable(dir: string) {
+ try {
+ await fs.access(dir, constants.W_OK)
+ return true
+ } catch {
+ return false
+ }
+ }
+
async function needsInstall(dir: string) {
+ // Some config dirs may be read-only.
+ // Installing deps there will fail; skip installation in that case.
+ const writable = await isWritable(dir)
+ if (!writable) {
+ log.debug("config dir is not writable, skipping dependency install", { dir })
+ return false
+ }
+
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 8611d8296..1014a4968 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -566,6 +566,67 @@ test("gets config directories", async () => {
})
})
+test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => {
+ if (process.platform === "win32") return
+
+ await using tmp = await tmpdir<string>({
+ init: async (dir) => {
+ const ro = path.join(dir, "readonly")
+ await fs.mkdir(ro, { recursive: true })
+ await fs.chmod(ro, 0o555)
+ return ro
+ },
+ dispose: async (dir) => {
+ const ro = path.join(dir, "readonly")
+ await fs.chmod(ro, 0o755).catch(() => {})
+ return ro
+ },
+ })
+
+ const prev = process.env.OPENCODE_CONFIG_DIR
+ process.env.OPENCODE_CONFIG_DIR = tmp.extra
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await Config.get()
+ },
+ })
+ } finally {
+ if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
+ else process.env.OPENCODE_CONFIG_DIR = prev
+ }
+})
+
+test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
+ await using tmp = await tmpdir<string>({
+ init: async (dir) => {
+ const cfg = path.join(dir, "configdir")
+ await fs.mkdir(cfg, { recursive: true })
+ return cfg
+ },
+ })
+
+ const prev = process.env.OPENCODE_CONFIG_DIR
+ process.env.OPENCODE_CONFIG_DIR = tmp.extra
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await Config.get()
+ },
+ })
+
+ expect(await Bun.file(path.join(tmp.extra, "package.json")).exists()).toBe(true)
+ expect(await Bun.file(path.join(tmp.extra, ".gitignore")).exists()).toBe(true)
+ } finally {
+ if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
+ else process.env.OPENCODE_CONFIG_DIR = prev
+ }
+})
+
test("resolves scoped npm plugins in config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {