diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index c910ec42b..37db40dae 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -1,4 +1,12 @@ import { withSentryConfig } from "@sentry/nextjs"; +import { createRequire } from "module"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const localConfigUpdaterRequire = createRequire(path.join(__dirname, "../../packages/local-config-updater/package.json")); +const claudeAgentSdkDir = path.dirname(localConfigUpdaterRequire.resolve("@anthropic-ai/claude-agent-sdk")); +const claudeAgentSdkTraceDir = path.relative(__dirname, claudeAgentSdkDir); const withConfiguredSentryConfig = (nextConfig) => withSentryConfig( @@ -48,13 +56,14 @@ const nextConfig = { // optionally set output to "standalone" for Docker builds // https://nextjs.org/docs/pages/api-reference/next-config-js/output output: process.env.NEXT_CONFIG_OUTPUT, + outputFileTracingRoot: path.join(__dirname, "../.."), outputFileTracingIncludes: { - "/*": [ - "./node_modules/@anthropic-ai/claude-agent-sdk/cli.js", - "./node_modules/@anthropic-ai/claude-agent-sdk/manifest.json", - "./node_modules/@anthropic-ai/claude-agent-sdk/manifest.zst.json", - "./node_modules/@anthropic-ai/claude-agent-sdk/resvg.wasm", - "./node_modules/@anthropic-ai/claude-agent-sdk/vendor/**/*", + "/api/remote-development-environment/config/apply-update": [ + path.join(claudeAgentSdkTraceDir, "cli.js"), + path.join(claudeAgentSdkTraceDir, "manifest.json"), + path.join(claudeAgentSdkTraceDir, "manifest.zst.json"), + path.join(claudeAgentSdkTraceDir, "resvg.wasm"), + path.join(claudeAgentSdkTraceDir, "vendor/**/*"), ], }, diff --git a/apps/dashboard/src/lib/remote-development-environment/config-file.test.ts b/apps/dashboard/src/lib/remote-development-environment/config-file.test.ts index d1cb6ad07..9cef1dc1d 100644 --- a/apps/dashboard/src/lib/remote-development-environment/config-file.test.ts +++ b/apps/dashboard/src/lib/remote-development-environment/config-file.test.ts @@ -1,10 +1,7 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { afterEach, describe, expect, it, vi } from "vitest"; -// Lets each AI-path test inject what the (otherwise network-backed) agent does -// to the files on disk, so we can exercise the orchestration and validation -// around `updateConfigObject` without calling a real model. type MockAgentOptions = { prompt: string, cwd: string, onFileWillChange?: (filePath: string) => void | Promise }; let mockAgentImpl: ((options: MockAgentOptions) => void | Promise) | null = null; @@ -210,7 +207,7 @@ describe("remote development environment config file", () => { await expect(readConfigFile(configPath)).rejects.toThrow(`Invalid config in ${configPath}.`); }); - it("applies updates to a plain static config through the agent", async () => { + it("applies updates to a plain static config through the shared agent updater", async () => { const configPath = writeTempConfig(` export const config = { auth: { @@ -260,8 +257,6 @@ describe("remote development environment config file", () => { const { updateConfigObject } = await import("./config-file"); - // Simulate the agent: write the new value into the referenced file and leave - // the config file untouched. mockAgentImpl = () => { writeFileSync(templatePath, "export default
New email
;\n", "utf-8"); }; @@ -270,138 +265,28 @@ describe("remote development environment config file", () => { "emails.templates.welcome": "export default
New email
;\n", }); - // The external file is updated and the config file keeps its import + shape. expect(readFileSync(templatePath, "utf-8")).toBe("export default
New email
;\n"); expect(readFileSync(configPath, "utf-8")).toBe(configSource); }); - it("validates the result semantically when the config is evaluable", async () => { - const configPath = writeTempConfig(` - import { defineStackConfig } from "@hexclave/shared/config"; - export const config = defineStackConfig({ - auth: { allowSignUp: true }, - }); - `); - const { readConfigFile, updateConfigObject } = await import("./config-file"); - - mockAgentImpl = () => { - writeFileSync(configPath, ` - import { defineStackConfig } from "@hexclave/shared/config"; - export const config = defineStackConfig({ - auth: { allowSignUp: false }, - }); - `, "utf-8"); - }; - - await updateConfigObject(configPath, { "auth.allowSignUp": false }); - - // The defineStackConfig wrapper is preserved and the value is updated. - expect(readFileSync(configPath, "utf-8")).toContain("defineStackConfig"); - await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(` - { - "config": { - "auth": { - "allowSignUp": false, - }, - }, - "showOnboarding": false, - } - `); - }); - - it("throws when the agent produces a config that does not match the requested update", async () => { - const configPath = writeTempConfig(` - import { defineStackConfig } from "@hexclave/shared/config"; - export const config = defineStackConfig({ - auth: { allowSignUp: true }, - }); - `); - const { updateConfigObject } = await import("./config-file"); - - // The agent writes the wrong value (allowSignUp stays true). - mockAgentImpl = () => { - writeFileSync(configPath, ` - import { defineStackConfig } from "@hexclave/shared/config"; - export const config = defineStackConfig({ - auth: { allowSignUp: true }, - }); - `, "utf-8"); - }; - - await expect(updateConfigObject(configPath, { "auth.allowSignUp": false })) - .rejects.toThrow(/validation failed/); - }); - - it("rolls back the config and its referenced files when the agent's result fails validation", async () => { + it("can update config and imported text files in one shared agent run", async () => { const templatePath = writeTempFile("welcome-email.tsx", "export default
Old email
;\n"); - const configSource = `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n emails: { templates: { welcome: welcomeEmail } },\n};\n`; + const configSource = `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n auth: { allowSignUp: true },\n emails: { templates: { welcome: welcomeEmail } },\n};\n`; const configPath = writeTempConfig(configSource); const { updateConfigObject } = await import("./config-file"); - // The agent edits both files but then fails, so the partially-applied edits - // must be rolled back and the failure surfaced. mockAgentImpl = () => { - writeFileSync(templatePath, "export default
Corrupted
;\n", "utf-8"); - writeFileSync(configPath, `export const config = { auth: { allowSignUp: true } };\n`, "utf-8"); - throw new Error("agent blew up"); + writeFileSync(templatePath, "export default
New email
;\n", "utf-8"); + writeFileSync(configPath, `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n auth: { allowSignUp: false },\n emails: { templates: { welcome: welcomeEmail } },\n};\n`, "utf-8"); }; - await expect(updateConfigObject(configPath, { + await updateConfigObject(configPath, { + "auth.allowSignUp": false, "emails.templates.welcome": "export default
New email
;\n", - })).rejects.toThrow("agent blew up"); + }); - // Both the config file and the externally-referenced file are back to their - // original contents \u2014 no half-applied update is left behind. - expect(readFileSync(configPath, "utf-8")).toBe(configSource); - expect(readFileSync(templatePath, "utf-8")).toBe("export default
Old email
;\n"); - }); - - it("rolls back a brand-new file the agent creates outside the statically-imported set", async () => { - // A wrapped config takes the agent path (it isn't a plain static literal), - // so the agent mock actually runs. - const configSource = `import { defineStackConfig } from "@hexclave/shared/config";\nexport const config = defineStackConfig({ auth: { allowSignUp: true } });\n`; - const configPath = writeTempConfig(configSource); - const newFilePath = join(getTempDir(), "generated-extra.ts"); - - const { updateConfigObject } = await import("./config-file"); - - // The agent creates a file that the config doesn't statically import, so the - // only way it can be rolled back is via the `onFileWillChange` hook firing - // before the write. After creating it, the agent fails so the run must roll - // back — deleting the newly-created file. - mockAgentImpl = async (options) => { - await options.onFileWillChange?.(newFilePath); - writeFileSync(newFilePath, "export const extra = true;\n", "utf-8"); - throw new Error("agent blew up"); - }; - - await expect(updateConfigObject(configPath, { "auth.allowSignUp": false })) - .rejects.toThrow("agent blew up"); - - expect(existsSync(newFilePath)).toBe(false); - expect(readFileSync(configPath, "utf-8")).toBe(configSource); - }); - - it("fails a non-evaluable update when the agent leaves every file unchanged", async () => { - const templatePath = writeTempFile("welcome-email.tsx", "export default
Old email
;\n"); - const configSource = `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n emails: { templates: { welcome: welcomeEmail } },\n};\n`; - const configPath = writeTempConfig(configSource); - - const { updateConfigObject } = await import("./config-file"); - - // The agent reports success but doesn't actually touch any file. Since this - // config isn't evaluable, we can't do a semantic check, but a no-op for a - // non-empty update must still be reported as a failure rather than silently - // succeeding. - mockAgentImpl = () => {}; - - await expect(updateConfigObject(configPath, { - "emails.templates.welcome": "export default
New email
;\n", - })).rejects.toThrow(/did not modify/); - - // The files are untouched (a no-op restored to its identical original). - expect(readFileSync(configPath, "utf-8")).toBe(configSource); - expect(readFileSync(templatePath, "utf-8")).toBe("export default
Old email
;\n"); + expect(readFileSync(templatePath, "utf-8")).toBe("export default
New email
;\n"); + expect(readFileSync(configPath, "utf-8")).toBe(`import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n auth: { allowSignUp: false },\n emails: { templates: { welcome: welcomeEmail } },\n};\n`); }); }); diff --git a/apps/dashboard/src/lib/remote-development-environment/manager.ts b/apps/dashboard/src/lib/remote-development-environment/manager.ts index a7eaccf2d..59d1f538e 100644 --- a/apps/dashboard/src/lib/remote-development-environment/manager.ts +++ b/apps/dashboard/src/lib/remote-development-environment/manager.ts @@ -14,9 +14,10 @@ import { basename, dirname } from "path"; import { ensureConfigFileExists, readConfigFile, + replaceConfigObject, resolveConfigFilePath, sha256String, - replaceConfigObject, + updateConfigObject, } from "./config-file"; import { assertRemoteDevelopmentEnvironmentEnabled } from "./env"; import { @@ -653,7 +654,7 @@ export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: { } else { state.synchronouslyUpdatingConfigFiles.add(configFilePath); try { - await replaceConfigObject(configFilePath, override(currentConfig, options.configUpdate)); + await updateConfigObject(configFilePath, options.configUpdate); } finally { setTimeout(() => { state.synchronouslyUpdatingConfigFiles.delete(configFilePath); diff --git a/packages/local-config-updater/src/index.ts b/packages/local-config-updater/src/index.ts index 6a1949e14..b30db2e20 100644 --- a/packages/local-config-updater/src/index.ts +++ b/packages/local-config-updater/src/index.ts @@ -114,7 +114,7 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C const snapshots = snapshotConfigFiles(configFilePath, content); try { await runConfigUpdateAgent({ - prompt: buildConfigUpdatePrompt(path.basename(configFilePath), configUpdate), + prompt: buildConfigUpdatePrompt(path.basename(configFilePath), configUpdate, baselineConfig), cwd: path.dirname(configFilePath), onFileWillChange: (filePath) => captureSnapshotIfAbsent(snapshots, filePath), }); @@ -283,11 +283,17 @@ function flattenConfigUpdate(update: Config): ConfigChange[] { return changes; } -function buildConfigUpdatePrompt(configFileName: string, configUpdate: Config): string { +function buildConfigUpdatePrompt(configFileName: string, configUpdate: Config, baselineConfig: Config | null): string { const changes = flattenConfigUpdate(configUpdate); const changeLines = changes.map(({ path: configPath, value }) => { return `- ${JSON.stringify(configPath)}: set to ${JSON.stringify(value)}`; }).join("\n"); + const expectedConfig = baselineConfig == null ? null : canonicalizeConfig(override(baselineConfig, configUpdate)); + const expectedConfigSection = expectedConfig == null ? "" : ` +After the edit, evaluating the exported \`config\` must produce this exact JSON value: + +${JSON.stringify(expectedConfig, null, 2)} +`; return `You are editing a Hexclave / Stack Auth configuration file in place. Apply a set of configuration changes WITHOUT changing how the file is written. @@ -301,6 +307,7 @@ The file exports a \`config\` object (it may be wrapped in a helper such as \`de Apply EXACTLY these changes. Paths use dot notation, so \`a.b.c\` refers to \`config.a.b.c\`: ${changeLines} +${expectedConfigSection} Rules: - Change ONLY the config paths listed above. Leave every other part of the file byte-for-byte unchanged: imports, comments, formatting, helper wrappers, and any config fields not listed.