diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 941674524f..1b8706eaa5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] ### Fixed + +- Hardened legacy config migration against partial files, permission widening, ambiguous publication cleanup, and unbounded failure diagnostics. - ACP session configuration now emits the spec-defined `category` field on the Mode, Model, and Thinking select options (`mode`, `model`, `thought_level`), so standards-compliant ACP clients such as Paseo discover models, modes, and thinking levels instead of an empty model picker (#3922). - The ACP session model catalog is now filtered to active providers via `providers.list/active`, falling back to the full catalog on older session hosts, so ACP clients no longer list models for providers without usable credentials (#3922). diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index 96b8b21ae9..6e0bd5405c 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -1,5 +1,8 @@ +import { randomUUID } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; +import type { NativeNoReplaceResult } from "@gajae-code/natives"; +import * as native from "@gajae-code/natives"; import { getAgentDir, isEnoent, logger } from "@gajae-code/utils"; import { JSONC, YAML } from "bun"; import type { ZodType } from "zod/v4"; @@ -10,20 +13,257 @@ interface ConfigSchemaError { message: string | undefined; } -function migrateJsonToYml(jsonPath: string, ymlPath: string) { +interface FileIdentity { + dev: number | bigint; + ino: number | bigint; +} + +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +const EVIDENCE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/; + +function sanitizeEvidence(value: unknown, fallback: string): string { + return typeof value === "string" && EVIDENCE_TOKEN.test(value) ? value : fallback; +} + +function readEvidence(value: unknown, property: string, fallback: string): string { try { - if (fs.existsSync(ymlPath)) return; - if (!fs.existsSync(jsonPath)) return; + if ((typeof value !== "object" && typeof value !== "function") || value === null) return fallback; + return sanitizeEvidence(Reflect.get(value, property), fallback); + } catch { + return fallback; + } +} + +function warningDetails( + outcomeCode: string, + stage: string, + error?: unknown, + errorCode?: unknown, +): Record { + return { + outcomeCode: sanitizeEvidence(outcomeCode, "unknown"), + stage: sanitizeEvidence(stage, "unknown"), + errorCode: sanitizeEvidence(errorCode, readEvidence(error, "code", "unknown")), + errorMessage: "Legacy config migration was not proven durable.", + }; +} + +function warnMigration(outcomeCode: string, stage: string, error?: unknown, errorCode?: unknown): void { + logger.warn("migrateJsonToYml: migration not completed", warningDetails(outcomeCode, stage, error, errorCode)); +} + +function isStructuredPublishOutcome(value: NativeNoReplaceResult): boolean { + return ( + typeof value.ok === "boolean" && + typeof value.mutationState === "string" && + typeof value.durabilityState === "string" && + typeof value.reason === "string" && + typeof value.primitive === "string" && + value.primitive.length > 0 && + typeof value.phase === "string" && + typeof value.diagnostic === "object" && + value.diagnostic !== null + ); +} + +function isCommittedPublishOutcome(value: NativeNoReplaceResult): boolean { + return ( + isStructuredPublishOutcome(value) && + value.mutationState === "committed" && + value.durabilityState === "not_attempted" && + value.reason === "none" && + value.phase === "complete" + ); +} + +const CERTIFIED_NON_COMMIT_REASONS = new Set([ + "destination_exists", + "atomic_unavailable", + "invalid_request", + "cross_device", + "permission_denied", + "io_failure", + "interrupted", + "identity_violation", +]); + +function isCertifiedNonCommit(value: NativeNoReplaceResult): boolean { + return ( + isStructuredPublishOutcome(value) && + value.ok === false && + value.mutationState === "not_committed" && + value.durabilityState === "not_attempted" && + CERTIFIED_NON_COMMIT_REASONS.has(value.reason) + ); +} + +function writeFully(fd: number, bytes: Uint8Array): void { + let offset = 0; + while (offset < bytes.byteLength) { + const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset); + if (written <= 0) throw new Error("Config migration temp write made no progress"); + offset += written; + } +} + +function syncParentDirectory(ymlPath: string): void { + const noFollow = fs.constants.O_NOFOLLOW; + const directory = fs.constants.O_DIRECTORY; + if (typeof noFollow !== "number" || noFollow === 0 || typeof directory !== "number" || directory === 0) { + throw new Error("Secure directory sync is unsupported"); + } + const fd = fs.openSync(path.dirname(ymlPath), fs.constants.O_RDONLY | directory | noFollow); + try { + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +function removeCertifiedTemp(tempPath: string, identity: FileIdentity | undefined): void { + if (!identity) return; + try { + const current = fs.lstatSync(tempPath); + if (!current.isFile() || current.isSymbolicLink() || !sameIdentity(current, identity)) return; + fs.unlinkSync(tempPath); + } catch { + // Cleanup is best effort and never grants authority to touch another path. + } +} + +export function migrateJsonToYml(jsonPath: string, ymlPath: string): void { + let tempPath: string | undefined; + let tempIdentity: FileIdentity | undefined; + let tempFd: number | undefined; + let publicationCommitted = false; + let cleanupTemp = true; + let stage = "destination_identity"; + try { + try { + fs.lstatSync(ymlPath); + return; + } catch (error) { + if (!isEnoent(error)) { + warnMigration("destination_identity_failed", stage, error); + return; + } + } + + let source: fs.Stats; + try { + source = fs.lstatSync(jsonPath); + } catch (error) { + if (!isEnoent(error)) warnMigration("source_identity_failed", "source_identity", error); + return; + } + if (!source.isFile() || source.isSymbolicLink()) { + warnMigration("source_not_regular", "source_identity"); + return; + } + + const noFollow = fs.constants.O_NOFOLLOW; + if (typeof noFollow !== "number" || noFollow === 0) { + warnMigration("no_follow_unsupported", "source_open"); + return; + } + stage = "source_open"; + + const sourceFd = fs.openSync(jsonPath, fs.constants.O_RDONLY | noFollow); + let content: string | undefined; + let sourceMode: number | undefined; + try { + const openedSource = fs.fstatSync(sourceFd); + if (openedSource.isFile() && sameIdentity(source, openedSource)) { + sourceMode = openedSource.mode & 0o777; + content = fs.readFileSync(sourceFd, "utf8"); + } + } finally { + fs.closeSync(sourceFd); + } + if (content === undefined || sourceMode === undefined) { + warnMigration("source_identity_changed", "source_read"); + return; + } - const content = fs.readFileSync(jsonPath, "utf-8"); const parsed = JSON.parse(content); if (!parsed) { - logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath }); + warnMigration("invalid_json_structure", "source_parse"); return; } - fs.writeFileSync(ymlPath, YAML.stringify(parsed, null, 2)); + const bytes = Buffer.from(YAML.stringify(parsed, null, 2), "utf8"); + + stage = "temp_create"; + tempPath = path.join(path.dirname(ymlPath), `.${path.basename(ymlPath)}.${randomUUID()}.tmp`); + tempFd = fs.openSync( + tempPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow, + sourceMode, + ); + const openedTemp = fs.fstatSync(tempFd); + if (!openedTemp.isFile()) throw new Error("Config migration temp is not a regular file"); + tempIdentity = openedTemp; + stage = "temp_write"; + writeFully(tempFd, bytes); + fs.fchmodSync(tempFd, sourceMode); + stage = "temp_sync"; + fs.fsyncSync(tempFd); + fs.closeSync(tempFd); + tempFd = undefined; + + stage = "publication"; + const publication = native.renameNoReplacePath(tempPath, ymlPath); + if (isCommittedPublishOutcome(publication)) { + publicationCommitted = true; + try { + stage = "parent_sync"; + syncParentDirectory(ymlPath); + } catch (error) { + warnMigration("published_parent_sync_failed", stage, error); + return; + } + if (!publication.ok) { + warnMigration( + "published_outcome_not_proven", + readEvidence(publication, "phase", "publication"), + undefined, + readEvidence(publication, "code", "unknown"), + ); + } + return; + } + if (isCertifiedNonCommit(publication)) { + if (publication.reason !== "destination_exists") { + warnMigration( + `not_committed_${publication.reason}`, + readEvidence(publication, "phase", "publication"), + undefined, + readEvidence(publication, "code", "unknown"), + ); + } + return; + } + cleanupTemp = false; + warnMigration( + "publication_outcome_indeterminate", + readEvidence(publication, "phase", "publication"), + undefined, + readEvidence(publication, "code", "unknown"), + ); } catch (error) { - logger.warn("migrateJsonToYml: migration failed", { error: String(error) }); + if (stage === "publication") cleanupTemp = false; + warnMigration("migration_failed", stage, error); + } finally { + if (tempFd !== undefined) { + try { + fs.closeSync(tempFd); + } catch { + // The identity check below remains authoritative for cleanup. + } + } + if (!publicationCommitted && cleanupTemp && tempPath) removeCertifiedTemp(tempPath, tempIdentity); } } diff --git a/packages/coding-agent/test/config-file-migration.test.ts b/packages/coding-agent/test/config-file-migration.test.ts new file mode 100644 index 0000000000..705667ea34 --- /dev/null +++ b/packages/coding-agent/test/config-file-migration.test.ts @@ -0,0 +1,318 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { NativeNoReplaceResult } from "@gajae-code/natives"; +import * as native from "@gajae-code/natives"; +import { logger } from "@gajae-code/utils"; +import { YAML } from "bun"; +import { migrateJsonToYml } from "../src/config/config-file"; + +const NOT_COMMITTED = { + ok: false, + code: "atomic_rename_unavailable", + mutationState: "not_committed", + durabilityState: "not_attempted", + reason: "atomic_unavailable", + primitive: "renameat2_noreplace", + phase: "rename", + diagnostic: { schemaVersion: 1, collectionState: "unavailable" }, +} satisfies NativeNoReplaceResult; + +describe("legacy JSON to YAML config migration", () => { + let directory: string; + let jsonPath: string; + let ymlPath: string; + const sourceValue = { theme: "dark", nested: { enabled: true }, values: [1, 2, 3] }; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-config-migration-")); + jsonPath = path.join(directory, "config.json"); + ymlPath = path.join(directory, "config.yml"); + fs.writeFileSync(jsonPath, JSON.stringify(sourceValue), { mode: 0o640 }); + fs.chmodSync(jsonPath, 0o640); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + function tempInventory(): string[] { + return fs.readdirSync(directory).filter(name => name.endsWith(".tmp")); + } + + it("publishes complete parse-equivalent YAML with the source mode and retains JSON", () => { + const trace: string[] = []; + const originalOpen = fs.openSync.bind(fs); + const originalWrite = fs.writeSync.bind(fs); + const originalFchmod = fs.fchmodSync.bind(fs); + const originalFsync = fs.fsyncSync.bind(fs); + const originalClose = fs.closeSync.bind(fs); + const originalPublish = native.renameNoReplacePath.bind(native); + let tempFd: number | undefined; + let parentFd: number | undefined; + + vi.spyOn(fs, "openSync").mockImplementation(((file, flags, mode) => { + const fd = originalOpen(file, flags, mode); + if (typeof file === "string" && file.endsWith(".tmp")) { + tempFd = fd; + trace.push("open"); + } else if (file === directory) { + parentFd = fd; + } + return fd; + }) as typeof fs.openSync); + vi.spyOn(fs, "writeSync").mockImplementation(((...args: Parameters) => { + if (args[0] === tempFd) trace.push("write"); + return originalWrite(...args); + }) as typeof fs.writeSync); + vi.spyOn(fs, "fchmodSync").mockImplementation(((fd, mode) => { + if (fd === tempFd) trace.push("chmod"); + return originalFchmod(fd, mode); + }) as typeof fs.fchmodSync); + vi.spyOn(fs, "fsyncSync").mockImplementation(((fd: number) => { + if (fd === parentFd) trace.push("parent fsync"); + else if (fd === tempFd) trace.push("temp fsync"); + return originalFsync(fd); + }) as typeof fs.fsyncSync); + vi.spyOn(fs, "closeSync").mockImplementation(((fd: number) => { + if (fd === parentFd) parentFd = undefined; + else if (fd === tempFd) { + trace.push("close"); + tempFd = undefined; + } + return originalClose(fd); + }) as typeof fs.closeSync); + vi.spyOn(native, "renameNoReplacePath").mockImplementation((source, destination) => { + trace.push("renameNoReplacePath"); + return originalPublish(source, destination); + }); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(YAML.parse(fs.readFileSync(ymlPath, "utf8"))).toEqual(sourceValue); + expect(JSON.parse(fs.readFileSync(jsonPath, "utf8"))).toEqual(sourceValue); + expect(fs.statSync(ymlPath).mode & 0o777).toBe(fs.statSync(jsonPath).mode & 0o777); + expect(trace).toEqual(["open", "write", "chmod", "temp fsync", "close", "renameNoReplacePath", "parent fsync"]); + expect(tempInventory()).toEqual([]); + }); + it("uses the descriptor-observed source mode rather than the pathname snapshot", () => { + const originalLstat = fs.lstatSync.bind(fs); + vi.spyOn(fs, "lstatSync").mockImplementation(((file: fs.PathLike) => { + const stats = originalLstat(file); + if (file === jsonPath) { + Object.defineProperty(stats, "mode", { + value: (stats.mode & ~0o777) | 0o777, + }); + } + return stats; + }) as typeof fs.lstatSync); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(fs.statSync(ymlPath).mode & 0o777).toBe(0o640); + }); + + it("never overwrites an existing YAML file or follows a YAML symlink", () => { + fs.writeFileSync(ymlPath, "winner: existing\n"); + migrateJsonToYml(jsonPath, ymlPath); + expect(fs.readFileSync(ymlPath, "utf8")).toBe("winner: existing\n"); + + fs.unlinkSync(ymlPath); + const winnerPath = path.join(directory, "winner.yml"); + fs.writeFileSync(winnerPath, "winner: symlink\n"); + fs.symlinkSync(winnerPath, ymlPath); + migrateJsonToYml(jsonPath, ymlPath); + expect(fs.readFileSync(winnerPath, "utf8")).toBe("winner: symlink\n"); + expect(fs.lstatSync(ymlPath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(jsonPath)).toBe(true); + expect(tempInventory()).toEqual([]); + }); + it("refuses a symlinked legacy source without reading or publishing it", () => { + const actualSource = path.join(directory, "actual.json"); + fs.renameSync(jsonPath, actualSource); + fs.symlinkSync(actualSource, jsonPath); + const publish = vi.spyOn(native, "renameNoReplacePath"); + const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(fs.existsSync(ymlPath)).toBe(false); + expect(fs.lstatSync(jsonPath).isSymbolicLink()).toBe(true); + expect(publish).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledTimes(1); + expect(tempInventory()).toEqual([]); + }); + + it("loses a destination race without fallback, retry, or retained temp files", () => { + const originalPublish = native.renameNoReplacePath.bind(native); + const publish = vi.spyOn(native, "renameNoReplacePath").mockImplementation((source, destination) => { + fs.writeFileSync(destination, "winner: concurrent\n"); + return originalPublish(source, destination); + }); + const ordinaryRename = vi.spyOn(fs, "renameSync"); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(fs.readFileSync(ymlPath, "utf8")).toBe("winner: concurrent\n"); + expect(fs.existsSync(jsonPath)).toBe(true); + expect(publish).toHaveBeenCalledTimes(1); + expect(ordinaryRename).not.toHaveBeenCalled(); + expect(tempInventory()).toEqual([]); + }); + + it("cleans certified non-commits but retains malformed publication outcomes", () => { + const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const publish = vi + .spyOn(native, "renameNoReplacePath") + .mockReturnValueOnce(NOT_COMMITTED) + .mockReturnValueOnce({} as NativeNoReplaceResult); + + migrateJsonToYml(jsonPath, ymlPath); + expect(fs.existsSync(ymlPath)).toBe(false); + expect(tempInventory()).toEqual([]); + + migrateJsonToYml(jsonPath, ymlPath); + expect(fs.existsSync(ymlPath)).toBe(false); + expect(fs.existsSync(jsonPath)).toBe(true); + expect(tempInventory()).toHaveLength(1); + expect(publish).toHaveBeenCalledTimes(2); + expect(warning).toHaveBeenCalledTimes(2); + }); + it("retains its identity-bound temp when publication throws and emits sanitized stage/code evidence", () => { + const error = new Error(`secret=${JSON.stringify(sourceValue)} path=${jsonPath}`) as NodeJS.ErrnoException; + error.code = "EIO"; + vi.spyOn(native, "renameNoReplacePath").mockImplementation(() => { + throw error; + }); + const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(fs.existsSync(ymlPath)).toBe(false); + expect(fs.existsSync(jsonPath)).toBe(true); + expect(tempInventory()).toHaveLength(1); + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0]?.[1]).toEqual({ + outcomeCode: "migration_failed", + stage: "publication", + errorCode: "EIO", + errorMessage: "Legacy config migration was not proven durable.", + }); + const logged = JSON.stringify(warning.mock.calls); + expect(logged).not.toContain(directory); + expect(logged).not.toContain("dark"); + }); + it("bounds malformed publication diagnostic evidence without deleting the temp", () => { + const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); + vi.spyOn(native, "renameNoReplacePath").mockReturnValue({ + phase: "\u001b[31mrename", + code: "x".repeat(1_000), + } as NativeNoReplaceResult); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(tempInventory()).toHaveLength(1); + expect(warning.mock.calls[0]?.[1]).toEqual({ + outcomeCode: "publication_outcome_indeterminate", + stage: "publication", + errorCode: "unknown", + errorMessage: "Legacy config migration was not proven durable.", + }); + expect(JSON.stringify(warning.mock.calls)).not.toContain("\u001b"); + }); + it("never rolls back a committed publication reported with a failure disposition", () => { + const originalPublish = native.renameNoReplacePath.bind(native); + const publish = vi.spyOn(native, "renameNoReplacePath").mockImplementation((source, destination) => ({ + ...originalPublish(source, destination), + ok: false, + code: "committed_durability_failure", + })); + const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const ordinaryRename = vi.spyOn(fs, "renameSync"); + const unlink = vi.spyOn(fs, "unlinkSync"); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(YAML.parse(fs.readFileSync(ymlPath, "utf8"))).toEqual(sourceValue); + expect(fs.existsSync(jsonPath)).toBe(true); + expect(publish).toHaveBeenCalledTimes(1); + expect(ordinaryRename).not.toHaveBeenCalled(); + expect(unlink).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledTimes(1); + expect(tempInventory()).toEqual([]); + }); + + it("preserves a committed publication when parent fsync fails and emits one bounded warning", () => { + const originalFsync = fs.fsyncSync.bind(fs); + const publish = vi.spyOn(native, "renameNoReplacePath"); + const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); + let parentFsyncCalls = 0; + vi.spyOn(fs, "fsyncSync").mockImplementation(((fd: number) => { + if (fs.fstatSync(fd).isDirectory()) { + parentFsyncCalls++; + const error = new Error( + `do not expose ${directory} or ${JSON.stringify(sourceValue)}`, + ) as NodeJS.ErrnoException; + error.code = "EIO"; + throw error; + } + return originalFsync(fd); + }) as typeof fs.fsyncSync); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(YAML.parse(fs.readFileSync(ymlPath, "utf8"))).toEqual(sourceValue); + expect(fs.existsSync(jsonPath)).toBe(true); + expect(parentFsyncCalls).toBe(1); + expect(publish).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalledTimes(1); + const logged = JSON.stringify(warning.mock.calls); + expect(logged).toContain("published_parent_sync_failed"); + expect(logged).not.toContain(directory); + expect(logged).not.toContain("dark"); + expect(tempInventory()).toEqual([]); + }); + it("preserves a committed publication when parent fsync is unsupported", () => { + const originalFsync = fs.fsyncSync.bind(fs); + const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); + let parentFsyncCalls = 0; + vi.spyOn(fs, "fsyncSync").mockImplementation(((fd: number) => { + if (fs.fstatSync(fd).isDirectory()) { + parentFsyncCalls++; + const error = new Error("directory fsync unsupported") as NodeJS.ErrnoException; + error.code = "EINVAL"; + throw error; + } + return originalFsync(fd); + }) as typeof fs.fsyncSync); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(YAML.parse(fs.readFileSync(ymlPath, "utf8"))).toEqual(sourceValue); + expect(fs.existsSync(jsonPath)).toBe(true); + expect(parentFsyncCalls).toBe(1); + expect(warning).toHaveBeenCalledTimes(1); + expect(tempInventory()).toEqual([]); + }); + + it("cleans only its certified temp after a pre-publication write failure", () => { + const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const publish = vi.spyOn(native, "renameNoReplacePath"); + vi.spyOn(fs, "writeSync").mockImplementation((() => { + throw new Error(`secret=${JSON.stringify(sourceValue)} path=${jsonPath}`); + }) as typeof fs.writeSync); + + migrateJsonToYml(jsonPath, ymlPath); + + expect(fs.existsSync(ymlPath)).toBe(false); + expect(fs.existsSync(jsonPath)).toBe(true); + expect(tempInventory()).toEqual([]); + expect(publish).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledTimes(1); + const logged = JSON.stringify(warning.mock.calls); + expect(logged).not.toContain(jsonPath); + expect(logged).not.toContain("dark"); + }); +}); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index e2374c72cc..46f6348300 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -13,6 +13,8 @@ import { setDefaultTabWidth, } from "@gajae-code/utils"; import { YAML } from "bun"; +import * as z from "zod/v4"; +import { ConfigFile } from "../src/config/config-file"; import { withFileLock } from "../src/config/file-lock"; import { createLightweightDaemonSettings } from "../src/sdk/bus/telegram-daemon-cli"; @@ -91,6 +93,22 @@ describe("Settings", () => { warning.mockRestore(); } }); + it("loads a legacy JSON config through the retained atomic YAML migration", async () => { + const legacyPath = path.join(agentDir, "config.json"); + const legacy = { theme: { dark: "custom-dark", light: "custom-light" } }; + fs.writeFileSync(legacyPath, JSON.stringify(legacy), { mode: 0o640 }); + fs.chmodSync(legacyPath, 0o640); + + const config = new ConfigFile( + "config", + z.object({ theme: z.object({ dark: z.string(), light: z.string() }) }), + getConfigPath(), + ); + expect(config.load()).toEqual(legacy); + expect(await readSettings()).toMatchObject(legacy); + expect(JSON.parse(fs.readFileSync(legacyPath, "utf8"))).toEqual(legacy); + expect(fs.statSync(getConfigPath()).mode & 0o777).toBe(fs.statSync(legacyPath).mode & 0o777); + }); it("distinguishes an absent first-event retry timeout from an explicit zero", () => { const absent = Settings.isolated(); expect(absent.get("retry.streamFirstEventTimeoutMs")).toBe(100_000);