Add fast path for static configs in updateConfigObject

Skip the AI agent for plain static config files (no imports, no helpers).
tryParseHexclaveConfigFileContent detects these and applies the update
deterministically via override + renderConfigObjectToFile.

Also updates tests to use import-bearing configs for agent boundary
tests, and adds a dedicated fast-path test.

Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
Devin AI 2026-06-04 02:15:10 +00:00
parent fdbc33697d
commit c169bcc876
2 changed files with 41 additions and 5 deletions

View File

@ -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");

View File

@ -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 {