Procházet zdrojové kódy

fix(config): resolve agent/command names from relative paths (#28359)

Kit Langton před 3 měsíci
rodič
revize
e94d46af86

+ 3 - 3
packages/opencode/src/config/agent.ts

@@ -1,5 +1,6 @@
 export * as ConfigAgent from "./agent"
 
+import path from "path"
 import { Exit, Schema, SchemaGetter } from "effect"
 import { PositiveInt } from "@opencode-ai/core/schema"
 import * as Log from "@opencode-ai/core/util/log"
@@ -116,8 +117,7 @@ export async function load(dir: string) {
     })
     if (!md) continue
 
-    const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
-    const name = configEntryNameFromPath(item, patterns)
+    const name = configEntryNameFromPath(path.relative(dir, item), ["agent/", "agents/"])
 
     const config = {
       name,
@@ -144,7 +144,7 @@ export async function loadMode(dir: string) {
     if (!md) continue
 
     const config = {
-      name: configEntryNameFromPath(item, []),
+      name: configEntryNameFromPath(path.relative(dir, item), ["mode/", "modes/"]),
       ...md.data,
       prompt: md.content.trim(),
     }

+ 2 - 2
packages/opencode/src/config/command.ts

@@ -1,5 +1,6 @@
 export * as ConfigCommand from "./command"
 
+import path from "path"
 import * as Log from "@opencode-ai/core/util/log"
 import { Cause, Exit, Schema } from "effect"
 import { Glob } from "@opencode-ai/core/util/glob"
@@ -36,8 +37,7 @@ export async function load(dir: string) {
     })
     if (!md) continue
 
-    const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
-    const name = configEntryNameFromPath(item, patterns)
+    const name = configEntryNameFromPath(path.relative(dir, item), ["command/", "commands/"])
 
     const config = {
       name,

+ 11 - 8
packages/opencode/src/config/entry-name.ts

@@ -1,16 +1,19 @@
 import path from "path"
 
-function sliceAfterMatch(filePath: string, searchRoots: string[]) {
-  const normalizedPath = filePath.replaceAll("\\", "/")
-  for (const searchRoot of searchRoots) {
-    const index = normalizedPath.indexOf(searchRoot)
-    if (index === -1) continue
-    return normalizedPath.slice(index + searchRoot.length)
+// Strips a known prefix from an already-relative path. Callers should pass the
+// path relative to the directory they scanned (e.g. `path.relative(dir, item)`)
+// so the prefix match is anchored. Matching anywhere in an absolute path used
+// to mis-key agents whose home/parent segments coincidentally contained one of
+// the prefix names (see #25713).
+function stripPrefix(relativePath: string, prefixes: string[]) {
+  const normalized = relativePath.replaceAll("\\", "/")
+  for (const prefix of prefixes) {
+    if (normalized.startsWith(prefix)) return normalized.slice(prefix.length)
   }
 }
 
-export function configEntryNameFromPath(filePath: string, searchRoots: string[]) {
-  const candidate = sliceAfterMatch(filePath, searchRoots) ?? path.basename(filePath)
+export function configEntryNameFromPath(relativePath: string, prefixes: string[]) {
+  const candidate = stripPrefix(relativePath, prefixes) ?? path.basename(relativePath)
   const ext = path.extname(candidate)
   return ext.length ? candidate.slice(0, -ext.length) : candidate
 }

+ 57 - 0
packages/opencode/test/config/entry-name.test.ts

@@ -0,0 +1,57 @@
+import { describe, expect, test } from "bun:test"
+import { posix } from "path"
+import { configEntryNameFromPath } from "@/config/entry-name"
+
+// Use POSIX semantics so the test is deterministic regardless of host OS —
+// production code passes paths through `path.relative` on the runtime
+// platform, but the helper normalizes via `replaceAll("\\", "/")`, so the
+// regression assertion ("the helper returns the bare name") holds on either
+// platform as long as we feed it a relative path. Using `posix.relative`
+// keeps the intermediate values stable across CI runners.
+
+// The prefixes shipped by config/agent.ts after the relative-path refactor.
+const AGENT_PREFIXES = ["agent/", "agents/"]
+
+describe("configEntryNameFromPath", () => {
+  test("strips an `agents/` prefix and returns the bare name", () => {
+    expect(configEntryNameFromPath("agents/build.md", AGENT_PREFIXES)).toBe("build")
+  })
+
+  test("strips an `agent/` (singular) prefix", () => {
+    expect(configEntryNameFromPath("agent/build.md", AGENT_PREFIXES)).toBe("build")
+  })
+
+  test("preserves nested subdirectories in the key", () => {
+    expect(configEntryNameFromPath("agents/team/build.md", AGENT_PREFIXES)).toBe("team/build")
+  })
+
+  test("normalizes Windows-style backslashes", () => {
+    expect(configEntryNameFromPath("agents\\team\\build.md", AGENT_PREFIXES)).toBe("team/build")
+  })
+
+  test("falls back to basename when no prefix matches", () => {
+    expect(configEntryNameFromPath("orphaned.md", AGENT_PREFIXES)).toBe("orphaned")
+    expect(configEntryNameFromPath("anywhere/orphaned.md", [])).toBe("orphaned")
+  })
+
+  // Regression for #25713: a username (or any parent segment) containing
+  // `agent` or `agents` used to win the substring match before the real
+  // `agents/` directory could match, leaking the entire intervening path into
+  // the agent key (e.g. `.config/opencode/agents/build`). Anchoring at the
+  // caller via `path.relative(dir, item)` makes this impossible — the relative
+  // path is always rooted at `agent/` or `agents/`.
+  test("regression #25713: caller passes relative path; parent /agent/ segment is irrelevant", () => {
+    const dir = "/home/agent/.config/opencode"
+    const item = "/home/agent/.config/opencode/agents/build.md"
+    const relative = posix.relative(dir, item)
+    expect(relative).toBe("agents/build.md")
+    expect(configEntryNameFromPath(relative, AGENT_PREFIXES)).toBe("build")
+  })
+
+  test("regression #25713: parent /agents/ segment is irrelevant", () => {
+    const dir = "/srv/agents/team/.config/opencode"
+    const item = "/srv/agents/team/.config/opencode/agents/build.md"
+    const relative = posix.relative(dir, item)
+    expect(configEntryNameFromPath(relative, AGENT_PREFIXES)).toBe("build")
+  })
+})