diff --git a/.changeset/fallback-state-directory.md b/.changeset/fallback-state-directory.md new file mode 100644 index 00000000000..5d3b95c9f9d --- /dev/null +++ b/.changeset/fallback-state-directory.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Start Kilo with a persistent fallback when the default runtime state directory is not writable. diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index ffc199317b1..10861364ab4 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,7 +5,7 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { markNoIndex } from "./kilocode/spotlight" // kilocode_change -import { ensureRealDir } from "./kilocode/global" // kilocode_change +import { ensureRealDir, resolveState } from "./kilocode/global" // kilocode_change import { Flag } from "./flag/flag" import { makeGlobalNode } from "./effect/app-node" @@ -22,7 +22,8 @@ const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "") const data = path.join(clean(xdgData)!, app) const cache = path.join(clean(xdgCache)!, app) const config = path.join(clean(xdgConfig)!, app) -const state = path.join(clean(xdgState)!, app) +const preferred = path.join(clean(xdgState)!, app) +const state = await resolveState(preferred, process.env.XDG_STATE_HOME ? undefined : path.join(data, "state")) // kilocode_change end const tmp = path.join(os.tmpdir(), app) @@ -47,7 +48,6 @@ Flock.setGlobal({ state }) await Promise.all([ ensureRealDir(Path.data), // kilocode_change ensureRealDir(Path.config), // kilocode_change - ensureRealDir(Path.state), // kilocode_change ensureRealDir(Path.tmp), // kilocode_change ensureRealDir(Path.log), // kilocode_change ensureRealDir(Path.bin), // kilocode_change diff --git a/packages/core/src/kilocode/global.ts b/packages/core/src/kilocode/global.ts index b57d06f7ae9..a98d815896d 100644 --- a/packages/core/src/kilocode/global.ts +++ b/packages/core/src/kilocode/global.ts @@ -1,4 +1,6 @@ import fs from "fs/promises" +import path from "path" +import { randomUUID } from "crypto" /** * Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and @@ -21,3 +23,50 @@ export async function ensureRealDir(p: string) { await fs.mkdir(p, { recursive: true }) } } + +async function writable(p: string) { + const probe = path.join(p, `.kilo-write-${process.pid}-${randomUUID()}`) + await fs.writeFile(probe, "", { flag: "wx", mode: 0o600 }) + await fs.unlink(probe) +} + +async function ready(p: string) { + await ensureRealDir(p) + await writable(p) +} + +export async function resolveState(p: string, fallback?: string) { + const sticky = + fallback === undefined + ? false + : await fs.stat(fallback).then( + (stat) => + stat.isDirectory() && + writable(fallback).then( + () => true, + () => false, + ), + () => false, + ) + if (sticky && fallback !== undefined) return fallback + + const err = await ready(p).then( + () => undefined, + (err: unknown) => err, + ) + if (err === undefined) return p + if (fallback === undefined) throw err + + const failed = await ready(fallback).then( + () => undefined, + (err: unknown) => err, + ) + if (failed !== undefined) { + throw new AggregateError([err, failed], `Cannot use state directory "${p}" or fallback "${fallback}"`) + } + + const msg = err instanceof Error ? err.message : "Unknown error" + // Logging is not initialized until Global.Path.log exists. + console.warn(`[kilo] Cannot use state directory "${p}"; using "${fallback}" instead: ${msg}`) + return fallback +} diff --git a/packages/core/test/kilocode/global.test.ts b/packages/core/test/kilocode/global.test.ts new file mode 100644 index 00000000000..5575e869f1e --- /dev/null +++ b/packages/core/test/kilocode/global.test.ts @@ -0,0 +1,109 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect, test } from "bun:test" +import { resolveState } from "@opencode-ai/core/kilocode/global" +import { tmpdir } from "../fixture/tmpdir" + +const skip = process.platform === "win32" || process.getuid?.() === 0 + +describe("global state directory", () => { + test("uses the preferred state directory when available", async () => { + await using tmp = await tmpdir() + const preferred = path.join(tmp.path, "preferred") + + expect(await resolveState(preferred, path.join(tmp.path, "fallback"))).toBe(preferred) + expect((await fs.stat(preferred)).isDirectory()).toBe(true) + expect(await fs.readdir(preferred)).toEqual([]) + }) + + test("falls back when the default state directory is unusable", async () => { + await using tmp = await tmpdir() + const preferred = path.join(tmp.path, "preferred") + const fallback = path.join(tmp.path, "data", "state") + await fs.writeFile(preferred, "not a directory") + + expect(await resolveState(preferred, fallback)).toBe(fallback) + expect((await fs.stat(fallback)).isDirectory()).toBe(true) + }) + + test("keeps using an existing fallback", async () => { + await using tmp = await tmpdir() + const preferred = path.join(tmp.path, "preferred") + const fallback = path.join(tmp.path, "fallback") + await fs.mkdir(fallback) + + expect(await resolveState(preferred, fallback)).toBe(fallback) + expect( + await fs.stat(preferred).then( + () => true, + () => false, + ), + ).toBe(false) + }) + + test.skipIf(skip)("uses the preferred directory when an existing fallback is not writable", async () => { + await using tmp = await tmpdir() + const preferred = path.join(tmp.path, "preferred") + const fallback = path.join(tmp.path, "fallback") + await fs.mkdir(fallback) + await fs.chmod(fallback, 0o500) + + try { + expect(await resolveState(preferred, fallback)).toBe(preferred) + } finally { + await fs.chmod(fallback, 0o700) + } + }) + + test.skipIf(skip)("falls back when the preferred directory cannot be created", async () => { + await using tmp = await tmpdir() + const parent = path.join(tmp.path, "preferred") + const preferred = path.join(parent, "kilo") + const fallback = path.join(tmp.path, "data", "state") + await fs.mkdir(parent) + await fs.chmod(parent, 0o500) + + try { + expect(await resolveState(preferred, fallback)).toBe(fallback) + } finally { + await fs.chmod(parent, 0o700) + } + }) + + test.skipIf(skip)("falls back when the preferred directory exists but is not writable", async () => { + await using tmp = await tmpdir() + const preferred = path.join(tmp.path, "preferred") + const fallback = path.join(tmp.path, "data", "state") + await fs.mkdir(preferred) + await fs.chmod(preferred, 0o500) + + try { + expect(await resolveState(preferred, fallback)).toBe(fallback) + } finally { + await fs.chmod(preferred, 0o700) + } + }) + + test("preserves errors for explicitly configured state directories", async () => { + await using tmp = await tmpdir() + const preferred = path.join(tmp.path, "preferred") + await fs.writeFile(preferred, "not a directory") + + const err = await resolveState(preferred).catch((err: unknown) => err) + expect(err).toBeInstanceOf(Error) + }) + + test("reports both paths when the fallback also fails", async () => { + await using tmp = await tmpdir() + const preferred = path.join(tmp.path, "preferred") + const fallback = path.join(tmp.path, "fallback") + await Promise.all([fs.writeFile(preferred, "not a directory"), fs.writeFile(fallback, "not a directory")]) + + const err = await resolveState(preferred, fallback).catch((err: unknown) => err) + expect(err).toBeInstanceOf(AggregateError) + if (!(err instanceof AggregateError)) throw err + expect(err.message).toContain(preferred) + expect(err.message).toContain(fallback) + expect(err.errors).toHaveLength(2) + }) +})