Просмотр исходного кода

feat: add OPENCODE_DISABLE_PROJECT_CONFIG env var (#8093)

Co-authored-by: Aiden Cline <[email protected]>
Kenny 2 месяцев назад
Родитель
Сommit
a18ae2c8b7

+ 17 - 11
packages/opencode/src/config/config.ts

@@ -80,10 +80,12 @@ export namespace Config {
     }
     }
 
 
     // Project config has highest precedence (overrides global and remote)
     // Project config has highest precedence (overrides global and remote)
-    for (const file of ["opencode.jsonc", "opencode.json"]) {
-      const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
-      for (const resolved of found.toReversed()) {
-        result = mergeConfigConcatArrays(result, await loadFile(resolved))
+    if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+      for (const file of ["opencode.jsonc", "opencode.json"]) {
+        const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
+        for (const resolved of found.toReversed()) {
+          result = mergeConfigConcatArrays(result, await loadFile(resolved))
+        }
       }
       }
     }
     }
 
 
@@ -99,13 +101,17 @@ export namespace Config {
 
 
     const directories = [
     const directories = [
       Global.Path.config,
       Global.Path.config,
-      ...(await Array.fromAsync(
-        Filesystem.up({
-          targets: [".opencode"],
-          start: Instance.directory,
-          stop: Instance.worktree,
-        }),
-      )),
+      // Only scan project .opencode/ directories when project discovery is enabled
+      ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+        ? await Array.fromAsync(
+            Filesystem.up({
+              targets: [".opencode"],
+              start: Instance.directory,
+              stop: Instance.worktree,
+            }),
+          )
+        : []),
+      // Always scan ~/.opencode/ (user home directory)
       ...(await Array.fromAsync(
       ...(await Array.fromAsync(
         Filesystem.up({
         Filesystem.up({
           targets: [".opencode"],
           targets: [".opencode"],

+ 29 - 6
packages/opencode/src/flag/flag.ts

@@ -1,8 +1,13 @@
+function truthy(key: string) {
+  const value = process.env[key]?.toLowerCase()
+  return value === "true" || value === "1"
+}
+
 export namespace Flag {
 export namespace Flag {
   export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
   export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
   export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
   export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
   export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
   export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
-  export const OPENCODE_CONFIG_DIR = process.env["OPENCODE_CONFIG_DIR"]
+  export declare const OPENCODE_CONFIG_DIR: string | undefined
   export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
   export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
   export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
   export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
   export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
   export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
@@ -18,6 +23,7 @@ export namespace Flag {
     OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
     OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
   export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
   export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
     OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
     OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
+  export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
   export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
   export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
   export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
   export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
   export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
   export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
@@ -41,11 +47,6 @@ export namespace Flag {
   export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
   export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
   export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
   export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
 
 
-  function truthy(key: string) {
-    const value = process.env[key]?.toLowerCase()
-    return value === "true" || value === "1"
-  }
-
   function number(key: string) {
   function number(key: string) {
     const value = process.env[key]
     const value = process.env[key]
     if (!value) return undefined
     if (!value) return undefined
@@ -53,3 +54,25 @@ export namespace Flag {
     return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
     return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
   }
   }
 }
 }
+
+// Dynamic getter for OPENCODE_DISABLE_PROJECT_CONFIG
+// This must be evaluated at access time, not module load time,
+// because external tooling may set this env var at runtime
+Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
+  get() {
+    return truthy("OPENCODE_DISABLE_PROJECT_CONFIG")
+  },
+  enumerable: true,
+  configurable: false,
+})
+
+// Dynamic getter for OPENCODE_CONFIG_DIR
+// This must be evaluated at access time, not module load time,
+// because external tooling may set this env var at runtime
+Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
+  get() {
+    return process.env["OPENCODE_CONFIG_DIR"]
+  },
+  enumerable: true,
+  configurable: false,
+})

+ 23 - 6
packages/opencode/src/session/system.ts

@@ -2,6 +2,7 @@ import { Ripgrep } from "../file/ripgrep"
 import { Global } from "../global"
 import { Global } from "../global"
 import { Filesystem } from "../util/filesystem"
 import { Filesystem } from "../util/filesystem"
 import { Config } from "../config/config"
 import { Config } from "../config/config"
+import { Log } from "../util/log"
 
 
 import { Instance } from "../project/instance"
 import { Instance } from "../project/instance"
 import path from "path"
 import path from "path"
@@ -17,6 +18,19 @@ import PROMPT_CODEX from "./prompt/codex_header.txt"
 import type { Provider } from "@/provider/provider"
 import type { Provider } from "@/provider/provider"
 import { Flag } from "@/flag/flag"
 import { Flag } from "@/flag/flag"
 
 
+const log = Log.create({ service: "system-prompt" })
+
+async function resolveRelativeInstruction(instruction: string): Promise<string[]> {
+  if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+    return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
+  }
+  if (!Flag.OPENCODE_CONFIG_DIR) {
+    log.warn(`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`)
+    return []
+  }
+  return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
+}
+
 export namespace SystemPrompt {
 export namespace SystemPrompt {
   export function header(providerID: string) {
   export function header(providerID: string) {
     if (providerID.includes("anthropic")) return [PROMPT_ANTHROPIC_SPOOF.trim()]
     if (providerID.includes("anthropic")) return [PROMPT_ANTHROPIC_SPOOF.trim()]
@@ -79,11 +93,14 @@ export namespace SystemPrompt {
     const config = await Config.get()
     const config = await Config.get()
     const paths = new Set<string>()
     const paths = new Set<string>()
 
 
-    for (const localRuleFile of LOCAL_RULE_FILES) {
-      const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
-      if (matches.length > 0) {
-        matches.forEach((path) => paths.add(path))
-        break
+    // Only scan local rule files when project discovery is enabled
+    if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+      for (const localRuleFile of LOCAL_RULE_FILES) {
+        const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
+        if (matches.length > 0) {
+          matches.forEach((path) => paths.add(path))
+          break
+        }
       }
       }
     }
     }
 
 
@@ -114,7 +131,7 @@ export namespace SystemPrompt {
             }),
             }),
           ).catch(() => [])
           ).catch(() => [])
         } else {
         } else {
-          matches = await Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
+          matches = await resolveRelativeInstruction(instruction)
         }
         }
         matches.forEach((path) => paths.add(path))
         matches.forEach((path) => paths.add(path))
       }
       }

+ 202 - 0
packages/opencode/test/config/config.test.ts

@@ -1412,3 +1412,205 @@ describe("deduplicatePlugins", () => {
     })
     })
   })
   })
 })
 })
+
+describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
+  test("skips project config files when flag is set", async () => {
+    const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+    process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+    
+    try {
+      await using tmp = await tmpdir({
+        init: async (dir) => {
+          // Create a project config that would normally be loaded
+          await Bun.write(
+            path.join(dir, "opencode.json"),
+            JSON.stringify({
+              $schema: "https://opencode.ai/config.json",
+              model: "project/model",
+              username: "project-user",
+            }),
+          )
+        },
+      })
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const config = await Config.get()
+          // Project config should NOT be loaded - model should be default, not "project/model"
+          expect(config.model).not.toBe("project/model")
+          expect(config.username).not.toBe("project-user")
+        },
+      })
+    } finally {
+      if (originalEnv === undefined) {
+        delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+      } else {
+        process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
+      }
+    }
+  })
+
+  test("skips project .opencode/ directories when flag is set", async () => {
+    const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+    process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+    
+    try {
+      await using tmp = await tmpdir({
+        init: async (dir) => {
+          // Create a .opencode directory with a command
+          const opencodeDir = path.join(dir, ".opencode", "command")
+          await fs.mkdir(opencodeDir, { recursive: true })
+          await Bun.write(
+            path.join(opencodeDir, "test-cmd.md"),
+            "# Test Command\nThis is a test command.",
+          )
+        },
+      })
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const directories = await Config.directories()
+          // Project .opencode should NOT be in directories list
+          const hasProjectOpencode = directories.some(d => d.startsWith(tmp.path))
+          expect(hasProjectOpencode).toBe(false)
+        },
+      })
+    } finally {
+      if (originalEnv === undefined) {
+        delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+      } else {
+        process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
+      }
+    }
+  })
+
+  test("still loads global config when flag is set", async () => {
+    const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+    process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+    
+    try {
+      await using tmp = await tmpdir()
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          // Should still get default config (from global or defaults)
+          const config = await Config.get()
+          expect(config).toBeDefined()
+          expect(config.username).toBeDefined()
+        },
+      })
+    } finally {
+      if (originalEnv === undefined) {
+        delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+      } else {
+        process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
+      }
+    }
+  })
+
+  test("skips relative instructions with warning when flag is set but no config dir", async () => {
+    const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+    const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
+    
+    try {
+      // Ensure no config dir is set
+      delete process.env["OPENCODE_CONFIG_DIR"]
+      process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+      
+      await using tmp = await tmpdir({
+        init: async (dir) => {
+          // Create a config with relative instruction path
+          await Bun.write(
+            path.join(dir, "opencode.json"),
+            JSON.stringify({
+              $schema: "https://opencode.ai/config.json",
+              instructions: ["./CUSTOM.md"],
+            }),
+          )
+          // Create the instruction file (should be skipped)
+          await Bun.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions")
+        },
+      })
+      
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          // The relative instruction should be skipped without error
+          // We're mainly verifying this doesn't throw and the config loads
+          const config = await Config.get()
+          expect(config).toBeDefined()
+          // The instruction should have been skipped (warning logged)
+          // We can't easily test the warning was logged, but we verify
+          // the relative path didn't cause an error
+        },
+      })
+    } finally {
+      if (originalDisable === undefined) {
+        delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+      } else {
+        process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
+      }
+      if (originalConfigDir === undefined) {
+        delete process.env["OPENCODE_CONFIG_DIR"]
+      } else {
+        process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
+      }
+    }
+  })
+
+  test("OPENCODE_CONFIG_DIR still works when flag is set", async () => {
+    const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+    const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
+    
+    try {
+      await using configDirTmp = await tmpdir({
+        init: async (dir) => {
+          // Create config in the custom config dir
+          await Bun.write(
+            path.join(dir, "opencode.json"),
+            JSON.stringify({
+              $schema: "https://opencode.ai/config.json",
+              model: "configdir/model",
+            }),
+          )
+        },
+      })
+      
+      await using projectTmp = await tmpdir({
+        init: async (dir) => {
+          // Create config in project (should be ignored)
+          await Bun.write(
+            path.join(dir, "opencode.json"),
+            JSON.stringify({
+              $schema: "https://opencode.ai/config.json",
+              model: "project/model",
+            }),
+          )
+        },
+      })
+      
+      process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+      process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path
+      
+      await Instance.provide({
+        directory: projectTmp.path,
+        fn: async () => {
+          const config = await Config.get()
+          // Should load from OPENCODE_CONFIG_DIR, not project
+          expect(config.model).toBe("configdir/model")
+        },
+      })
+    } finally {
+      if (originalDisable === undefined) {
+        delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+      } else {
+        process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
+      }
+      if (originalConfigDir === undefined) {
+        delete process.env["OPENCODE_CONFIG_DIR"]
+      } else {
+        process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
+      }
+    }
+  })
+})