From c6acd28cbcfa1fecdef8c19e5970b9c655499b45 Mon Sep 17 00:00:00 2001 From: Whitebeard Date: Wed, 29 Jul 2026 16:35:42 +0530 Subject: [PATCH 1/4] fix(cli): remember bash permission migration --- .changeset/quiet-bash-permissions.md | 5 ++ .../opencode/src/kilocode/config/config.ts | 18 +++++-- .../test/kilocode/config/config.test.ts | 51 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 .changeset/quiet-bash-permissions.md diff --git a/.changeset/quiet-bash-permissions.md b/.changeset/quiet-bash-permissions.md new file mode 100644 index 000000000000..e8e940c2a6d3 --- /dev/null +++ b/.changeset/quiet-bash-permissions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Respect deleted bash permission overrides after upgrading. diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index a33445494b89..0a2f67233d3a 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -334,6 +334,7 @@ export namespace KilocodeConfig { // ── Bash permission migration ──────────────────────────────────────── const GLOBAL_CONFIG_FILES = ["config.json", "kilo.json", "kilo.jsonc", "opencode.json", "opencode.jsonc"] + const BASH_PERMISSION_MIGRATION = ".bash-permission-migrated" /** * Migrate bash permission for existing users before config is consumed. @@ -344,13 +345,20 @@ export namespace KilocodeConfig { * behavior now that the new default is `bash: "ask"`. */ export async function migrateBashPermission() { + const marker = path.join(Global.Path.config, BASH_PERMISSION_MIGRATION) + if (existsSync(marker)) return + const done = () => + Bun.write(marker, "").then( + () => undefined, + (err) => log.warn("failed to record bash permission migration", { path: marker, err }), + ) const files = GLOBAL_CONFIG_FILES.map((f) => path.join(Global.Path.config, f)) const legacy = path.join(Global.Path.config, "config") const existing = files.filter((f) => existsSync(f)) const hasLegacy = existsSync(legacy) // no global config → new user, they'll get the new bash:ask default - if (existing.length === 0 && !hasLegacy) return + if (existing.length === 0 && !hasLegacy) return done() const configs: Array<{ file: string; data: Record }> = [] // check if any config file already has an explicit bash permission @@ -360,17 +368,17 @@ export namespace KilocodeConfig { .catch(() => "") const data = parseJsonc(text) ?? {} configs.push({ file, data }) - if (typeof data.permission === "string" || (isRecord(data.permission) && data.permission.bash)) return + if (typeof data.permission === "string" || (isRecord(data.permission) && data.permission.bash)) return done() } // A schema-only file is generated for editor completion. It does not mean // the user predates the bash permission default. - if (!hasLegacy && configs.every((item) => Object.keys(item.data).every((key) => key === "$schema"))) return + if (!hasLegacy && configs.every((item) => Object.keys(item.data).every((key) => key === "$schema"))) return done() // also check legacy TOML config for bash permission if (hasLegacy) { const toml = await import(pathToFileURL(legacy).href, { with: { type: "toml" } }).catch(() => undefined) - if (toml?.default?.permission?.bash) return + if (toml?.default?.permission?.bash) return done() } // existing user without bash permission → write bash:allow to highest-precedence file @@ -384,6 +392,7 @@ export namespace KilocodeConfig { formattingOptions: { insertSpaces: true, tabSize: 2 }, }) await Bun.write(target, applyEdits(text, edits)) + await done() log.info("migrated bash permission to allow for existing user", { path: target }) return } @@ -391,6 +400,7 @@ export namespace KilocodeConfig { const data = parseJsonc(text) ?? {} const merged = { ...data, permission: { ...data.permission, bash: "allow" } } await Bun.write(target, JSON.stringify(merged, null, 2)) + await done() log.info("migrated bash permission to allow for existing user", { path: target }) } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index dde6a6d70781..c233a62d531f 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -887,4 +887,55 @@ describe("bash permission migration", () => { await disposeAllInstances() } }) + + test("does not restore a migrated bash permission after the user deletes it", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { permission: { read: "allow" } }, "kilo.jsonc") + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + const file = path.join(tmp.path, "kilo.jsonc") + await KilocodeConfig.migrateBashPermission() + expect(JSON.parse(await Filesystem.readText(file)).permission.bash).toBe("allow") + + await writeConfig(tmp.path, { permission: { read: "allow" } }, "kilo.jsonc") + await KilocodeConfig.migrateBashPermission() + + expect(JSON.parse(await Filesystem.readText(file)).permission).toEqual({ read: "allow" }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("does not later migrate a fresh install after its config gains settings", async () => { + await using tmp = await tmpdir() + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + await KilocodeConfig.migrateBashPermission() + await writeConfig(tmp.path, { model: "test/model" }, "kilo.jsonc") + await KilocodeConfig.migrateBashPermission() + + expect(JSON.parse(await Filesystem.readText(path.join(tmp.path, "kilo.jsonc")))).toEqual({ + model: "test/model", + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) }) From eadd2d3c9fa1258ba2532b417d1571f27e189756 Mon Sep 17 00:00:00 2001 From: Whitebeard Date: Wed, 29 Jul 2026 16:57:06 +0530 Subject: [PATCH 2/4] docs(cli): explain bash migration marker --- packages/opencode/src/kilocode/config/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 0a2f67233d3a..d87154d55ea1 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -342,7 +342,8 @@ export namespace KilocodeConfig { * Existing users (those with at least one global config file or the legacy TOML * config) who have no explicit `permission.bash` setting get `bash: "allow"` * written to their highest-precedence config file. This preserves their current - * behavior now that the new default is `bash: "ask"`. + * behavior now that the new default is `bash: "ask"`. A completion marker makes + * the migration idempotent so subsequent user edits are not migrated again. */ export async function migrateBashPermission() { const marker = path.join(Global.Path.config, BASH_PERMISSION_MIGRATION) From abbeb3a6f5f16d608530438e00c6070e8f27699a Mon Sep 17 00:00:00 2001 From: Whitebeard Date: Thu, 27 Aug 2026 17:33:51 +0530 Subject: [PATCH 3/4] fix(cli): retry bash permission migration after unreadable or malformed config Distinguish read/parse failures from empty valid configs so a transient file error or JSONC syntax error does not permanently mark the migration as done. Fixing the file now correctly retries. --- .../opencode/src/kilocode/config/config.ts | 41 ++++++++++++--- .../test/kilocode/config/config.test.ts | 52 +++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index f0fc049f4697..cf1f212dbf3d 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -2,7 +2,7 @@ import path from "path" import { pathToFileURL } from "url" import { existsSync } from "fs" import { Effect, Schema } from "effect" -import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser" +import { applyEdits, modify, parse as parseJsonc, type ParseError } from "jsonc-parser" import { mergeDeep } from "remeda" import * as Log from "@opencode-ai/core/util/log" import { Global } from "@opencode-ai/core/global" @@ -432,24 +432,51 @@ export namespace KilocodeConfig { if (existing.length === 0 && !hasLegacy) return done() const configs: Array<{ file: string; data: Record }> = [] + let hasFailure = false // check if any config file already has an explicit bash permission for (const file of existing) { - const text = await Bun.file(file) - .text() - .catch(() => "") - const data = parseJsonc(text) ?? {} + let text: string + try { + text = await Bun.file(file).text() + } catch (err) { + hasFailure = true + log.warn("skipping bash permission migration due to unreadable config", { file, err }) + continue + } + if (text.trim() === "") { + const data: Record = {} + configs.push({ file, data }) + continue + } + const errors: ParseError[] = [] + const data = (parseJsonc(text, errors) as Record | undefined) ?? {} + if (errors.length > 0) { + hasFailure = true + log.warn("skipping bash permission migration due to malformed config", { file, errors }) + continue + } configs.push({ file, data }) if (typeof data.permission === "string" || (isRecord(data.permission) && data.permission.bash)) return done() } + if (hasFailure) return + // A schema-only file is generated for editor completion. It does not mean // the user predates the bash permission default. if (!hasLegacy && configs.every((item) => Object.keys(item.data).every((key) => key === "$schema"))) return done() // also check legacy TOML config for bash permission if (hasLegacy) { - const toml = await import(pathToFileURL(legacy).href, { with: { type: "toml" } }).catch(() => undefined) - if (toml?.default?.permission?.bash) return done() + try { + const toml = await import(pathToFileURL(legacy).href, { with: { type: "toml" } }) + if (toml?.default?.permission?.bash) return done() + } catch (err) { + log.warn("skipping bash permission migration due to unreadable or malformed legacy config", { + path: legacy, + err, + }) + return + } } // existing user without bash permission → write bash:allow to highest-precedence file diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 33dce3604cb7..06c8472e63ee 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -1085,4 +1085,56 @@ describe("bash permission migration", () => { await disposeAllInstances() } }) + + test("does not mark migration done for malformed config and retries after fix", async () => { + await using tmp = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + const file = path.join(tmp.path, "kilo.jsonc") + const marker = path.join(tmp.path, ".bash-permission-migrated") + await Filesystem.write(file, "{ not valid json") + await KilocodeConfig.migrateBashPermission() + expect(await Bun.file(marker).exists()).toBe(false) + expect(await Filesystem.readText(file)).toBe("{ not valid json") + await Filesystem.write(file, JSON.stringify({ permission: { read: "allow" } })) + await KilocodeConfig.migrateBashPermission() + expect(await Bun.file(marker).exists()).toBe(true) + expect(JSON.parse(await Filesystem.readText(file)).permission.bash).toBe("allow") + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("does not mark migration done for unreadable config and retries after fix", async () => { + await using tmp = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + const file = path.join(tmp.path, "kilo.jsonc") + const marker = path.join(tmp.path, ".bash-permission-migrated") + await Filesystem.write(file, JSON.stringify({ permission: { read: "allow" } })) + await $`rm ${file}`.quiet().nothrow() + await $`mkdir -p ${file}`.quiet() + await KilocodeConfig.migrateBashPermission() + expect(await Bun.file(marker).exists()).toBe(false) + await $`rm -rf ${file}`.quiet() + await Filesystem.write(file, JSON.stringify({ permission: { read: "allow" } })) + await KilocodeConfig.migrateBashPermission() + expect(await Bun.file(marker).exists()).toBe(true) + expect(JSON.parse(await Filesystem.readText(file)).permission.bash).toBe("allow") + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) }) From 0330e40716e095b1cfb275fb86c3d6c1acd0aa58 Mon Sep 17 00:00:00 2001 From: Whitebeard Date: Fri, 28 Aug 2026 20:37:48 +0530 Subject: [PATCH 4/4] fix(cli): handle trailing commas in bash permission migration Pass { allowTrailingComma: true } to match ConfigParse.jsonc so valid JSONC with trailing commas is not treated as malformed and correctly receives the compatibility migration. Add regression test for trailing-comma config. --- .../opencode/src/kilocode/config/config.ts | 2 +- .../test/kilocode/config/config.test.ts | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 273ecac2c0ca..0d83de5be61e 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -487,7 +487,7 @@ export namespace KilocodeConfig { continue } const errors: ParseError[] = [] - const data = (parseJsonc(text, errors) as Record | undefined) ?? {} + const data = (parseJsonc(text, errors, { allowTrailingComma: true }) as Record | undefined) ?? {} if (errors.length > 0) { hasFailure = true log.warn("skipping bash permission migration due to malformed config", { file, errors }) diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index be98056ba6cb..d64bbaf14e5b 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -1644,4 +1644,36 @@ describe("bash permission migration", () => { await disposeAllInstances() } }) + + test("migrates config with trailing commas", async () => { + await using tmp = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + const file = path.join(tmp.path, "kilo.jsonc") + const marker = path.join(tmp.path, ".bash-permission-migrated") + await Filesystem.write( + file, + `{ + "$schema": "https://app.kilo.ai/config.json", + "permission": { + "read": "allow", + }, +}`, + ) + await KilocodeConfig.migrateBashPermission() + expect(await Bun.file(marker).exists()).toBe(true) + const text = await Filesystem.readText(file) + const parsed = ConfigParse.jsonc(text, file) as Record + expect(parsed.permission.bash).toBe("allow") + expect(text).toContain(`"read": "allow"`) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) })