Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fallback-state-directory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Start Kilo with a persistent fallback when the default runtime state directory is not writable.
6 changes: 3 additions & 3 deletions packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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)

Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/kilocode/global.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
109 changes: 109 additions & 0 deletions packages/core/test/kilocode/global.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading