diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 25959fab5..be10eb541 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -85,3 +85,14 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin } export { expect } + +/** + * Check if KILO credentials are available for tests requiring connected providers. + * Model picker tests require connected providers which need these credentials. + * @returns true if credentials are missing + */ +export function missingKiloCredentials() { + const hasKey = !!process.env.KILO_API_KEY + const hasOrg = !!process.env.KILO_ORG_ID + return !hasKey || !hasOrg +} diff --git a/packages/app/e2e/models/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts index 01e72464c..e860f9585 100644 --- a/packages/app/e2e/models/model-picker.spec.ts +++ b/packages/app/e2e/models/model-picker.spec.ts @@ -1,8 +1,9 @@ -import { test, expect } from "../fixtures" +import { test, expect, missingKiloCredentials } from "../fixtures" import { promptSelector } from "../selectors" import { clickListItem } from "../actions" test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { + test.skip(missingKiloCredentials(), "KILO_API_KEY or KILO_ORG_ID not set") await gotoSession() await page.locator(promptSelector).click() diff --git a/packages/app/e2e/models/models-visibility.spec.ts b/packages/app/e2e/models/models-visibility.spec.ts index c69911179..b0c475334 100644 --- a/packages/app/e2e/models/models-visibility.spec.ts +++ b/packages/app/e2e/models/models-visibility.spec.ts @@ -1,8 +1,9 @@ -import { test, expect } from "../fixtures" +import { test, expect, missingKiloCredentials } from "../fixtures" import { promptSelector } from "../selectors" import { closeDialog, openSettings, clickListItem } from "../actions" test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { + test.skip(missingKiloCredentials(), "KILO_API_KEY or KILO_ORG_ID not set") await gotoSession() await page.locator(promptSelector).click() diff --git a/packages/app/e2e/settings/settings-models.spec.ts b/packages/app/e2e/settings/settings-models.spec.ts index f7397abe8..610bd8dca 100644 --- a/packages/app/e2e/settings/settings-models.spec.ts +++ b/packages/app/e2e/settings/settings-models.spec.ts @@ -1,8 +1,9 @@ -import { test, expect } from "../fixtures" +import { test, expect, missingKiloCredentials } from "../fixtures" import { promptSelector } from "../selectors" import { closeDialog, openSettings } from "../actions" test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { + test.skip(missingKiloCredentials(), "KILO_API_KEY or KILO_ORG_ID not set") await gotoSession() await page.locator(promptSelector).click() @@ -61,6 +62,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi }) test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => { + test.skip(missingKiloCredentials(), "KILO_API_KEY or KILO_ORG_ID not set") await gotoSession() await page.locator(promptSelector).click() diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts deleted file mode 120000 index e4ea0d6ce..000000000 --- a/packages/app/src/custom-elements.d.ts +++ /dev/null @@ -1 +0,0 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts new file mode 100644 index 000000000..72f87bc24 --- /dev/null +++ b/packages/app/src/custom-elements.d.ts @@ -0,0 +1,18 @@ +// kilocode_change - copy content instead of symlink for Windows compatibility +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. + */ + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9ef680ed8..ef8c1a34c 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -74,7 +74,12 @@ const createPlatform = (password: Accessor): Platform => ({ multiple: opts?.multiple ?? false, title: opts?.title ?? t("desktop.dialog.chooseFolder"), }) - return result + // kilocode_change - normalize paths to use forward slashes + if (result === null) return null + if (Array.isArray(result)) { + return result.map((p) => p.replace(/\\/g, "/")) + } + return result.replace(/\\/g, "/") }, async openFilePickerDialog(opts) { @@ -83,7 +88,12 @@ const createPlatform = (password: Accessor): Platform => ({ multiple: opts?.multiple ?? false, title: opts?.title ?? t("desktop.dialog.chooseFile"), }) - return result + // kilocode_change - normalize paths to use forward slashes + if (result === null) return null + if (Array.isArray(result)) { + return result.map((p) => p.replace(/\\/g, "/")) + } + return result.replace(/\\/g, "/") }, async saveFilePickerDialog(opts) { @@ -91,7 +101,9 @@ const createPlatform = (password: Accessor): Platform => ({ title: opts?.title ?? t("desktop.dialog.saveFile"), defaultPath: opts?.defaultPath, }) - return result + // kilocode_change - normalize paths to use forward slashes + if (result === null) return null + return result.replace(/\\/g, "/") }, openLink(url: string) { diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts deleted file mode 120000 index e4ea0d6ce..000000000 --- a/packages/enterprise/src/custom-elements.d.ts +++ /dev/null @@ -1 +0,0 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts new file mode 100644 index 000000000..72f87bc24 --- /dev/null +++ b/packages/enterprise/src/custom-elements.d.ts @@ -0,0 +1,18 @@ +// kilocode_change - copy content instead of symlink for Windows compatibility +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. + */ + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 9c4b9645a..5119d562a 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -21,6 +21,7 @@ import { Plugin } from "@/plugin" import { Skill } from "../skill" import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change +import { Filesystem } from "@/util/filesystem" export namespace Agent { export const Info = z @@ -103,12 +104,12 @@ export namespace Agent { question: "allow", plan_exit: "allow", external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", + [Filesystem.join(Global.Path.data, "plans", "*")]: "allow", }, edit: { "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + [Filesystem.join(".opencode", "plans", "*.md")]: "allow", + [Filesystem.relative(Instance.worktree, Filesystem.join(Global.Path.data, "plans", "*.md"))]: "allow", }, }), user, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index cacc40e1a..3284aea84 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -831,7 +831,7 @@ export function Session() { } else { const exportDir = process.cwd() const filename = options.filename.trim() - const filepath = path.join(exportDir, filename) + const filepath = Filesystem.join(exportDir, filename) await Bun.write(filepath, transcript) @@ -1647,7 +1647,7 @@ function Bash(props: ToolProps) { const base = sync.data.path.directory if (!base) return undefined - const absolute = path.resolve(base, workdir) + const absolute = Filesystem.resolve(base, workdir) if (absolute === base) return undefined const home = Global.Path.home @@ -1701,7 +1701,7 @@ function Write(props: ToolProps) { }) const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + const filePath = Filesystem.realpath(props.input.filePath ?? "") return props.metadata.diagnostics?.[filePath] ?? [] }) @@ -1911,7 +1911,7 @@ function Edit(props: ToolProps) { const diffContent = createMemo(() => props.metadata.diff) const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + const filePath = Filesystem.realpath(props.input.filePath ?? "") const arr = props.metadata.diagnostics?.[filePath] ?? [] return arr.filter((x) => x.severity === 1).slice(0, 3) }) @@ -2104,7 +2104,7 @@ function Skill(props: ToolProps) { function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { - return path.relative(process.cwd(), input) || "." + return Filesystem.relative(process.cwd(), input) || "." } return input } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index aff48d2fc..27ef28409 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@/global" import { useDialog } from "../../ui/dialog" +import { Filesystem } from "@/util/filesystem" type PermissionStage = "permission" | "always" | "reject" @@ -23,8 +24,8 @@ function normalizePath(input?: string) { const cwd = process.cwd() const home = Global.Path.home - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) + const absolute = path.isAbsolute(input) ? input : Filesystem.resolve(cwd, input) + const relative = Filesystem.relative(cwd, absolute) if (!relative) return "." if (!relative.startsWith("..")) return relative @@ -248,7 +249,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const derived = typeof pattern === "string" ? pattern.includes("*") - ? path.dirname(pattern) + ? Filesystem.dirname(pattern) : pattern : undefined diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c70894ffe..8e3dcf1df 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -372,10 +372,13 @@ export namespace Config { } function rel(item: string, patterns: string[]) { + // Normalize the item path first + const normalizedItem = Filesystem.normalize(item) for (const pattern of patterns) { - const index = item.indexOf(pattern) + const normalizedPattern = Filesystem.normalize(pattern) + const index = normalizedItem.indexOf(normalizedPattern) if (index === -1) continue - return item.slice(index + pattern.length) + return normalizedItem.slice(index + normalizedPattern.length) } } diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 7230f67af..5c3cf595b 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -1,4 +1,4 @@ -import { sep } from "node:path" +import { Filesystem } from "../util/filesystem" export namespace FileIgnore { const FOLDERS = new Set([ @@ -64,18 +64,21 @@ export namespace FileIgnore { whitelist?: Bun.Glob[] }, ) { + // Normalize path to use forward slashes + const normalizedPath = Filesystem.normalize(filepath) + for (const glob of opts?.whitelist || []) { - if (glob.match(filepath)) return false + if (glob.match(normalizedPath)) return false } - const parts = filepath.split(sep) + const parts = normalizedPath.split("/") for (let i = 0; i < parts.length; i++) { if (FOLDERS.has(parts[i])) return true } const extra = opts?.extra || [] for (const glob of [...FILE_GLOBS, ...extra]) { - if (glob.match(filepath)) return true + if (glob.match(normalizedPath)) return true } return false diff --git a/packages/opencode/src/kilocode/paths.ts b/packages/opencode/src/kilocode/paths.ts index e784f517b..0b7373fcc 100644 --- a/packages/opencode/src/kilocode/paths.ts +++ b/packages/opencode/src/kilocode/paths.ts @@ -62,7 +62,7 @@ export namespace KilocodePaths { }), ) for (const dir of projectDirs) { - const skillsDir = path.join(dir, "skills") + const skillsDir = Filesystem.join(dir, "skills") if (await Filesystem.isDir(skillsDir)) { directories.push(dir) // Return parent (.kilocode/), not skills/ } @@ -71,14 +71,14 @@ export namespace KilocodePaths { if (!opts.skipGlobalPaths) { // 2. Global ~/.kilocode/ const global = globalDir() - const globalSkills = path.join(global, "skills") + const globalSkills = Filesystem.join(global, "skills") if (await Filesystem.isDir(globalSkills)) { directories.push(global) // Return parent, not skills/ } // 3. VSCode extension global storage (marketplace-installed skills) const vscode = vscodeGlobalStorage() - const vscodeSkills = path.join(vscode, "skills") + const vscodeSkills = Filesystem.join(vscode, "skills") if (await Filesystem.isDir(vscodeSkills)) { directories.push(vscode) // Return parent, not skills/ } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 8704b65ac..d770bc793 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -50,7 +50,7 @@ export namespace LSPClient { const diagnostics = new Map() connection.onNotification("textDocument/publishDiagnostics", (params) => { - const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) + const filePath = Filesystem.realpath(fileURLToPath(params.uri)) l.info("textDocument/publishDiagnostics", { path: filePath, count: params.diagnostics.length, @@ -208,7 +208,7 @@ export namespace LSPClient { return diagnostics }, async waitForDiagnostics(input: { path: string }) { - const normalizedPath = Filesystem.normalizePath( + const normalizedPath = Filesystem.realpath( path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), ) log.info("waiting for diagnostics", { path: normalizedPath }) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 0fd3b69df..57ce3ad7a 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -10,6 +10,7 @@ import { Config } from "../config/config" import { spawn } from "child_process" import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" +import { Filesystem } from "@/util/filesystem" export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -111,9 +112,10 @@ export namespace LSP { root: existing?.root ?? (async () => Instance.directory), extensions: item.extensions ?? existing?.extensions ?? [], spawn: async (root) => { + const normalizedRoot = Filesystem.normalize(root) return { process: spawn(item.command[0], item.command.slice(1), { - cwd: root, + cwd: normalizedRoot, env: { ...process.env, ...item.env, diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 0efeff544..278080966 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -79,23 +79,23 @@ export namespace Patch { const line = lines[startIdx] if (line.startsWith("*** Add File:")) { - const filePath = line.split(":", 2)[1]?.trim() + const filePath = line.substring("*** Add File:".length).trim() return filePath ? { filePath, nextIdx: startIdx + 1 } : null } if (line.startsWith("*** Delete File:")) { - const filePath = line.split(":", 2)[1]?.trim() + const filePath = line.substring("*** Delete File:".length).trim() return filePath ? { filePath, nextIdx: startIdx + 1 } : null } if (line.startsWith("*** Update File:")) { - const filePath = line.split(":", 2)[1]?.trim() + const filePath = line.substring("*** Update File:".length).trim() let movePath: string | undefined let nextIdx = startIdx + 1 // Check for move directive if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) { - movePath = lines[nextIdx].split(":", 2)[1]?.trim() + movePath = lines[nextIdx].substring("*** Move to:".length).trim() nextIdx++ } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d..2f296d5fa 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -20,13 +20,14 @@ const disposal = { export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - let existing = cache.get(input.directory) + const directory = Filesystem.normalize(input.directory) + let existing = cache.get(directory) if (!existing) { - Log.Default.info("creating instance", { directory: input.directory }) + Log.Default.info("creating instance", { directory }) existing = iife(async () => { - const { project, sandbox } = await Project.fromDirectory(input.directory) + const { project, sandbox } = await Project.fromDirectory(directory) const ctx = { - directory: input.directory, + directory: directory, worktree: sandbox, project, } @@ -35,7 +36,7 @@ export const Instance = { }) return ctx }) - cache.set(input.directory, existing) + cache.set(directory, existing) } const ctx = await existing return context.provide(ctx, async () => { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f6902de4e..66c09ed57 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -58,12 +58,12 @@ export namespace Project { const git = await matches.next().then((x) => x.value) await matches.return() if (git) { - let sandbox = path.dirname(git) + let sandbox = Filesystem.dirname(git) const gitBinary = Bun.which("git") // cached id calculation - let id = await Bun.file(path.join(git, "opencode")) + let id = await Bun.file(Filesystem.join(git, "opencode")) .text() .then((x) => x.trim()) .catch(() => undefined) @@ -104,7 +104,7 @@ export namespace Project { id = roots[0] if (id) { - void Bun.file(path.join(git, "opencode")) + void Bun.file(Filesystem.join(git, "opencode")) .write(id) .catch(() => undefined) } @@ -124,7 +124,7 @@ export namespace Project { .nothrow() .cwd(sandbox) .text() - .then((x) => path.resolve(sandbox, x.trim())) + .then((x) => Filesystem.resolve(sandbox, x.trim())) .catch(() => undefined) if (!top) { @@ -144,7 +144,7 @@ export namespace Project { .cwd(sandbox) .text() .then((x) => { - const dirname = path.dirname(x.trim()) + const dirname = Filesystem.dirname(x.trim()) if (dirname === ".") return sandbox return dirname }) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index a27ee9a74..c644d2cbe 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -8,6 +8,7 @@ import type { WSContext } from "hono/ws" import { Instance } from "../project/instance" import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" +import { Filesystem } from "@/util/filesystem" import { Plugin } from "@/plugin" export namespace Pty { @@ -102,7 +103,13 @@ export namespace Pty { args.push("-l") } - const cwd = input.cwd || Instance.directory + const cwd = Filesystem.normalize(input.cwd || Instance.directory) + + // Validate directory exists + if (!(await Filesystem.isDir(cwd))) { + throw new Error(`Directory does not exist: ${cwd}`) + } + const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} }) const env = { ...process.env, diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 3850376bd..48c1bc97e 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -16,6 +16,7 @@ import { Log } from "../../util/log" import { PermissionNext } from "@/permission/next" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Filesystem } from "@/util/filesystem" const log = Log.create({ service: "server" }) @@ -54,9 +55,12 @@ export const SessionRoutes = lazy(() => async (c) => { const query = c.req.valid("query") const term = query.search?.toLowerCase() + // Normalize directory path for comparison + const normalizedQueryDir = query.directory ? Filesystem.normalize(query.directory) : undefined const sessions: Session.Info[] = [] for await (const session of Session.list()) { - if (query.directory !== undefined && session.directory !== query.directory) continue + if (normalizedQueryDir !== undefined && Filesystem.normalize(session.directory) !== normalizedQueryDir) + continue if (query.roots && session.parentID) continue if (query.start !== undefined && session.time.updated < query.start) continue if (term !== undefined && !session.title.toLowerCase().includes(term)) continue diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index d96f76a80..4f055b6cf 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,5 +1,4 @@ import { Slug } from "@opencode-ai/util/slug" -import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" @@ -22,6 +21,7 @@ import { Snapshot } from "@/snapshot" import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" export namespace Session { const log = Log.create({ service: "session" }) @@ -248,9 +248,9 @@ export namespace Session { export function plan(input: { slug: string; time: { created: number } }) { const base = Instance.project.vcs - ? path.join(Instance.worktree, ".opencode", "plans") - : path.join(Global.Path.data, "plans") - return path.join(base, [input.time.created, input.slug].join("-") + ".md") + ? Filesystem.join(Instance.worktree, ".opencode", "plans") + : Filesystem.join(Global.Path.data, "plans") + return Filesystem.join(base, [input.time.created, input.slug].join("-") + ".md") } export const get = fn(Identifier.schema("session"), async (id) => { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 29e84452c..c86dfe2cd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -46,6 +46,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { Filesystem } from "@/util/filesystem" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -965,9 +966,8 @@ export namespace SessionPrompt { break case "file:": log.info("file", { mime: part.mime }) - // have to normalize, symbol search returns absolute paths - // Decode the pathname since URL constructor doesn't automatically decode it - const filepath = fileURLToPath(part.url) + // Normalize file path from URL + const filepath = Filesystem.normalize(fileURLToPath(part.url)) const stat = await Bun.file(filepath).stat() if (stat.isDirectory()) { diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index b3c8a905c..cc97f4ff7 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,12 +1,13 @@ import { $ } from "bun" -import path from "path" import fs from "fs/promises" import { Log } from "../util/log" import { Global } from "../global" import z from "zod" +import path from "path" import { Config } from "../config/config" import { Instance } from "../project/instance" import { Scheduler } from "../scheduler" +import { Filesystem } from "../util/filesystem" export namespace Snapshot { const log = Log.create({ service: "snapshot" }) @@ -104,7 +105,7 @@ export namespace Snapshot { .split("\n") .map((x) => x.trim()) .filter(Boolean) - .map((x) => path.join(Instance.worktree, x)), + .map((x) => Filesystem.join(Instance.worktree, x)), } } @@ -250,6 +251,6 @@ export namespace Snapshot { function gitdir() { const project = Instance.project - return path.join(Global.Path.data, "snapshot", project.id) + return Filesystem.join(Global.Path.data, "snapshot", project.id) } } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 1344467c7..ffe7c400b 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -161,7 +161,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { // 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), + relativePath: Filesystem.relative(Instance.worktree, change.movePath ?? change.filePath), type: change.type, diff: change.diff, before: change.oldContent, @@ -172,7 +172,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { })) // Check permissions if needed - const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)) + const relativePaths = fileChanges.map((c) => Filesystem.relative(Instance.worktree, c.filePath)) await ctx.ask({ permission: "edit", patterns: relativePaths, @@ -242,13 +242,13 @@ export const ApplyPatchTool = Tool.define("apply_patch", { // Generate output summary const summaryLines = fileChanges.map((change) => { if (change.type === "add") { - return `A ${path.relative(Instance.worktree, change.filePath)}` + return `A ${Filesystem.relative(Instance.worktree, change.filePath)}` } if (change.type === "delete") { - return `D ${path.relative(Instance.worktree, change.filePath)}` + return `D ${Filesystem.relative(Instance.worktree, change.filePath)}` } const target = change.movePath ?? change.filePath - return `M ${path.relative(Instance.worktree, target)}` + return `M ${Filesystem.relative(Instance.worktree, target)}` }) let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` @@ -257,14 +257,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", { for (const change of fileChanges) { if (change.type === "delete") continue const target = change.movePath ?? change.filePath - const normalized = Filesystem.normalizePath(target) + const normalized = Filesystem.realpath(target) const issues = diagnostics[normalized] ?? [] const errors = issues.filter((item) => item.severity === 1) if (errors.length > 0) { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` + output += `\n\nLSP errors detected in ${Filesystem.relative(Instance.worktree, target)}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` } } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 67559b78c..95f19d6f2 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -77,16 +77,21 @@ export const BashTool = Tool.define("bash", async () => { }), async execute(params, ctx) { const cwd = params.workdir || Instance.directory + const normalizedCwd = Filesystem.normalize(cwd) if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } + // Validate working directory exists to prevent Bun spawn crashes on Windows + if (!(await Filesystem.isDir(normalizedCwd))) { + throw new Error(`Working directory does not exist or is not a directory: ${cwd}`) + } const timeout = params.timeout ?? DEFAULT_TIMEOUT const tree = await parser().then((p) => p.parse(params.command)) if (!tree) { throw new Error("Failed to parse command") } const directories = new Set() - if (!Instance.containsPath(cwd)) directories.add(cwd) + if (!Instance.containsPath(normalizedCwd)) directories.add(normalizedCwd) const patterns = new Set() const always = new Set() @@ -116,24 +121,10 @@ export const BashTool = Tool.define("bash", async () => { if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { for (const arg of command.slice(1)) { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue - const resolved = await $`realpath ${arg}` - .cwd(cwd) - .quiet() - .nothrow() - .text() - .then((x) => x.trim()) - log.info("resolved path", { arg, resolved }) - if (resolved) { - // Git Bash on Windows returns Unix-style paths like /c/Users/... - const normalized = - process.platform === "win32" && resolved.match(/^\/[a-z]\//) - ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") - : resolved - if (!Instance.containsPath(normalized)) { - const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized) - directories.add(dir) - } - } + const target = Filesystem.resolve(normalizedCwd, arg) + const dir = (await Filesystem.isDir(target)) ? target : Filesystem.dirname(target) + log.info("resolved path", { arg, dir, target }) + if (!Filesystem.contains(Instance.directory, dir)) directories.add(dir) } } @@ -145,7 +136,7 @@ export const BashTool = Tool.define("bash", async () => { } if (directories.size > 0) { - const globs = Array.from(directories).map((dir) => path.join(dir, "*")) + const globs = Array.from(directories).map((dir) => Filesystem.normalize(path.join(dir, "*"))) await ctx.ask({ permission: "external_directory", patterns: globs, @@ -166,7 +157,7 @@ export const BashTool = Tool.define("bash", async () => { const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} }) const proc = spawn(params.command, { shell, - cwd, + cwd: normalizedCwd, env: { ...process.env, ...shellEnv.env, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 0bf1d6792..763ed1097 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -41,7 +41,8 @@ export const EditTool = Tool.define("edit", { throw new Error("oldString and newString must be different") } - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + const normalized = Filesystem.normalize(params.filePath) + const filePath = path.isAbsolute(normalized) ? normalized : Filesystem.join(Instance.directory, normalized) await assertExternalDirectory(ctx, filePath) let diff = "" @@ -54,7 +55,7 @@ export const EditTool = Tool.define("edit", { diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) await ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], + patterns: [Filesystem.relative(Instance.worktree, filePath)], always: ["*"], metadata: { filepath: filePath, @@ -86,7 +87,7 @@ export const EditTool = Tool.define("edit", { ) await ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], + patterns: [Filesystem.relative(Instance.worktree, filePath)], always: ["*"], metadata: { filepath: filePath, @@ -132,7 +133,7 @@ export const EditTool = Tool.define("edit", { let output = "Edit applied successfully." await LSP.touchFile(filePath, true) const diagnostics = await LSP.diagnostics() - const normalizedFilePath = Filesystem.normalizePath(filePath) + const normalizedFilePath = Filesystem.realpath(filePath) const issues = diagnostics[normalizedFilePath] ?? [] const errors = issues.filter((item) => item.severity === 1) if (errors.length > 0) { @@ -148,7 +149,7 @@ export const EditTool = Tool.define("edit", { diff, filediff, }, - title: `${path.relative(Instance.worktree, filePath)}`, + title: `${Filesystem.relative(Instance.worktree, filePath)}`, output, } }, diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 1d3958fc4..cc261d3d2 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,6 +1,7 @@ import path from "path" import type { Tool } from "./tool" import { Instance } from "../project/instance" +import { Filesystem } from "@/util/filesystem" type Kind = "file" | "directory" @@ -14,11 +15,12 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string if (options?.bypass) return + target = Filesystem.normalize(target) if (Instance.containsPath(target)) return const kind = options?.kind ?? "file" - const parentDir = kind === "directory" ? target : path.dirname(target) - const glob = path.join(parentDir, "*") + const parentDir = kind === "directory" ? target : Filesystem.dirname(target) + const glob = Filesystem.join(parentDir, "*") await ctx.ask({ permission: "external_directory", diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 6943795f8..189dba386 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -5,6 +5,7 @@ import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" +import { Filesystem } from "../util/filesystem" export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -28,8 +29,8 @@ export const GlobTool = Tool.define("glob", { }, }) - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + let search = params.path ? Filesystem.normalize(params.path) : Instance.directory + search = path.isAbsolute(search) ? search : Filesystem.resolve(Instance.directory, search) await assertExternalDirectory(ctx, search, { kind: "directory" }) const limit = 100 @@ -44,7 +45,7 @@ export const GlobTool = Tool.define("glob", { truncated = true break } - const full = path.resolve(search, file) + const full = Filesystem.resolve(search, file) const stats = await Bun.file(full) .stat() .then((x) => x.mtime.getTime()) @@ -67,7 +68,7 @@ export const GlobTool = Tool.define("glob", { } return { - title: path.relative(Instance.worktree, search), + title: Filesystem.relative(Instance.worktree, search), metadata: { count: files.length, truncated, diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index c10b4dfb8..1a8fe1f70 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -6,6 +6,7 @@ import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" import path from "path" import { assertExternalDirectory } from "./external-directory" +import { Filesystem } from "../util/filesystem" const MAX_LINE_LENGTH = 2000 @@ -33,7 +34,9 @@ export const GrepTool = Tool.define("grep", { }) let searchPath = params.path ?? Instance.directory - searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) + searchPath = path.isAbsolute(searchPath) + ? Filesystem.normalize(searchPath) + : Filesystem.resolve(Instance.directory, searchPath) await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) const rgPath = await Ripgrep.filepath() diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index b848e969b..2d7f31857 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -5,6 +5,7 @@ import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectory } from "./external-directory" +import { Filesystem } from "../util/filesystem" export const IGNORE_PATTERNS = [ "node_modules/", @@ -42,7 +43,7 @@ export const ListTool = Tool.define("list", { ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), }), async execute(params, ctx) { - const searchPath = path.resolve(Instance.directory, params.path || ".") + const searchPath = Filesystem.resolve(Instance.directory, params.path || ".") await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) await ctx.ask({ @@ -66,7 +67,7 @@ export const ListTool = Tool.define("list", { const filesByDir = new Map() for (const file of files) { - const dir = path.dirname(file) + const dir = Filesystem.dirname(file) const parts = dir === "." ? [] : dir.split("/") // Add all parent directories @@ -90,7 +91,7 @@ export const ListTool = Tool.define("list", { const childIndent = " ".repeat(depth + 1) const children = Array.from(dirs) - .filter((d) => path.dirname(d) === dirPath && d !== dirPath) + .filter((d) => Filesystem.dirname(d) === dirPath && d !== dirPath) .sort() // Render subdirectories first @@ -110,7 +111,7 @@ export const ListTool = Tool.define("list", { const output = `${searchPath}/\n` + renderDir(".", 0) return { - title: path.relative(Instance.worktree, searchPath), + title: Filesystem.relative(Instance.worktree, searchPath), metadata: { count: files.length, truncated: files.length >= LIMIT, diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index ca352280b..5d4842407 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -6,6 +6,7 @@ import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" import { pathToFileURL } from "url" import { assertExternalDirectory } from "./external-directory" +import { Filesystem } from "../util/filesystem" const operations = [ "goToDefinition", @@ -28,7 +29,7 @@ export const LspTool = Tool.define("lsp", { character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), }), execute: async (args, ctx) => { - const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) + const file = path.isAbsolute(args.filePath) ? args.filePath : Filesystem.join(Instance.directory, args.filePath) await assertExternalDirectory(ctx, file) await ctx.ask({ @@ -44,7 +45,7 @@ export const LspTool = Tool.define("lsp", { character: args.character - 1, } - const relPath = path.relative(Instance.worktree, file) + const relPath = Filesystem.relative(Instance.worktree, file) const title = `${args.operation} ${relPath}:${args.line}:${args.character}` const exists = await Bun.file(file).exists() diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 7f562f473..20ecf7a2f 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -4,6 +4,7 @@ import { EditTool } from "./edit" import DESCRIPTION from "./multiedit.txt" import path from "path" import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" export const MultiEditTool = Tool.define("multiedit", { description: DESCRIPTION, @@ -36,7 +37,7 @@ export const MultiEditTool = Tool.define("multiedit", { results.push(result) } return { - title: path.relative(Instance.worktree, params.filePath), + title: Filesystem.relative(Instance.worktree, params.filePath), metadata: { results: results.map((r) => r.metadata), }, diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index c93a52276..c3b438ff9 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,5 +1,4 @@ import z from "zod" -import path from "path" import { Tool } from "./tool" import { Question } from "../question" import { Session } from "../session" @@ -9,6 +8,7 @@ import { Provider } from "../provider/provider" import { Instance } from "../project/instance" import EXIT_DESCRIPTION from "./plan-exit.txt" import ENTER_DESCRIPTION from "./plan-enter.txt" +import { Filesystem } from "../util/filesystem" async function getLastModel(sessionID: string) { for await (const item of MessageV2.stream(sessionID)) { @@ -22,7 +22,7 @@ export const PlanExitTool = Tool.define("plan_exit", { parameters: z.object({}), async execute(_params, ctx) { const session = await Session.get(ctx.sessionID) - const plan = path.relative(Instance.worktree, Session.plan(session)) + const plan = Filesystem.relative(Instance.worktree, Session.plan(session)) const answers = await Question.ask({ sessionID: ctx.sessionID, questions: [ @@ -81,7 +81,7 @@ export const PlanEnterTool = Tool.define("plan_enter", { parameters: z.object({}), async execute(_params, ctx) { const session = await Session.get(ctx.sessionID) - const plan = path.relative(Instance.worktree, Session.plan(session)) + const plan = Filesystem.relative(Instance.worktree, Session.plan(session)) const answers = await Question.ask({ sessionID: ctx.sessionID, diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f230cdf44..efb5ab736 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 { Instance } from "../project/instance" import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" +import { Filesystem } from "../util/filesystem" import { InstructionPrompt } from "../session/instruction" const DEFAULT_READ_LIMIT = 2000 @@ -22,11 +23,11 @@ export const ReadTool = Tool.define("read", { limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(), }), async execute(params, ctx) { - let filepath = params.filePath + let filepath = Filesystem.normalize(params.filePath) if (!path.isAbsolute(filepath)) { - filepath = path.resolve(Instance.directory, filepath) + filepath = Filesystem.join(Instance.directory, filepath) } - const title = path.relative(Instance.worktree, filepath) + const title = Filesystem.relative(Instance.worktree, filepath) await assertExternalDirectory(ctx, filepath, { bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), @@ -41,7 +42,7 @@ export const ReadTool = Tool.define("read", { const file = Bun.file(filepath) if (!(await file.exists())) { - const dir = path.dirname(filepath) + const dir = Filesystem.dirname(filepath) const base = path.basename(filepath) const dirEntries = fs.readdirSync(dir) @@ -50,7 +51,7 @@ export const ReadTool = Tool.define("read", { (entry) => entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()), ) - .map((entry) => path.join(dir, entry)) + .map((entry) => Filesystem.join(dir, entry)) .slice(0, 3) if (suggestions.length > 0) { diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 84e799c13..aac67a7b8 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -1,16 +1,16 @@ import fs from "fs/promises" -import path from "path" import { Global } from "../global" import { Identifier } from "../id/id" import { PermissionNext } from "../permission/next" import type { Agent } from "../agent/agent" import { Scheduler } from "../scheduler" +import { Filesystem } from "../util/filesystem" export namespace Truncate { export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 - export const DIR = path.join(Global.Path.data, "tool-output") - export const GLOB = path.join(DIR, "*") + export const DIR = Filesystem.join(Global.Path.data, "tool-output") + export const GLOB = Filesystem.join(DIR, "*") const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days const HOUR_MS = 60 * 60 * 1000 @@ -37,7 +37,7 @@ export namespace Truncate { const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[]) for (const entry of entries) { if (Identifier.timestamp(entry) >= cutoff) continue - await fs.unlink(path.join(DIR, entry)).catch(() => {}) + await fs.unlink(Filesystem.join(DIR, entry)).catch(() => {}) } } @@ -90,7 +90,7 @@ export namespace Truncate { const preview = out.join("\n") const id = Identifier.ascending("tool") - const filepath = path.join(DIR, id) + const filepath = Filesystem.join(DIR, id) await Bun.write(Bun.file(filepath), text) const hint = hasTaskTool(agent) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index eca64d303..0fb922039 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -23,7 +23,8 @@ export const WriteTool = Tool.define("write", { filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), }), async execute(params, ctx) { - const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + const normalized = Filesystem.normalize(params.filePath) + const filepath = path.isAbsolute(normalized) ? normalized : Filesystem.resolve(Instance.directory, normalized) await assertExternalDirectory(ctx, filepath) const file = Bun.file(filepath) @@ -34,7 +35,7 @@ export const WriteTool = Tool.define("write", { const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) await ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filepath)], + patterns: [Filesystem.relative(Instance.worktree, filepath)], always: ["*"], metadata: { filepath, @@ -55,7 +56,7 @@ export const WriteTool = Tool.define("write", { let output = "Wrote file successfully." await LSP.touchFile(filepath, true) const diagnostics = await LSP.diagnostics() - const normalizedFilepath = Filesystem.normalizePath(filepath) + const normalizedFilepath = Filesystem.realpath(filepath) let projectDiagnosticsCount = 0 for (const [file, issues] of Object.entries(diagnostics)) { const errors = issues.filter((item) => item.severity === 1) @@ -73,7 +74,7 @@ export const WriteTool = Tool.define("write", { } return { - title: path.relative(Instance.worktree, filepath), + title: Filesystem.relative(Instance.worktree, filepath), metadata: { diagnostics, filepath, diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 7aff6bd1d..05a17d888 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,5 +1,7 @@ import { realpathSync } from "fs" -import { dirname, join, relative } from "path" +import { Flag } from "@/flag/flag" +import path from "path" +import { normalize as _normalize } from "@opencode-ai/util/path" export namespace Filesystem { export const exists = (p: string) => @@ -13,37 +15,59 @@ export namespace Filesystem { .stat() .then((s) => s.isDirectory()) .catch(() => false) + /** * On Windows, normalize a path to its canonical casing using the filesystem. * This is needed because Windows paths are case-insensitive but LSP servers * may return paths with different casing than what we send them. */ - export function normalizePath(p: string): string { + export function realpath(p: string): string { if (process.platform !== "win32") return p try { - return realpathSync.native(p) + return normalize(realpathSync.native(p)) } catch { return p } } - export function overlaps(a: string, b: string) { - const relA = relative(a, b) - const relB = relative(b, a) - return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") + + /** + * Normalize a path to use forward slashes on all platforms. + * On Windows, also convert MSYS and Cygwin style paths to Windows drive letter paths. + */ + export function normalize(p: string): string { + if (process.platform !== "win32") return p + return _normalize(p) + } + + export function relative(from: string, to: string) { + return normalize(path.relative(normalize(from), normalize(to))) + } + + export function resolve(...segments: string[]) { + return normalize(path.resolve(...segments)) + } + + export function join(...segments: string[]) { + return normalize(path.join(...segments)) + } + + export function dirname(p: string) { + return normalize(path.dirname(p)) } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + const path = relative(parent, child) + return !/^\.\.|.:/.test(path) } export async function findUp(target: string, start: string, stop?: string) { let current = start const result = [] while (true) { - const search = join(current, target) + const search = normalize(path.join(current, target)) if (await exists(search)) result.push(search) if (stop === current) break - const parent = dirname(current) + const parent = path.dirname(current) if (parent === current) break current = parent } @@ -55,11 +79,11 @@ export namespace Filesystem { let current = start while (true) { for (const target of targets) { - const search = join(current, target) + const search = normalize(path.join(current, target)) if (await exists(search)) yield search } if (stop === current) break - const parent = dirname(current) + const parent = path.dirname(current) if (parent === current) break current = parent } @@ -84,7 +108,7 @@ export namespace Filesystem { // Skip invalid glob patterns } if (stop === current) break - const parent = dirname(current) + const parent = path.dirname(current) if (parent === current) break current = parent } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8611d8296..c6e3706fa 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -606,7 +606,11 @@ test("resolves scoped npm plugins in config", async () => { const config = await Config.get() const pluginEntries = config.plugin ?? [] - const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href + // kilocode_change - on Windows, use regular path instead of file:// URL for import.meta.resolve + const baseUrl = + process.platform === "win32" + ? path.join(tmp.path, "opencode.json") + : pathToFileURL(path.join(tmp.path, "opencode.json")).href const expected = import.meta.resolve("@scope/plugin", baseUrl) expect(pluginEntries.includes(expected)).toBe(true) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ed8c5e344..5f52732f3 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -3,6 +3,7 @@ import * as fs from "fs/promises" import os from "os" import path from "path" import type { Config } from "../../src/config/config" +import { Filesystem } from "../../src/util/filesystem" // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { @@ -19,8 +20,15 @@ export async function tmpdir(options?: TmpDirOptions) { const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))) await fs.mkdir(dirpath, { recursive: true }) if (options?.git) { - await $`git init`.cwd(dirpath).quiet() - await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() + // kilocode_change - improve error handling for git init + const initResult = await $`git init`.cwd(dirpath).quiet().nothrow() + if (initResult.exitCode !== 0) { + throw new Error(`git init failed: ${initResult.stderr}`) + } + const commitResult = await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet().nothrow() + if (commitResult.exitCode !== 0) { + throw new Error(`git commit failed: ${commitResult.stderr}`) + } } if (options?.config) { await Bun.write( @@ -32,7 +40,8 @@ export async function tmpdir(options?: TmpDirOptions) { ) } const extra = await options?.init?.(dirpath) - const realpath = sanitizePath(await fs.realpath(dirpath)) + // kilocode_change - use Filesystem.normalize for realpath + const realpath = Filesystem.normalize(sanitizePath(await fs.realpath(dirpath))) const result = { [Symbol.asyncDispose]: async () => { await options?.dispose?.(dirpath) diff --git a/packages/opencode/test/ide/ide.test.ts b/packages/opencode/test/ide/ide.test.ts index 4d7014019..13ee6f51f 100644 --- a/packages/opencode/test/ide/ide.test.ts +++ b/packages/opencode/test/ide/ide.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test, afterEach } from "bun:test" import { Ide } from "../../src/ide" describe("ide", () => { - const original = structuredClone(process.env) + // kilocode_change - use shallow copy instead of structuredClone for process.env + const original = { ...process.env } afterEach(() => { Object.keys(process.env).forEach((key) => { diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index d44e60674..dcbf81ac2 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Project } from "../../src/project/project" import { Log } from "../../src/util/log" import { Storage } from "../../src/storage/storage" +import { Filesystem } from "../../src/util/filesystem" import { $ } from "bun" import path from "path" import { tmpdir } from "../fixture/fixture" @@ -60,10 +61,10 @@ describe("Project.fromDirectory with worktrees", () => { const { project, sandbox } = await Project.fromDirectory(worktreePath) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(worktreePath) - expect(project.sandboxes).toContain(worktreePath) - expect(project.sandboxes).not.toContain(tmp.path) + expect(Filesystem.normalize(project.worktree)).toBe(Filesystem.normalize(tmp.path)) + expect(Filesystem.normalize(sandbox)).toBe(Filesystem.normalize(worktreePath)) + expect(project.sandboxes.map(Filesystem.normalize)).toContain(Filesystem.normalize(worktreePath)) + expect(project.sandboxes.map(Filesystem.normalize)).not.toContain(Filesystem.normalize(tmp.path)) await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet() }) @@ -79,10 +80,10 @@ describe("Project.fromDirectory with worktrees", () => { await Project.fromDirectory(worktree1) const { project } = await Project.fromDirectory(worktree2) - expect(project.worktree).toBe(tmp.path) - expect(project.sandboxes).toContain(worktree1) - expect(project.sandboxes).toContain(worktree2) - expect(project.sandboxes).not.toContain(tmp.path) + expect(Filesystem.normalize(project.worktree)).toBe(Filesystem.normalize(tmp.path)) + expect(project.sandboxes.map(Filesystem.normalize)).toContain(Filesystem.normalize(worktree1)) + expect(project.sandboxes.map(Filesystem.normalize)).toContain(Filesystem.normalize(worktree2)) + expect(project.sandboxes.map(Filesystem.normalize)).not.toContain(Filesystem.normalize(tmp.path)) await $`git worktree remove ${worktree1}`.cwd(tmp.path).quiet() await $`git worktree remove ${worktree2}`.cwd(tmp.path).quiet() diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 1f7e17e1b..36fd8856f 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, beforeAll, beforeEach, describe, expect, test, mock } from "bun:test" import path from "path" import type { ModelMessage } from "ai" import { LLM } from "../../src/session/llm" @@ -11,6 +11,31 @@ import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import type { MessageV2 } from "../../src/session/message-v2" +// Mock BunProc to avoid package installation timeouts in tests +mock.module("../../src/bun/index", () => ({ + BunProc: { + install: async (pkg: string, _version?: string) => { + const lastAtIndex = pkg.lastIndexOf("@") + return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg + }, + run: async () => { + throw new Error("BunProc.run should not be called in tests") + }, + which: () => process.execPath, + InstallFailedError: class extends Error {}, + }, +})) + +// Mock Plugin to avoid loading plugins during provider initialization +mock.module("../../src/plugin/index", () => ({ + Plugin: { + list: async () => [], + load: async () => {}, + reload: async () => {}, + trigger: async (_event: string, _context: unknown, input: Record) => input, + }, +})) + describe("session.llm.hasToolCalls", () => { test("returns false for empty messages array", () => { expect(LLM.hasToolCalls([])).toBe(false) @@ -127,19 +152,19 @@ beforeAll(() => { state.server = Bun.serve({ port: 0, async fetch(req) { - const next = state.queue.shift() - if (!next) { + const url = new URL(req.url) + if (state.queue.length === 0) { return new Response("unexpected request", { status: 500 }) } - const url = new URL(req.url) - const body = (await req.json()) as Record - next.resolve({ url, headers: req.headers, body }) - - if (!url.pathname.endsWith(next.path)) { + const index = state.queue.findIndex((item) => url.pathname.endsWith(item.path)) + if (index === -1) { return new Response("not found", { status: 404 }) } + const next = state.queue.splice(index, 1)[0] + const body = (await req.json()) as Record + next.resolve({ url, headers: req.headers, body }) return next.response }, }) @@ -306,7 +331,7 @@ describe("session.llm.stream", () => { expect(url.pathname.startsWith("/v1/")).toBe(true) expect(url.pathname.endsWith("/chat/completions")).toBe(true) expect(headers.get("Authorization")).toBe("Bearer test-key") - expect(headers.get("User-Agent") ?? "").toMatch(/^opencode\//) + expect(headers.get("User-Agent") ?? "").toMatch(/(Kilo|opencode)/) expect(body.model).toBe(resolved.api.id) expect(body.temperature).toBe(0.4) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index c310256c5..c09196e58 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -4,6 +4,7 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" +import { Filesystem } from "../../src/util/filesystem" async function createGlobalSkill(homeDir: string) { const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") @@ -50,7 +51,7 @@ Instructions here. const testSkill = skills.find((s) => s.name === "test-skill") expect(testSkill).toBeDefined() expect(testSkill!.description).toBe("A test skill for verification.") - expect(testSkill!.location).toContain("skill/test-skill/SKILL.md") + expect(Filesystem.normalize(testSkill!.location)).toContain("skill/test-skill/SKILL.md") }, }) }) @@ -180,7 +181,7 @@ description: A skill in the .claude/skills directory. expect(skills.length).toBe(1) const claudeSkill = skills.find((s) => s.name === "claude-skill") expect(claudeSkill).toBeDefined() - expect(claudeSkill!.location).toContain(".claude/skills/claude-skill/SKILL.md") + expect(Filesystem.normalize(claudeSkill!.location)).toContain(".claude/skills/claude-skill/SKILL.md") }, }) }) @@ -200,7 +201,7 @@ test("discovers global skills from ~/.claude/skills/ directory", async () => { expect(skills.length).toBe(1) expect(skills[0].name).toBe("global-test-skill") expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.") - expect(skills[0].location).toContain(".claude/skills/global-test-skill/SKILL.md") + expect(Filesystem.normalize(skills[0].location)).toContain(".claude/skills/global-test-skill/SKILL.md") }, }) } finally { @@ -245,7 +246,7 @@ description: A skill in the .agents/skills directory. expect(skills.length).toBe(1) const agentSkill = skills.find((s) => s.name === "agent-skill") expect(agentSkill).toBeDefined() - expect(agentSkill!.location).toContain(".agents/skills/agent-skill/SKILL.md") + expect(Filesystem.normalize(agentSkill!.location)).toContain(".agents/skills/agent-skill/SKILL.md") }, }) }) @@ -279,7 +280,7 @@ This skill is loaded from the global home directory. expect(skills.length).toBe(1) expect(skills[0].name).toBe("global-agent-skill") expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.") - expect(skills[0].location).toContain(".agents/skills/global-agent-skill/SKILL.md") + expect(Filesystem.normalize(skills[0].location)).toContain(".agents/skills/global-agent-skill/SKILL.md") }, }) } finally { diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 091469ec7..cf032850e 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -350,7 +350,8 @@ test("very long filenames", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - const longName = "a".repeat(200) + ".txt" + // kilocode_change - use shorter path on Windows to avoid MAX_PATH issues + const longName = process.platform === "win32" ? "a".repeat(100) + ".txt" : "a".repeat(200) + ".txt" const longFile = `${tmp.path}/${longName}` await Bun.write(longFile, "long filename content") @@ -385,6 +386,9 @@ test("hidden files", async () => { }) test("nested symlinks", async () => { + // kilocode_change - skip on Windows (symlinks require admin privileges) + if (process.platform === "win32") return + await using tmp = await bootstrap() await Instance.provide({ directory: tmp.path, diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index fb4b5ab7b..d8918c67e 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -5,6 +5,7 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission/next" import { Truncate } from "../../src/tool/truncation" +import { Filesystem } from "../../src/util/filesystem" const ctx = { sessionID: "test", @@ -108,6 +109,7 @@ describe("tool.bash permissions", () => { requests.push(req) }, } + const parentDir = Filesystem.normalize(path.join(tmp.path, "..")) await bash.execute( { command: "cd ../", @@ -117,6 +119,7 @@ describe("tool.bash permissions", () => { ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns.some((p) => p.includes(Filesystem.normalize(parentDir)))).toBe(true) }, }) }) @@ -134,17 +137,18 @@ describe("tool.bash permissions", () => { requests.push(req) }, } + const tmpDir = process.platform === "win32" ? "C:/Windows/Temp" : "/tmp" await bash.execute( { command: "ls", - workdir: "/tmp", - description: "List /tmp", + workdir: tmpDir, + description: "List tmp directory", }, testCtx, ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain("/tmp/*") + expect(extDirReq!.patterns.some((p) => p.includes(Filesystem.normalize(tmpDir)))).toBe(true) }, }) }) @@ -176,7 +180,7 @@ describe("tool.bash permissions", () => { testCtx, ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const expected = path.join(outerTmp.path, "*") + const expected = Filesystem.normalize(path.join(outerTmp.path, "*")) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(expected) expect(extDirReq!.always).toContain(expected) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 716dd9a3f..9e8ae3b1d 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -4,6 +4,7 @@ import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import type { PermissionNext } from "../../src/permission/next" +import { Filesystem } from "../../src/util/filesystem" const baseCtx: Omit = { sessionID: "test", @@ -65,7 +66,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside/file.txt" - const expected = path.join(path.dirname(target), "*") + const expected = Filesystem.join(Filesystem.dirname(target), "*") await Instance.provide({ directory, @@ -91,7 +92,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside" - const expected = path.join(target, "*") + const expected = Filesystem.join(target, "*") await Instance.provide({ directory, diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 0e5f0ba38..798a82c2f 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -36,4 +36,36 @@ describe("util.filesystem", () => { await rm(tmp, { recursive: true, force: true }) }) + + test("normalize() converts paths to posix format", () => { + expect(Filesystem.normalize("C:\\Users\\test\\file.txt")).toBe("C:/Users/test/file.txt") + expect(Filesystem.normalize("/unix/path/file.txt")).toBe("/unix/path/file.txt") + expect(Filesystem.normalize("relative\\path\\file.txt")).toBe("relative/path/file.txt") + }) + + test("relative() returns posix-style relative paths", () => { + const from = "C:\\Users\\test\\project" + const to = "C:\\Users\\test\\project\\src\\file.txt" + expect(Filesystem.relative(from, to)).toBe("src/file.txt") + }) + + test("join() produces posix-style paths", () => { + expect(Filesystem.join("C:\\Users", "test", "file.txt")).toBe("C:/Users/test/file.txt") + expect(Filesystem.join("/unix", "path", "file.txt")).toBe("/unix/path/file.txt") + }) + + test("dirname() returns posix-style directory", () => { + expect(Filesystem.dirname("C:\\Users\\test\\file.txt")).toBe("C:/Users/test") + expect(Filesystem.dirname("/unix/path/file.txt")).toBe("/unix/path") + }) + + test("contains() checks if path is within directory", () => { + const dir = "C:\\Users\\test\\project" + const inside = "C:\\Users\\test\\project\\src\\file.txt" + const outside = "C:\\Users\\other\\file.txt" + + expect(Filesystem.contains(dir, inside)).toBe(true) + expect(Filesystem.contains(dir, outside)).toBe(false) + expect(Filesystem.contains(dir, dir)).toBe(true) + }) }) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 5c292c542..7018bc017 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -43,7 +43,7 @@ import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" import { findLast } from "@opencode-ai/util/array" -import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory as _getDirectory, getFilename, normalize } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" @@ -64,7 +64,9 @@ function getDiagnostics( filePath: string | undefined, ): Diagnostic[] { if (!diagnosticsByFile || !filePath) return [] - const diagnostics = diagnosticsByFile[filePath] ?? [] + // kilocode_change - normalize filePath for diagnostics lookup + const normalizedPath = normalize(filePath) + const diagnostics = diagnosticsByFile[normalizedPath] ?? diagnosticsByFile[filePath] ?? [] return diagnostics.filter((d) => d.severity === 1).slice(0, 3) } diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index bb191f512..1e80b7eca 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -35,3 +35,11 @@ export function truncateMiddle(text: string, maxLength: number = 20) { const end = Math.floor(available / 2) return text.slice(0, start) + "…" + text.slice(-end) } + +/** + * Normalize a path to use forward slashes on all platforms. + * On Windows, also convert MSYS and Cygwin style paths to Windows drive letter paths. + */ +export function normalize(p: string): string { + return p.replace(/^\/(?:cygdrive\/)?([a-zA-Z])(\/|$)/, (_, d) => `${d.toUpperCase()}:/`).replace(/\\+/g, "/") +}