diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 42f272c90d4..1475f9a3710 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -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 } +/** 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()("@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, diff --git a/packages/core/src/shell/parse.ts b/packages/core/src/shell/parse.ts index a9588bbebc6..5f68468b04c 100644 --- a/packages/core/src/shell/parse.ts +++ b/packages/core/src/shell/parse.ts @@ -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 } diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index 0aac63e0341..830939e04d5 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -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"})`, diff --git a/packages/core/src/tool/plugin/patch.ts b/packages/core/src/tool/plugin/patch.ts index eaccf3a8a9a..90b580f6a62 100644 --- a/packages/core/src/tool/plugin/patch.ts +++ b/packages/core/src/tool/plugin/patch.ts @@ -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) => { diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index faee4c479d3..ee4da7d9dde 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -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)), + ) }) diff --git a/packages/core/test/shell-parse.test.ts b/packages/core/test/shell-parse.test.ts index 2212711cd9c..5ad4a43e928 100644 --- a/packages/core/test/shell-parse.test.ts +++ b/packages/core/test/shell-parse.test.ts @@ -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"), )