diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index f9201be8a7db..040ad84ae0de 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -12,6 +12,7 @@ import { LSP } from "@/lsp/lsp" import { FSUtil } from "@opencode-ai/core/fs-util" import DESCRIPTION from "./apply_patch.txt" import { FileSystem } from "@opencode-ai/core/filesystem" +import { relative } from "./relative" import { Format } from "../format" import * as Bom from "@/util/bom" @@ -193,7 +194,7 @@ export const ApplyPatchTool = Tool.define( // Build per-file metadata for UI rendering (used for both permission and result) const files = fileChanges.map((change) => ({ filePath: change.filePath, - relativePath: path.relative(instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), + relativePath: relative(instance, change.movePath ?? change.filePath).replaceAll("\\", "/"), type: change.type, patch: change.diff, additions: change.additions, @@ -202,7 +203,7 @@ export const ApplyPatchTool = Tool.define( })) // Check permissions if needed - const relativePaths = fileChanges.map((c) => path.relative(instance.worktree, c.filePath).replaceAll("\\", "/")) + const relativePaths = fileChanges.map((c) => relative(instance, c.filePath).replaceAll("\\", "/")) yield* ctx.ask({ permission: "edit", patterns: relativePaths, @@ -273,13 +274,13 @@ export const ApplyPatchTool = Tool.define( // Generate output summary const summaryLines = fileChanges.map((change) => { if (change.type === "add") { - return `A ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `A ${relative(instance, change.filePath).replaceAll("\\", "/")}` } if (change.type === "delete") { - return `D ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `D ${relative(instance, change.filePath).replaceAll("\\", "/")}` } const target = change.movePath ?? change.filePath - return `M ${path.relative(instance.worktree, target).replaceAll("\\", "/")}` + return `M ${relative(instance, target).replaceAll("\\", "/")}` }) let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` @@ -288,7 +289,7 @@ export const ApplyPatchTool = Tool.define( const target = change.movePath ?? change.filePath const block = LSP.Diagnostic.report(target, diagnostics[FSUtil.normalizePath(target)] ?? []) if (!block) continue - const rel = path.relative(instance.worktree, target).replaceAll("\\", "/") + const rel = relative(instance, target).replaceAll("\\", "/") output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}` } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a92e4720c0fc..35d695c67bec 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -17,6 +17,7 @@ import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { FSUtil } from "@opencode-ai/core/fs-util" +import { relative } from "./relative" import * as Bom from "@/util/bom" function normalizeLineEndings(text: string): string { @@ -81,6 +82,7 @@ export const EditTool = Tool.define( ? params.filePath : path.join(instance.directory, params.filePath) yield* assertExternalDirectoryEffect(ctx, filePath) + const rel = relative(instance, filePath) let diff = "" let contentOld = "" @@ -101,7 +103,7 @@ export const EditTool = Tool.define( diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) yield* ctx.ask({ permission: "edit", - patterns: [path.relative(instance.worktree, filePath)], + patterns: [rel], always: ["*"], metadata: { filepath: filePath, @@ -144,7 +146,7 @@ export const EditTool = Tool.define( ) yield* ctx.ask({ permission: "edit", - patterns: [path.relative(instance.worktree, filePath)], + patterns: [rel], always: ["*"], metadata: { filepath: filePath, @@ -206,7 +208,7 @@ export const EditTool = Tool.define( diff, filediff, }, - title: `${path.relative(instance.worktree, filePath)}`, + title: rel, output, } }), diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index a605cea749d5..a9b23e084bfb 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -7,6 +7,7 @@ import { InstanceState } from "@/effect/instance-state" import { pathToFileURL } from "url" import { assertExternalDirectoryEffect } from "./external-directory" import { FSUtil } from "@opencode-ai/core/fs-util" +import { relative } from "./relative" const operations = [ "goToDefinition", @@ -62,7 +63,7 @@ export const LspTool = Tool.define( const uri = pathToFileURL(file).href const position = { file, line: args.line - 1, character: args.character - 1 } - const relPath = path.relative(instance.worktree, file) + const relPath = relative(instance, file) const detail = args.operation === "workspaceSymbol" ? "" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 678ed4451048..e7e55ded38cd 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt" import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" +import { relative } from "./relative" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 @@ -238,7 +239,7 @@ export const ReadTool = Tool.define< if (process.platform === "win32") { filepath = FSUtil.normalizePath(filepath) } - const title = path.relative(instance.worktree, filepath) + const rel = relative(instance, filepath) const stat = yield* fs.stat(filepath).pipe( Effect.catchIf( @@ -254,7 +255,7 @@ export const ReadTool = Tool.define< yield* ctx.ask({ permission: "read", - patterns: [path.relative(instance.worktree, filepath)], + patterns: [rel], always: ["*"], metadata: {}, }) @@ -270,7 +271,7 @@ export const ReadTool = Tool.define< const truncated = start + sliced.length < items.length return { - title, + title: rel, output: [ `${filepath}`, `directory`, @@ -307,7 +308,7 @@ export const ReadTool = Tool.define< const bytes = yield* fs.readFile(filepath) const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" return { - title, + title: rel, output: msg, metadata: { preview: msg, @@ -357,7 +358,7 @@ export const ReadTool = Tool.define< } return { - title, + title: rel, output, metadata: { preview: file.raw.slice(0, 20).join("\n"), diff --git a/packages/opencode/src/tool/relative.ts b/packages/opencode/src/tool/relative.ts new file mode 100644 index 000000000000..df484b52f997 --- /dev/null +++ b/packages/opencode/src/tool/relative.ts @@ -0,0 +1,8 @@ +import path from "path" +import type { InstanceContext } from "@/project/instance-context" + +export function relative(instance: InstanceContext, file: string) { + + const root = instance.worktree === "/" ? instance.directory : instance.worktree + return path.relative(root, file) +} diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 37be6d8c47bc..0dccad816505 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -13,6 +13,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { InstanceState } from "@/effect/instance-state" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" +import { relative } from "./relative" import * as Bom from "@/util/bom" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -49,11 +50,13 @@ export const WriteTool = Tool.define( const desiredBom = source.bom || next.bom const contentOld = source.text const contentNew = next.text + + const rel = relative(instance, filepath) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) yield* ctx.ask({ permission: "edit", - patterns: [path.relative(instance.worktree, filepath)], + patterns: [rel], always: ["*"], metadata: { filepath, @@ -90,7 +93,7 @@ export const WriteTool = Tool.define( } return { - title: path.relative(instance.worktree, filepath), + title: rel, metadata: { diagnostics, filepath, diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 01a09add8732..ec8f8109c11b 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -188,6 +188,20 @@ describe("tool.apply_patch freeform", () => { { git: true }, ) + it.instance("uses directory-relative permission paths in non-git projects", () => + Effect.gen(function* () { + const { ctx, calls } = makeCtx() + + const patchText = "*** Begin Patch\n*** Add File: .agents/new.txt\n+created\n*** End Patch" + + yield* execute({ patchText }, ctx) + + expect(calls).toHaveLength(1) + expect(calls[0]?.patterns).toEqual([".agents/new.txt"]) + expect(calls[0]?.metadata.files[0]?.relativePath).toBe(".agents/new.txt") + }), + ) + it.instance("applies multiple hunks to one file", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 12db53551856..23bf63d05176 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -143,6 +143,28 @@ describe("tool.edit", () => { yield* Deferred.await(updated) }), ) + + it.instance("uses directory-relative permission paths in non-git projects", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, ".agents", "file.txt") + const calls: Array<{ patterns: readonly string[] }> = [] + + yield* run( + { filePath: filepath, oldString: "", newString: "new content" }, + { + ...ctx, + ask: (input) => + Effect.sync(() => { + calls.push({ patterns: input.patterns }) + }), + }, + ) + + expect(calls).toHaveLength(1) + expect(calls[0]?.patterns).toEqual([path.join(".agents", "file.txt")]) + }), + ) }) describe("editing existing files", () => { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 67205f56e384..88b4cac13ab3 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -146,6 +146,21 @@ const asks = () => { } describe("tool.read external_directory permission", () => { + it.live("uses directory-relative read permission paths in non-git projects", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, ".agents", "test.txt"), "hello world") + + const { items, next } = asks() + + const result = yield* exec(dir, { filePath: path.join(dir, ".agents", "test.txt") }, next) + expect(result.output).toContain("hello world") + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns).toEqual([path.join(".agents", "test.txt")]) + }), + ) + it.live("allows reading absolute path inside project directory", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 63a2a52aa986..bbe50073ad8d 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -91,6 +91,31 @@ describe("tool.write", () => { expect(content).toBe("relative content") }), ) + + it.instance("uses directory-relative permission paths in non-git projects", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, ".agents", "file.txt") + const calls: Array<{ patterns: readonly string[] }> = [] + + yield* run( + { + filePath: filepath, + content: "content", + }, + { + ...ctx, + ask: (input) => + Effect.sync(() => { + calls.push({ patterns: input.patterns }) + }), + }, + ) + + expect(calls).toHaveLength(1) + expect(calls[0]?.patterns).toEqual([path.join(".agents", "file.txt")]) + }), + ) }) describe("existing file overwrite", () => {