diff --git a/packages/local-config-updater/src/index.test.ts b/packages/local-config-updater/src/index.test.ts index 370495618..f23888270 100644 --- a/packages/local-config-updater/src/index.test.ts +++ b/packages/local-config-updater/src/index.test.ts @@ -57,9 +57,30 @@ afterEach(() => { } }); +// Config with an import triggers the agent path (tryParseHexclaveConfigFileContent returns null) +const CUSTOM_CONFIG = `import emailHtml from "./emails/welcome.html" with { type: "text" }; +export const config = { auth: { allowSignUp: true }, emails: { welcomeHtml: emailHtml } }; +`; + +describe("local config updater fast path", () => { + it("uses the fast path for plain static configs (no agent invoked)", async () => { + const configPath = writeTempConfig("export const config = { auth: { allowSignUp: true } };\n"); + mockScriptedWrites = [{ tool_name: "Write", file_path: path.join(getTempDir(), "x.ts") }]; + + const { updateConfigObject } = await import("./index"); + + await expect(updateConfigObject(configPath, { "auth.allowSignUp": false })).resolves.toBeUndefined(); + + // Agent was never called, so no hook decisions were recorded + expect(mockHookDecisions).toEqual([]); + // The config file was updated deterministically + expect(readFileSync(configPath, "utf-8")).toContain('"allowSignUp": false'); + }); +}); + describe("local config updater agent write boundary", () => { it("allows writes inside the config directory and captures them for rollback", async () => { - const configPath = writeTempConfig("export const config = { auth: { allowSignUp: true } };\n"); + const configPath = writeTempConfig(CUSTOM_CONFIG); const inside = path.join(getTempDir(), "emails", "welcome-email.tsx"); mockScriptedWrites = [{ tool_name: "Write", file_path: inside }]; mockAfterWrites = () => { @@ -74,7 +95,7 @@ describe("local config updater agent write boundary", () => { }); it("denies a `../` escape and fails the run", async () => { - const configPath = writeTempConfig("export const config = { auth: { allowSignUp: true } };\n"); + const configPath = writeTempConfig(CUSTOM_CONFIG); const outside = path.resolve(getTempDir(), "../../.env"); mockScriptedWrites = [{ tool_name: "Write", file_path: outside }]; @@ -87,11 +108,11 @@ describe("local config updater agent write boundary", () => { expect(mockHookDecisions[0]).toMatchObject({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny" }, }); - expect(readFileSync(configPath, "utf-8")).toBe("export const config = { auth: { allowSignUp: true } };\n"); + expect(readFileSync(configPath, "utf-8")).toBe(CUSTOM_CONFIG); }); it("denies an absolute path outside the config directory", async () => { - const configPath = writeTempConfig("export const config = { auth: { allowSignUp: true } };\n"); + const configPath = writeTempConfig(CUSTOM_CONFIG); mockScriptedWrites = [{ tool_name: "Edit", file_path: "/etc/passwd" }]; const { updateConfigObject } = await import("./index"); diff --git a/packages/local-config-updater/src/index.ts b/packages/local-config-updater/src/index.ts index 05138ca7f..dda8846cc 100644 --- a/packages/local-config-updater/src/index.ts +++ b/packages/local-config-updater/src/index.ts @@ -2,7 +2,7 @@ import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config- import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; import type { Config, ConfigValue, NormalizedConfig } from "@hexclave/shared/dist/config/format"; import { isValidConfig, normalize, override } from "@hexclave/shared/dist/config/format"; -import { getRelativeImportSpecifiers, hexclaveConfigFileExportsConfig } from "@hexclave/shared/dist/hexclave-config-file"; +import { getRelativeImportSpecifiers, hexclaveConfigFileExportsConfig, tryParseHexclaveConfigFileContent } from "@hexclave/shared/dist/hexclave-config-file"; import { createHash } from "crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; import { createJiti } from "jiti"; @@ -111,6 +111,21 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C if (flattenConfigUpdate(configUpdate).length === 0) return; const content = readFileSync(configFilePath, "utf-8"); + + // Fast path: if the config is a plain static literal (no imports, no helpers), + // apply the update deterministically without invoking the AI agent. + const staticConfig = tryParseHexclaveConfigFileContent(content, configFilePath); + if (staticConfig != null && typeof staticConfig === "object" && isValidConfig(staticConfig)) { + const merged = override(staticConfig, configUpdate); + if (!isValidConfig(merged)) { + throw new Error(`${LOG_PREFIX} Merged config is invalid after applying update to ${configFilePath}`); + } + renderConfigObjectToFile(configFilePath, merged); + return; + } + + // Agent path: config has custom structure (imports, helpers, external files) + // that must be preserved — delegate to the AI agent. const baselineConfig = await tryReadConfigForValidation(configFilePath); const snapshots = snapshotConfigFiles(configFilePath, content); try {