diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 56688f22cf..379301cd29 100644 --- a/packages/opencode/src/config/agent.ts +++ b/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(), } diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index f6b93ead11..d5046b6a17 100644 --- a/packages/opencode/src/config/command.ts +++ b/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, diff --git a/packages/opencode/src/config/entry-name.ts b/packages/opencode/src/config/entry-name.ts index a553152c97..e0a788e663 100644 --- a/packages/opencode/src/config/entry-name.ts +++ b/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 } diff --git a/packages/opencode/test/config/entry-name.test.ts b/packages/opencode/test/config/entry-name.test.ts new file mode 100644 index 0000000000..bb29ef63ad --- /dev/null +++ b/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") + }) +})