fix(cli): prevent VS Code server connection failure on unwritable state paths (#13115)

* fix(cli): fall back from unwritable state directory

* fix(cli): verify state directory writes
This commit is contained in:
Johnny Eric Amancio
2026-08-18 12:00:57 +02:00
committed by GitHub
parent facd3f1708
commit d9f0eff306
4 changed files with 166 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Start Kilo with a persistent fallback when the default runtime state directory is not writable.
+3 -3
View File
@@ -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
+49
View File
@@ -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
}
+109
View File
@@ -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)
})
})