feat(core): expand tildes in tool path resolution (#45605)

This commit is contained in:
Aiden Cline
2026-08-27 13:41:39 -05:00
committed by GitHub
Unverified
parent d3694a5383
commit a609174969
6 changed files with 74 additions and 13 deletions
+20 -7
View File
@@ -4,6 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
@@ -13,9 +14,9 @@ export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
/**
* Mutation paths do not accept project references. Relative paths resolve
* from the active Location. Paths outside it require separate
* `external_directory` approval.
* Mutation paths do not accept project references. A leading `~` expands to
* the home directory; other relative paths resolve from the active Location.
* Paths outside it require separate `external_directory` approval.
*/
export const ResolveInput = Schema.Struct({
path: Schema.String,
@@ -49,13 +50,25 @@ export interface Target {
export interface Interface {
/**
* Resolve a path and derive its permission resources. Relative paths resolve
* from the Location. Paths outside it require separate `external_directory`
* approval. This does not approve the mutation.
* Resolve a path and derive its permission resources. A leading `~` expands
* to the home directory; other relative paths resolve from the Location.
* Paths outside it require separate `external_directory` approval. This does
* not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
/** Lexical absolute path, expanding a leading `~` before resolving against `directory`. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) =>
path.resolve(
directory,
input === "~"
? home
: input.startsWith("~/") || (process.platform === "win32" && input.startsWith("~\\"))
? path.join(home, input.slice(2))
: input,
)
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
const slash = (value: string) => value.replaceAll("\\", "/")
@@ -68,7 +81,7 @@ const layer = Layer.effect(
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
const absolute = resolvePath(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) {
return {
absolute,
+3 -1
View File
@@ -329,7 +329,9 @@ function expandKnownDirectory(value: string) {
// Unknown shell expressions cannot be resolved safely during permission analysis.
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
if (value === "~") return os.homedir()
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
if (value.startsWith("~/") || (process.platform === "win32" && value.startsWith("~\\"))) {
return path.join(os.homedir(), value.slice(2))
}
return value
}
+1 -2
View File
@@ -11,7 +11,6 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
@@ -219,7 +218,7 @@ export const Plugin = {
replacements,
} satisfies Output
}).pipe(
fileMutation.withLock([path.resolve(location.directory, input.path)]),
fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+4 -3
View File
@@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Effect, Result, Schema } from "effect"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment/index.js"
import { Formatter } from "../../formatter.js"
@@ -87,8 +86,10 @@ export const Plugin = {
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
path.resolve(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
LocationMutation.resolvePath(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath
? [LocationMutation.resolvePath(location.directory, hunk.movePath)]
: []),
])
: []
const fail = (operation: string, error: unknown) => {
@@ -6,6 +6,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
@@ -196,4 +197,44 @@ describe("LocationMutation", () => {
path: "README.md",
})
})
test("expands a leading tilde against the home directory", () => {
const home = path.resolve("/Users/aiden")
expect(LocationMutation.resolvePath("/project", "~", home)).toBe(home)
expect(LocationMutation.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
expect(LocationMutation.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
expect(LocationMutation.resolvePath("/project", "~\\notes.md", home)).toBe(
process.platform === "win32"
? path.resolve(home, "notes.md")
: path.resolve("/project", "~\\notes.md"),
)
})
it.live("resolves a tilde path as an external home target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
resource: absolute.replaceAll("\\", "/"),
})
expect(target.externalDirectory).toMatchObject({
directory: Global.Path.home,
resource: path.join(Global.Path.home, "*").replaceAll("\\", "/"),
})
}).pipe(provide(directory)),
),
)
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
})
expect(target.externalDirectory).toBeUndefined()
}).pipe(provide(Global.Path.home)),
)
})
+5
View File
@@ -137,6 +137,11 @@ describe("ShellParse", () => {
const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace"))
expect(bash.directories).toEqual([path.join(os.homedir(), "src")])
const backslash = await Effect.runPromise(ShellParse.scan("cd '~\\src'", "/bin/bash", "/workspace"))
expect(backslash.directories).toEqual(
process.platform === "win32" ? [path.join(os.homedir(), "src")] : ["~\\src"],
)
const powershell = await Effect.runPromise(
ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"),
)