mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
fdbc33697d
commit
c169bcc876
@ -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");
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user