diff --git a/.claude/settings.json b/.claude/settings.json index fb742f9fc..04fa4eda9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -32,7 +32,7 @@ "hooks": [ { "type": "command", - "command": "if jq -e '.stop_hook_active == true' >/dev/null 2>&1; then exit 0; fi && (pnpm run typecheck 1>&2 || exit 2) && (pnpm run lint 1>&2 || exit 2)" + "command": "if jq -e '.stop_hook_active == true' >/dev/null 2>&1; then exit 0; fi; fail=0; pnpm run typecheck 1>&2 || fail=1; pnpm run lint 1>&2 || fail=1; if [ $fail -ne 0 ]; then printf '\\n%s\\n' 'Only fix typecheck/lint failures caused by changes you made this session. Leave pre-existing or unrelated failures (in code you did not touch) as-is - do not modify unrelated code to satisfy the whole-repo check. If every remaining failure is unrelated to your changes, note that briefly and stop.' 1>&2; exit 2; fi" } ] } diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 590173cf6..88b8be0f5 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -79,6 +79,7 @@ HEXCLAVE_INTEGRATION_CLIENTS_CONFIG='[{"client_id": "neon-local", "client_secret CRON_SECRET=mock_cron_secret HEXCLAVE_FREESTYLE_API_KEY=mock_stack_freestyle_key HEXCLAVE_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development +HEXCLAVE_CONFIG_AGENT_BASE_SNAPSHOT_ID= HEXCLAVE_OPENAI_API_KEY=mock_openai_api_key HEXCLAVE_STRIPE_SECRET_KEY=sk_test_mockstripekey HEXCLAVE_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret diff --git a/apps/backend/.eslintrc.cjs b/apps/backend/.eslintrc.cjs index bfbe4d71f..e43607a19 100644 --- a/apps/backend/.eslintrc.cjs +++ b/apps/backend/.eslintrc.cjs @@ -3,7 +3,7 @@ const publicVars = require("../../configs/eslint/extra-rules.js"); module.exports = { extends: ["../../configs/eslint/defaults.js", "../../configs/eslint/next.js"], - ignorePatterns: ["/*", "!/src", "!/scripts", "!/prisma"], + ignorePatterns: ["/*", "!/src", "!/scripts", "!/prisma", "/scripts/spike-*.mts"], rules: { "no-restricted-syntax": [ ...defaults.rules["no-restricted-syntax"], diff --git a/apps/backend/package.json b/apps/backend/package.json index cbe30dc6a..79ffb2d69 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -87,6 +87,7 @@ "@simplewebauthn/server": "^13.3.0", "@hexclave/next": "workspace:*", "@hexclave/shared": "workspace:*", + "@hexclave/shared-backend": "workspace:*", "@upstash/qstash": "^2.8.2", "@vercel/functions": "^2.0.0", "@vercel/otel": "^1.10.4", @@ -95,6 +96,7 @@ "bcrypt": "^6.0.0", "cel-js": "^0.8.2", "chokidar-cli": "^3.0.0", + "diff": "^8.0.3", "dotenv": "^16.4.5", "dotenv-cli": "^7.3.0", "elkjs": "^0.11.1", diff --git a/apps/backend/prisma/migrations/20260626000000_add_config_agent_run_table/migration.sql b/apps/backend/prisma/migrations/20260626000000_add_config_agent_run_table/migration.sql new file mode 100644 index 000000000..157884fbc --- /dev/null +++ b/apps/backend/prisma/migrations/20260626000000_add_config_agent_run_table/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "ConfigAgentRun" ( + "id" UUID NOT NULL, + "projectId" TEXT NOT NULL, + "branchId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "status" TEXT NOT NULL, + "startedAt" TIMESTAMP(3) NOT NULL, + "finishedAt" TIMESTAMP(3), + "commitUrl" TEXT, + "error" TEXT, + "sandboxId" TEXT, + "progress" TEXT, + "stage" TEXT, + "diff" TEXT, + "baseCommitSha" TEXT, + + CONSTRAINT "ConfigAgentRun_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ConfigAgentRun_projectId_branchId_idx" ON "ConfigAgentRun"("projectId", "branchId"); + +-- AddForeignKey +ALTER TABLE "ConfigAgentRun" ADD CONSTRAINT "ConfigAgentRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index f830b2989..cf6a49da5 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -46,6 +46,7 @@ model Project { tenancies Tenancy[] branchConfigOverrides BranchConfigOverride[] environmentConfigOverrides EnvironmentConfigOverride[] + configAgentRuns ConfigAgentRun[] localEmulatorProject LocalEmulatorProject? aiConversations AiConversation[] @@ -144,6 +145,31 @@ model BranchConfigOverride { @@id([projectId, branchId]) } +model ConfigAgentRun { + id String @id @default(uuid()) @db.Uuid + + projectId String + branchId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + status String + startedAt DateTime + finishedAt DateTime? + commitUrl String? + error String? + sandboxId String? + progress String? + stage String? + diff String? @db.Text + baseCommitSha String? + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId, branchId]) +} + model EnvironmentConfigOverride { projectId String branchId String diff --git a/apps/backend/scripts/config-agent/build-image.ts b/apps/backend/scripts/config-agent/build-image.ts new file mode 100644 index 000000000..092acd3f7 --- /dev/null +++ b/apps/backend/scripts/config-agent/build-image.ts @@ -0,0 +1,27 @@ +/** + * One-off: builds the shared config-agent base snapshot (node24 + Claude Agent SDK + * + git bot identity; no repo, no token) that every config write warm-boots from, + * in place of a custom Docker image. Re-run when AGENT_SDK_VERSION in repo-agent.ts + * changes; old snapshots can be deleted from the Vercel dashboard. + * + * cd apps/backend && pnpm run with-env:dev tsx scripts/config-agent/build-image.ts + * + * Then set the printed id as HEXCLAVE_CONFIG_AGENT_BASE_SNAPSHOT_ID. Needs + * HEXCLAVE_VERCEL_SANDBOX_TOKEN (+ team/project ids). + */ +import { buildConfigAgentBaseSnapshot } from "@/lib/config/repo-agent"; + +async function main() { + const t0 = Date.now(); + const { snapshotId } = await buildConfigAgentBaseSnapshot((m) => console.log(` ${m}`)); + const secs = ((Date.now() - t0) / 1000).toFixed(0); + console.log(`\n✅ Base snapshot built in ${secs}s.\n`); + console.log("Set this env var so config writes warm-boot from it:\n"); + console.log(` HEXCLAVE_CONFIG_AGENT_BASE_SNAPSHOT_ID=${snapshotId}\n`); +} + +// eslint-disable-next-line no-restricted-syntax +main().then(() => process.exit(0)).catch((error: unknown) => { + console.error("Failed to build the config-agent base snapshot:", error); + process.exit(1); +}); diff --git a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts index 42d534a24..778b473db 100644 --- a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts +++ b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts @@ -1,4 +1,5 @@ import { ALLOWED_MODEL_IDS } from "@/lib/ai/models"; +import { PRODUCTION_AI_PROXY_BASE_URL } from "@/lib/ai/proxy-url"; import { preprocessProxyBody } from "@/private"; import { handleApiRequest } from "@/route-handlers/smart-route-handler"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; @@ -6,7 +7,6 @@ import { StatusError } from "@hexclave/shared/dist/utils/errors"; import { NextRequest } from "next/server"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api"; -const PRODUCTION_PROXY_BASE_URL = "https://api.hexclave.com/api/latest/integrations/ai-proxy"; const OPENROUTER_DEFAULT_MODEL = "anthropic/claude-sonnet-4.6"; function sanitizeBody(raw: ArrayBuffer): Uint8Array { @@ -49,7 +49,7 @@ async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ : undefined; if (apiKey === "FORWARD_TO_PRODUCTION") { - const targetUrl = `${PRODUCTION_PROXY_BASE_URL}/${subpath}${req.nextUrl.search}`; + const targetUrl = `${PRODUCTION_AI_PROXY_BASE_URL}/${subpath}${req.nextUrl.search}`; const headers: Record = {}; if (contentType) { headers["Content-Type"] = contentType; diff --git a/apps/backend/src/app/api/latest/internal/config/github/apply/route.tsx b/apps/backend/src/app/api/latest/internal/config/github/apply/route.tsx new file mode 100644 index 000000000..ffa119552 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/github/apply/route.tsx @@ -0,0 +1,144 @@ +import { + getCompleteBranchConfigForFile, + getGithubConfigSourceOrThrow, + recordConfigAgentRunProgress, + recordConfigAgentRunResult, + recordConfigAgentRunSandbox, + recordConfigAgentRunStage, + setConfigAgentRunAwaitingReview, + startConfigAgentRun, +} from "@/lib/config"; +import { applyConfigUpdate, type ConfigAgentInFlightStage, type GithubRepoRef } from "@/lib/config/repo-agent"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; +import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; +import { getInvalidConfigReason } from "@hexclave/shared/dist/config/format"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { StatusError, captureError } from "@hexclave/shared/dist/utils/errors"; + +// Background work (sandbox boot, clone, agent edit, capture the change set) continues +// via waitUntil after the immediate response, so allow a long invocation. +export const maxDuration = 800; + +/** + * Kicks off an AI-agent config write to the linked GitHub repo (writes go through + * the agent in a Vercel Sandbox; reads use jiti). The agent edits the repo in the + * sandbox; we then capture the change set and stop the sandbox, leaving the run + * `awaiting_review` — the actual commit happens later via `/commit`. The GitHub token + * is the user's own OAuth token, passed transiently for the clone — never persisted, + * never placed in the agent's environment. The dashboard polls `agent_run` for progress. + */ +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Apply config update via the GitHub repo agent", + description: "Runs the config agent in a sandbox to commit a dashboard config change to the linked GitHub branch.", + tags: ["Config"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + body: yupObject({ + github_access_token: yupString().defined(), + config_update_string: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + status: yupString().oneOf(["started"]).defined(), + id: yupString().defined(), + }).defined(), + }), + handler: async (req) => { + const projectId = req.auth.tenancy.project.id; + const branchId = req.auth.tenancy.branchId; + + await getGithubConfigSourceOrThrow({ projectId, branchId }); + + let parsed: unknown; + try { + parsed = JSON.parse(req.body.config_update_string); + } catch { + throw new StatusError(StatusError.BadRequest, "config_update_string is not valid JSON."); + } + const reason = getInvalidConfigReason(parsed, { configName: "config_update_string" }); + if (reason) { + throw new StatusError(StatusError.BadRequest, reason); + } + const configUpdate = parsed as EnvironmentConfigOverrideOverride; + + const githubToken = req.body.github_access_token; + + const nowMs = Date.now(); + // Inserts a fresh `running` run row and returns its id plus the source read in + // the same FOR UPDATE txn (so a concurrent re-link can't redirect the push). + // Runs aren't serialized; many can target this branch at once and a concurrent + // commit is caught by GitHub at push time. + const { source: startedSource, runId } = await startConfigAgentRun({ projectId, branchId, nowMs }); + const ref: GithubRepoRef = { owner: startedSource.owner, repo: startedSource.repo, branch: startedSource.branch }; + + // Fetched fresh per boot; this admin route can't mint GitHub tokens for the + // internal user (would be priv-esc), so we reuse the caller's freshest OAuth token. + const getGithubToken = async () => githubToken; + // Persist the sandbox id so a concurrent cancel can hard-stop it while the agent + // runs. `applyConfigUpdate` owns the sandbox lifetime and always stops it before + // returning/throwing, so the route doesn't need to track or stop it itself. + const onSandboxId = async (sandboxId: string) => { + await recordConfigAgentRunSandbox({ runId, sandboxId }); + }; + const onProgress = async (activity: string) => { + await recordConfigAgentRunProgress({ runId, progress: activity }); + }; + const onStage = async (stage: ConfigAgentInFlightStage) => { + await recordConfigAgentRunStage({ runId, stage }); + }; + + runAsynchronouslyAndWaitUntil(async () => { + try { + // The file mirrors the COMPLETE branch config (current override merged with + // this change), not just this delta. + const completeConfig = await getCompleteBranchConfigForFile({ projectId, branchId, configUpdate }); + + const result = await applyConfigUpdate({ + getGithubToken, + ref, + completeConfig, + onSandboxId, + onStage, + onProgress, + }); + + if (result.mode === "no-change") { + await recordConfigAgentRunResult({ + projectId, + branchId, + runId, + nowMs: Date.now(), + outcome: { status: "no-change" }, + }); + } else { + await setConfigAgentRunAwaitingReview({ + runId, + change: result.change, + }); + } + } catch (error) { + captureError("config-github-apply", error); + await recordConfigAgentRunResult({ + projectId, + branchId, + runId, + nowMs: Date.now(), + outcome: { status: "error", error: "The config agent failed to apply the change." }, + }).catch((e) => captureError("config-github-apply-record-error", e)); + } + }); + + return { statusCode: 200, bodyType: "json", body: { status: "started", id: runId } }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/config/github/cancel/route.tsx b/apps/backend/src/app/api/latest/internal/config/github/cancel/route.tsx new file mode 100644 index 000000000..d5f3d1d63 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/github/cancel/route.tsx @@ -0,0 +1,66 @@ +import { + cancelConfigAgentRun, + getGithubConfigSourceOrThrow, +} from "@/lib/config"; +import { stopConfigAgentSandbox } from "@/lib/config/repo-agent"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; + +export const maxDuration = 60; + +/** + * Atomically flips the run to terminal `cancelled` (so the original run's late + * result is ignored) and hard-stops its sandbox if one was recorded (only `running` + * runs have a live sandbox; an `awaiting_review` run's sandbox is already gone, so + * cancelling it just discards the captured change set before it is committed). No + * commit has been pushed at this point, so there is nothing to revert. + */ +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Cancel an in-flight config agent run", + description: "Stops the running config agent sandbox for the linked GitHub repo.", + tags: ["Config"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + body: yupObject({ + run_id: yupString().uuid().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + status: yupString().oneOf(["cancelling", "not-running"]).defined(), + }).defined(), + }), + handler: async (req) => { + const projectId = req.auth.tenancy.project.id; + const branchId = req.auth.tenancy.branchId; + + await getGithubConfigSourceOrThrow({ projectId, branchId }); + + const { cancelled, sandboxId, previousStatus } = await cancelConfigAgentRun({ projectId, branchId, runId: req.body.run_id, nowMs: Date.now() }); + if (!cancelled) { + return { statusCode: 200, bodyType: "json", body: { status: "not-running" } }; + } + + if (sandboxId) { + runAsynchronouslyAndWaitUntil(stopConfigAgentSandbox(sandboxId)); + } else if (previousStatus === "running") { + // A `running` run should always have a sandbox recorded; missing one means it + // may still be running. (An `awaiting_review` run has no live sandbox — the + // change set was already captured and the sandbox stopped — so that's expected.) + captureError("config-github-cancel", new Error("Cancelled a running config agent run but no sandboxId was recorded; the sandbox may still be running.")); + } + + return { statusCode: 200, bodyType: "json", body: { status: "cancelling" } }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/config/github/commit/route.tsx b/apps/backend/src/app/api/latest/internal/config/github/commit/route.tsx new file mode 100644 index 000000000..757e93c17 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/github/commit/route.tsx @@ -0,0 +1,109 @@ +import { + getConfigAgentRunChange, + getGithubConfigSourceOrThrow, + recordConfigAgentRunResult, +} from "@/lib/config"; +import { CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, ConfigRepoCommitConflictError, commitConfigUpdate, type GithubRepoRef } from "@/lib/config/repo-agent"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; + +// The commit is a handful of GitHub API calls (~seconds); the generous ceiling just +// absorbs slow GitHub responses for large change sets. +export const maxDuration = 120; + +/** + * Commits the agent's captured change set to GitHub after the user reviews the diff. + * The change set was captured and persisted when the run entered `awaiting_review` + * (the sandbox is long gone), so this replays it via the GitHub git data API — it + * works no matter how long the review took. Returns immediately and does the work in + * the background; the dashboard polls `agent_run` for the result. + */ +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Commit config agent changes to GitHub", + description: "Commits the agent's captured config change to the linked GitHub branch after user review.", + tags: ["Config"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + body: yupObject({ + run_id: yupString().uuid().defined(), + github_access_token: yupString().defined(), + commit_message: yupString().optional(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + status: yupString().oneOf(["committing", "not-awaiting-review"]).defined(), + }).defined(), + }), + handler: async (req) => { + const projectId = req.auth.tenancy.project.id; + const branchId = req.auth.tenancy.branchId; + + const source = await getGithubConfigSourceOrThrow({ projectId, branchId }); + + const runId = req.body.run_id; + const plan = await getConfigAgentRunChange({ projectId, branchId, runId }); + if (!plan || plan.status !== "awaiting_review") { + return { statusCode: 200, bodyType: "json", body: { status: "not-awaiting-review" } }; + } + + if (!plan.change) { + // Awaiting review but no captured change (should not happen for runs created by + // the current apply flow). Mark it errored so the dashboard surfaces a retry. + await recordConfigAgentRunResult({ + projectId, + branchId, + runId, + nowMs: Date.now(), + outcome: { status: "error", error: "Failed to commit and push the config changes." }, + }); + return { statusCode: 200, bodyType: "json", body: { status: "not-awaiting-review" } }; + } + + const change = plan.change; + const githubToken = req.body.github_access_token; + const commitMessage = req.body.commit_message?.trim() || "chore(hexclave): update config from dashboard"; + const ref: GithubRepoRef = { owner: source.owner, repo: source.repo, branch: source.branch }; + const getGithubToken = async () => githubToken; + + runAsynchronouslyAndWaitUntil(async () => { + try { + const result = await commitConfigUpdate({ getGithubToken, ref, commitMessage, change }); + await recordConfigAgentRunResult({ + projectId, + branchId, + runId, + nowMs: Date.now(), + outcome: { status: "success", commitUrl: result.commitUrl, newCommitHash: result.commitSha, committedRef: ref }, + }); + } catch (error) { + if (!(error instanceof ConfigRepoCommitConflictError)) { + captureError("config-github-commit", error); + } + await recordConfigAgentRunResult({ + projectId, + branchId, + runId, + nowMs: Date.now(), + outcome: { + status: "error", + error: error instanceof ConfigRepoCommitConflictError ? CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR : "Failed to commit and push the config changes.", + }, + }).catch((e) => captureError("config-github-commit-record-error", e)); + } + }); + + return { statusCode: 200, bodyType: "json", body: { status: "committing" } }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/config/github/run/route.tsx b/apps/backend/src/app/api/latest/internal/config/github/run/route.tsx new file mode 100644 index 000000000..dd1f035c3 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/github/run/route.tsx @@ -0,0 +1,47 @@ +import { getConfigAgentRun } from "@/lib/config"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, configAgentRunSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +/** + * Returns the state of a specific dashboard→GitHub config agent run (by `run_id`), + * or `null` if it doesn't belong to this branch. The dashboard polls this while a + * run is in flight to show progress and, once `awaiting_review`, the diff. Each run + * is its own row in the `ConfigAgentRun` table, addressed by id. + */ +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Get config agent run state", + description: "Returns a specific config agent run (by run_id) for the linked GitHub repo.", + tags: ["Config"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + query: yupObject({ + run_id: yupString().uuid().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + agent_run: configAgentRunSchema.nullable().defined(), + }).defined(), + }), + handler: async (req) => { + const agentRun = await getConfigAgentRun({ + projectId: req.auth.tenancy.project.id, + branchId: req.auth.tenancy.branchId, + runId: req.query.run_id, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { agent_run: agentRun }, + }; + }, +}); diff --git a/apps/backend/src/lib/ai/models.ts b/apps/backend/src/lib/ai/models.ts index 69c58eaf6..63acc17da 100644 --- a/apps/backend/src/lib/ai/models.ts +++ b/apps/backend/src/lib/ai/models.ts @@ -2,6 +2,7 @@ import { isLocalEmulatorEnabled } from "@/lib/local-emulator"; import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { PRODUCTION_AI_PROXY_BASE_URL } from "./proxy-url"; export const MODEL_QUALITIES = ["dumb", "smart", "smartest"] as const; export const MODEL_SPEEDS = ["slow", "fast"] as const; @@ -51,6 +52,7 @@ const MODEL_SELECTION_MATRIX: Record< // All unique model IDs referenced in the selection matrix, plus sonnet as the proxy default export const ALLOWED_MODEL_IDS: ReadonlySet = new Set([ "anthropic/claude-sonnet-4.6", + "anthropic/claude-haiku-4.5", ...Object.values(MODEL_SELECTION_MATRIX).flatMap(quality => Object.values(quality).flatMap(speed => Object.values(speed).map(config => config.modelId) @@ -61,7 +63,7 @@ export const ALLOWED_MODEL_IDS: ReadonlySet = new Set([ export function createOpenRouterProvider() { const baseURL = (getNodeEnvironment() === "development" || isLocalEmulatorEnabled()) ? "http://localhost:8102/api/latest/integrations/ai-proxy/v1" - : "https://api.hexclave.com/api/latest/integrations/ai-proxy/v1"; + : `${PRODUCTION_AI_PROXY_BASE_URL}/v1`; return createOpenRouter({ apiKey: "forwarded", baseURL, diff --git a/apps/backend/src/lib/ai/proxy-url.ts b/apps/backend/src/lib/ai/proxy-url.ts new file mode 100644 index 000000000..e3c4da6ee --- /dev/null +++ b/apps/backend/src/lib/ai/proxy-url.ts @@ -0,0 +1 @@ +export const PRODUCTION_AI_PROXY_BASE_URL = "https://api.hexclave.com/api/latest/integrations/ai-proxy"; diff --git a/apps/backend/src/lib/config/index.test.tsx b/apps/backend/src/lib/config/index.test.tsx new file mode 100644 index 000000000..751a39af6 --- /dev/null +++ b/apps/backend/src/lib/config/index.test.tsx @@ -0,0 +1,198 @@ +import { randomUUID } from "crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Prisma } from "@/generated/prisma/client"; +import { globalPrismaClient } from "@/prisma-client"; +import { cancelConfigAgentRun, getConfigAgentRun, getConfigAgentRunChange, recordConfigAgentRunResult, recordConfigAgentRunSandbox, setConfigAgentRunAwaitingReview, startConfigAgentRun } from "./index"; + +const createdProjectIds: string[] = []; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +const githubSource: Prisma.InputJsonObject = { + type: "pushed-from-github", + owner: "hexclave-validation", + repo: "config-agent-validation", + branch: "main", + commit_hash: "base-commit", + config_file_path: "hexclave.config.ts", +}; + +async function createGithubLinkedBranch() { + const projectId = randomUUID(); + const branchId = "main"; + createdProjectIds.push(projectId); + await globalPrismaClient.project.create({ + data: { + id: projectId, + displayName: "Config agent validation", + isProductionMode: false, + isDevelopmentEnvironment: true, + }, + }); + await globalPrismaClient.branchConfigOverride.create({ + data: { projectId, branchId, config: {}, source: githubSource }, + }); + return { projectId, branchId }; +} + +async function readBranchRow(projectId: string, branchId: string) { + return await globalPrismaClient.branchConfigOverride.findUniqueOrThrow({ + where: { projectId_branchId: { projectId, branchId } }, + }); +} + +afterEach(async () => { + // ConfigAgentRun rows cascade-delete with the project. + await globalPrismaClient.project.deleteMany({ + where: { id: { in: createdProjectIds.splice(0) } }, + }); +}); + +describe("config agent run state", () => { + it("starts independent runs for the same branch instead of overwriting", async () => { + const { projectId, branchId } = await createGithubLinkedBranch(); + + const first = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 }); + const second = await startConfigAgentRun({ projectId, branchId, nowMs: 2000 }); + + expect(first.source.type).toBe("pushed-from-github"); + expect(first.runId).not.toBe(second.runId); + // Runs are NOT serialized: both rows coexist, each still "running". + expect((await getConfigAgentRun({ projectId, branchId, runId: first.runId }))?.status).toBe("running"); + expect((await getConfigAgentRun({ projectId, branchId, runId: second.runId }))?.status).toBe("running"); + }); + + it("scopes a run read to its own project/branch", async () => { + const a = await createGithubLinkedBranch(); + const b = await createGithubLinkedBranch(); + const { runId } = await startConfigAgentRun({ projectId: a.projectId, branchId: a.branchId, nowMs: 1000 }); + + // The run id is real, but asking under a different project must not leak it. + expect(await getConfigAgentRun({ projectId: b.projectId, branchId: b.branchId, runId })).toBeNull(); + expect((await getConfigAgentRun({ projectId: a.projectId, branchId: a.branchId, runId }))?.id).toBe(runId); + }); + + it("won't move a cancelled run to awaiting_review", async () => { + const { projectId, branchId } = await createGithubLinkedBranch(); + const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 }); + await recordConfigAgentRunSandbox({ runId, sandboxId: "sandbox-1" }); + + const cancel = await cancelConfigAgentRun({ projectId, branchId, runId, nowMs: 2000 }); + expect(cancel).toMatchObject({ cancelled: true, sandboxId: "sandbox-1", previousStatus: "running" }); + + // A late transition from the agent must not resurrect the cancelled run. + await setConfigAgentRunAwaitingReview({ runId, change: { diff: "old diff", baseSha: "abc123" } }); + + const run = await getConfigAgentRun({ projectId, branchId, runId }); + expect(run?.status).toBe("cancelled"); + expect(run?.diff).toBeUndefined(); + }); + + it("captures the diff + base commit on awaiting_review; base commit is server-only", async () => { + const { projectId, branchId } = await createGithubLinkedBranch(); + const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 }); + + const change = { diff: "diff --git a/hexclave.config.ts b/hexclave.config.ts\n@@ -1 +1 @@\n-a\n+b\n", baseSha: "abc123" }; + await setConfigAgentRunAwaitingReview({ runId, change }); + + // The captured change is readable for the commit route... + const plan = await getConfigAgentRunChange({ projectId, branchId, runId }); + expect(plan?.status).toBe("awaiting_review"); + expect(plan?.change).toEqual(change); + + // ...the dashboard sees the diff but never the base commit (and sandbox id is cleared). + const run = await getConfigAgentRun({ projectId, branchId, runId }); + expect(run?.status).toBe("awaiting_review"); + expect(run?.diff).toBe(change.diff); + expect(run?.sandbox_id).toBeUndefined(); + expect(run as Record).not.toHaveProperty("base_commit_sha"); + expect(run as Record).not.toHaveProperty("baseCommitSha"); + + // The captured change scopes to its own project/branch like the run read does. + const other = await createGithubLinkedBranch(); + expect(await getConfigAgentRunChange({ projectId: other.projectId, branchId: other.branchId, runId })).toBeNull(); + }); + + it("advances the source commit hash on a successful result", async () => { + const { projectId, branchId } = await createGithubLinkedBranch(); + const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 }); + await setConfigAgentRunAwaitingReview({ runId, change: { diff: "diff --git a/hexclave.config.ts b/hexclave.config.ts", baseSha: "abc123" } }); + + await recordConfigAgentRunResult({ + projectId, + branchId, + runId, + nowMs: 3000, + outcome: { + status: "success", + commitUrl: "https://github.com/hexclave-validation/config-agent-validation/commit/new", + newCommitHash: "new-commit", + committedRef: { owner: "hexclave-validation", repo: "config-agent-validation", branch: "main" }, + }, + }); + + expect((await getConfigAgentRun({ projectId, branchId, runId }))?.status).toBe("success"); + const { source } = await readBranchRow(projectId, branchId); + expect(isRecord(source) ? source.commit_hash : null).toBe("new-commit"); + }); + + it("does not advance the commit hash when the branch was re-linked to a different repo mid-run", async () => { + const { projectId, branchId } = await createGithubLinkedBranch(); + const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 }); + await setConfigAgentRunAwaitingReview({ runId, change: { diff: "diff --git a/hexclave.config.ts b/hexclave.config.ts", baseSha: "abc123" } }); + + // The branch is re-linked to a DIFFERENT repo after the commit was pushed but + // before the result is recorded. + await globalPrismaClient.branchConfigOverride.update({ + where: { projectId_branchId: { projectId, branchId } }, + data: { source: { ...githubSource, repo: "some-other-repo", commit_hash: "other-base" } }, + }); + + await recordConfigAgentRunResult({ + projectId, + branchId, + runId, + nowMs: 3000, + outcome: { + status: "success", + commitUrl: "https://github.com/hexclave-validation/config-agent-validation/commit/new", + newCommitHash: "new-commit", + committedRef: { owner: "hexclave-validation", repo: "config-agent-validation", branch: "main" }, + }, + }); + + // The run still succeeds, but the new repo's source must NOT inherit a hash from + // the old repo's commit. + expect((await getConfigAgentRun({ projectId, branchId, runId }))?.status).toBe("success"); + const { source } = await readBranchRow(projectId, branchId); + expect(isRecord(source) ? source.commit_hash : null).toBe("other-base"); + }); + + it("ignores a terminal result for an already-cancelled run", async () => { + const { projectId, branchId } = await createGithubLinkedBranch(); + const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 }); + await cancelConfigAgentRun({ projectId, branchId, runId, nowMs: 2000 }); + + await recordConfigAgentRunResult({ + projectId, + branchId, + runId, + nowMs: 3000, + outcome: { + status: "success", + commitUrl: "https://github.com/hexclave-validation/config-agent-validation/commit/stale", + newCommitHash: "stale-commit", + committedRef: { owner: "hexclave-validation", repo: "config-agent-validation", branch: "main" }, + }, + }); + + // The cancel wins; neither the run status nor the source commit hash moves. + expect((await getConfigAgentRun({ projectId, branchId, runId }))?.status).toBe("cancelled"); + const { source } = await readBranchRow(projectId, branchId); + expect(isRecord(source) ? source.commit_hash : null).toBe("base-commit"); + }); +}); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config/index.tsx similarity index 79% rename from apps/backend/src/lib/config.tsx rename to apps/backend/src/lib/config/index.tsx index e7144d0b3..a6d962890 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config/index.tsx @@ -1,19 +1,21 @@ import { Prisma } from "@/generated/prisma/client"; +import type { ConfigAgentRun as ConfigAgentRunRow } from "@/generated/prisma/client"; import { Config, getInvalidConfigReason, normalize, override, removeKeysFromConfig } from "@hexclave/shared/dist/config/format"; import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, CompleteConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@hexclave/shared/dist/config/schema"; import { ProjectsCrud } from "@hexclave/shared/dist/interface/crud/projects"; -import { branchConfigSourceSchema, yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields"; +import { branchConfigSourceSchema, type ConfigAgentRunApi, type ConfigAgentSafeErrorMessage, yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields"; import { isTruthy } from "@hexclave/shared/dist/utils/booleans"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; -import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors"; +import { HexclaveAssertionError, StatusError, captureError } from "@hexclave/shared/dist/utils/errors"; import { filterUndefined, typedEntries } from "@hexclave/shared/dist/utils/objects"; import { Result } from "@hexclave/shared/dist/utils/results"; import { deindent, stringCompare } from "@hexclave/shared/dist/utils/strings"; import * as yup from "yup"; -import { RawQuery, globalPrismaClient, rawQuery } from "../prisma-client"; -import { DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE, getEnvironmentConfigWriteBlockReason, isDevelopmentEnvironmentProject } from "./development-environment"; -import { getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "./local-emulator"; -import { listPermissionDefinitionsFromConfig } from "./permissions"; +import { PrismaClientTransaction, RawQuery, globalPrismaClient, rawQuery, retryTransaction } from "../../prisma-client"; +import { DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE, getEnvironmentConfigWriteBlockReason, isDevelopmentEnvironmentProject } from "../development-environment"; +import { getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "../local-emulator"; +import { listPermissionDefinitionsFromConfig } from "../permissions"; +import type { CapturedChange, ConfigAgentInFlightStage, GithubRepoRef } from "./repo-agent"; type BranchConfigSourceApi = yup.InferType; export type BranchConfigPushedError = { @@ -475,6 +477,323 @@ export async function unlinkBranchConfigOverrideSource(options: { }); } +export type GithubConfigSource = Extract; + +/** + * Loads the branch config source and asserts it is linked to a GitHub repo, + * returning the narrowed `pushed-from-github` source (else throws `BadRequest`). + * Shared by the GitHub config-agent routes. + */ +export async function getGithubConfigSourceOrThrow(options: { + projectId: string, + branchId: string, +}): Promise { + const source = await getBranchConfigOverrideSource(options); + if (source.type !== "pushed-from-github") { + throw new StatusError(StatusError.BadRequest, "This project's configuration is not linked to a GitHub repository."); + } + return source; +} + +/** Maps a `ConfigAgentRun` table row to the API shape the dashboard polls. */ +function toConfigAgentRunApi(row: ConfigAgentRunRow): ConfigAgentRunApi { + return { + id: row.id, + status: row.status as ConfigAgentRunApi["status"], + started_at: row.startedAt.getTime(), + ...(row.finishedAt != null ? { finished_at: row.finishedAt.getTime() } : {}), + ...(row.commitUrl != null ? { commit_url: row.commitUrl } : {}), + ...(row.error != null ? { error: row.error as ConfigAgentSafeErrorMessage } : {}), + ...(row.sandboxId != null ? { sandbox_id: row.sandboxId } : {}), + ...(row.progress != null ? { progress: row.progress } : {}), + ...(row.stage != null ? { stage: row.stage as NonNullable } : {}), + ...(row.diff != null ? { diff: row.diff } : {}), + }; +} + +/** + * `FOR UPDATE`-locks a single run row by id (so a read-modify-write transition can't + * race a concurrent cancel/commit on the SAME run). Returns `null` if the run is gone. + * Each run is its own row, so this never blocks a different run on the same branch. + */ +async function lockConfigAgentRun(tx: PrismaClientTransaction, runId: string): Promise { + const rows = await tx.$queryRaw` + SELECT * FROM "ConfigAgentRun" WHERE "id" = ${runId}::uuid FOR UPDATE + `; + return rows[0] ?? null; +} + +/** + * Computes the COMPLETE config that belongs in the linked repo's config file: the + * project's current BRANCH config override (the user's full intended config — + * enabled apps, sign-up rules WITH their content, auth settings, …) merged with + * the dashboard's pending change, normalized to a nested object. The repo agent + * writes this WHOLE object to the file, so the file stays complete instead of + * accreting one-key deltas. (The config file maps 1:1 to the branch config + * override — `pushConfig` from the repo's workflow replaces the branch override + * with the file's contents, so an incomplete file would wipe real config.) + */ +export async function getCompleteBranchConfigForFile(options: { + projectId: string, + branchId: string, + configUpdate: Record, +}): Promise> { + const current = await rawQuery(globalPrismaClient, getBranchConfigOverrideQuery({ projectId: options.projectId, branchId: options.branchId })); + const merged = override(current as Config, options.configUpdate as Config); + // Dashboard saves usually arrive as dot-notation deltas (for example + // `auth.allowSignUp: false`). The branch override can be empty, so missing + // parents must be materialized instead of silently dropping the pending edit. + return normalize(merged, { onDotIntoNonObject: "ignore", onDotIntoNull: "empty-object" }) as Record; +} + +/** + * Records the start of a dashboard→GitHub config agent run: inserts a fresh + * `running` row in the `ConfigAgentRun` table and returns its id plus the locked + * GitHub source (for the repo ref). Runs are intentionally NOT serialized — each + * start is an independent row, so many runs can target the same branch at once; + * a concurrent edit to the real repo is caught by GitHub at push time + * (`assertRemoteBranchStillAtClonedHead` / non-fast-forward → `ConfigRepoCommitConflictError`), + * never by a DB lock. {@link recordConfigAgentRunResult} writes the terminal status. + */ +export async function startConfigAgentRun(options: { + projectId: string, + branchId: string, + nowMs: number, +}): Promise<{ source: GithubConfigSource, runId: string }> { + return await retryTransaction(globalPrismaClient, async (tx) => { + // Read (and lock) the source in the same txn so a concurrent re-link can't + // redirect this run's push to a different repo. + const rows = await tx.$queryRaw<{ source: BranchConfigSourceApi | null }[]>` + SELECT "source" FROM "BranchConfigOverride" + WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId} + FOR UPDATE + `; + const source = rows[0]?.source ?? null; + if (source?.type !== "pushed-from-github") { + throw new HexclaveAssertionError("Config source is not linked to GitHub; cannot run the config agent."); + } + const run = await tx.configAgentRun.create({ + data: { + projectId: options.projectId, + branchId: options.branchId, + status: "running", + startedAt: new Date(options.nowMs), + }, + select: { id: true }, + }); + return { source, runId: run.id }; + }); +} + +/** + * Reads a specific config-agent run (scoped to its project/branch) for the + * dashboard to poll. Returns `null` if the run id doesn't belong to this branch. + */ +export async function getConfigAgentRun(options: { + projectId: string, + branchId: string, + runId: string, +}): Promise { + const row = await globalPrismaClient.configAgentRun.findFirst({ + where: { id: options.runId, projectId: options.projectId, branchId: options.branchId }, + }); + return row ? toConfigAgentRunApi(row) : null; +} + +/** + * Records the live sandbox id of an in-flight run so a later cancel (a separate + * request/invocation) can hard-stop it. The `status = "running"` guard makes it a + * no-op once the run is terminal, so a late write can't resurrect a sandbox id. + */ +export async function recordConfigAgentRunSandbox(options: { + runId: string, + sandboxId: string, +}): Promise { + await globalPrismaClient.configAgentRun.updateMany({ + where: { id: options.runId, status: "running" }, + data: { sandboxId: options.sandboxId }, + }); +} + +/** + * Writes the live (sanitized) activity feed of an in-flight run so the dashboard + * can show what the agent is doing. No-ops unless this run is still `running`. The + * caller is responsible for keeping `progress` short and free of secrets/tokens. + */ +export async function recordConfigAgentRunProgress(options: { + runId: string, + progress: string, +}): Promise { + await globalPrismaClient.configAgentRun.updateMany({ + where: { id: options.runId, status: "running" }, + // DB-size guard on the persisted feed; the runner already trims lines/count + // (see buildRunnerScript), so this only bites pathological input. + data: { progress: options.progress.slice(0, 2000) }, + }); +} + +/** + * Records the current stage of an in-flight run for the dashboard progress bar. + * No-ops unless this run is still `running`. + */ +export async function recordConfigAgentRunStage(options: { + runId: string, + stage: ConfigAgentInFlightStage, +}): Promise { + await globalPrismaClient.configAgentRun.updateMany({ + where: { id: options.runId, status: "running" }, + data: { stage: options.stage }, + }); +} + +/** + * Transitions a `running` run to `awaiting_review`: the agent has finished editing and + * the change is already captured (the sandbox has been stopped). The diff is stored for + * the dashboard AND as the commit source, with `baseCommitSha` (the commit it was made + * against) so it can be rebuilt + pushed via the GitHub API on confirm; the stale + * sandbox id is cleared. No-ops if the run is no longer `running` — e.g. cancelled mid-flight. + */ +export async function setConfigAgentRunAwaitingReview(options: { + runId: string, + change: CapturedChange, +}): Promise { + await retryTransaction(globalPrismaClient, async (tx) => { + const run = await lockConfigAgentRun(tx, options.runId); + if (run?.status !== "running") return; + await tx.configAgentRun.update({ + where: { id: options.runId }, + data: { + status: "awaiting_review", + stage: "awaiting_review", + // The diff is authoritative for the commit, so it is stored whole (already + // size-capped at capture time), not truncated. + diff: options.change.diff, + baseCommitSha: options.change.baseSha, + sandboxId: null, + }, + }); + }); +} + +/** + * Loads a run's captured change (diff + base commit) for the commit route, scoped to + * its project/branch. Returns `null` if the run id doesn't belong to this branch, or + * `{ status, change: null }` if the row isn't carrying a complete capture. + */ +export async function getConfigAgentRunChange(options: { + projectId: string, + branchId: string, + runId: string, +}): Promise<{ status: string, change: CapturedChange | null } | null> { + const row = await globalPrismaClient.configAgentRun.findFirst({ + where: { id: options.runId, projectId: options.projectId, branchId: options.branchId }, + select: { status: true, diff: true, baseCommitSha: true }, + }); + if (!row) return null; + const change = row.diff != null && row.baseCommitSha != null + ? { diff: row.diff, baseSha: row.baseCommitSha } + : null; + return { status: row.status, change }; +} + +/** + * Requests cancellation of a specific config agent run. Atomically flips a + * `running` or `awaiting_review` run to the terminal `cancelled` status and returns + * the sandbox id (only present while `running`) so the caller can hard-stop the + * sandbox, plus the `previousStatus` (so the caller can tell "running with no sandbox + * recorded" — a real leak — from "awaiting_review", where the sandbox is expected to + * be gone already). Returns `{ cancelled: false }` when the run is gone, not on this + * branch, or already terminal. (No revert: a commit that already landed stays.) + */ +export async function cancelConfigAgentRun(options: { + projectId: string, + branchId: string, + runId: string, + nowMs: number, +}): Promise<{ cancelled: boolean, sandboxId?: string, previousStatus?: string }> { + return await retryTransaction(globalPrismaClient, async (tx) => { + const run = await lockConfigAgentRun(tx, options.runId); + if (!run || run.projectId !== options.projectId || run.branchId !== options.branchId) { + return { cancelled: false }; + } + if (run.status !== "running" && run.status !== "awaiting_review") { + return { cancelled: false }; + } + await tx.configAgentRun.update({ + where: { id: options.runId }, + // Clear the captured change too: a cancelled run is abandoned, so its diff/base + // must not linger in the API shape or be replayable by the commit route. + data: { status: "cancelled", finishedAt: new Date(options.nowMs), sandboxId: null, stage: null, baseCommitSha: null, diff: null }, + }); + return { cancelled: true, sandboxId: run.sandboxId ?? undefined, previousStatus: run.status }; + }); +} + +/** + * Records the outcome of a config agent run: stamps the terminal run status, and on + * a pushed commit advances the source's `commit_hash`. No-ops unless the run is + * still in flight (`running`/`awaiting_review`), so a cancel that already landed wins. + */ +export async function recordConfigAgentRunResult(options: { + projectId: string, + branchId: string, + runId: string, + nowMs: number, + outcome: + | { status: "success", commitUrl?: string, newCommitHash?: string, committedRef: GithubRepoRef } + | { status: "no-change" } + | { status: "error", error: ConfigAgentSafeErrorMessage }, +}): Promise { + await retryTransaction(globalPrismaClient, async (tx) => { + const run = await lockConfigAgentRun(tx, options.runId); + if (!run || (run.status !== "running" && run.status !== "awaiting_review")) return; + const finishedAt = new Date(options.nowMs); + if (options.outcome.status === "error") { + await tx.configAgentRun.update({ + where: { id: options.runId }, + data: { status: "error", finishedAt, error: options.outcome.error, sandboxId: null, stage: null, baseCommitSha: null }, + }); + return; + } + if (options.outcome.status === "no-change") { + await tx.configAgentRun.update({ + where: { id: options.runId }, + data: { status: "no-change", finishedAt, sandboxId: null, stage: null, baseCommitSha: null }, + }); + return; + } + await tx.configAgentRun.update({ + where: { id: options.runId }, + data: { status: "success", finishedAt, commitUrl: options.outcome.commitUrl ?? null, sandboxId: null, stage: null, baseCommitSha: null }, + }); + // Advance the source's last-known commit when a commit landed and the branch + // is still linked to the SAME repo the commit was pushed against (locked in the + // same txn). A mid-run re-link to a different repo still reads as + // `pushed-from-github`, so identity — not just type — must match, or the new + // source would inherit a commit hash that only exists on the old repo. + const committedRef = options.outcome.committedRef; + if (options.outcome.newCommitHash) { + const sourceRows = await tx.$queryRaw<{ source: BranchConfigSourceApi | null }[]>` + SELECT "source" FROM "BranchConfigOverride" + WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId} + FOR UPDATE + `; + const source = sourceRows[0]?.source ?? null; + if ( + source?.type === "pushed-from-github" + && source.owner === committedRef.owner + && source.repo === committedRef.repo + && source.branch === committedRef.branch + ) { + await tx.branchConfigOverride.update({ + where: { projectId_branchId: { projectId: options.projectId, branchId: options.branchId } }, + data: { source: { ...source, commit_hash: options.outcome.newCommitHash } as any }, + }); + } + } + }); +} + export async function setEnvironmentConfigOverride(options: { projectId: string, branchId: string, @@ -1173,7 +1492,7 @@ import.meta.vitest?.test('setEnvironmentConfigOverride blocks writes for develop throw new HexclaveAssertionError("Vitest context is required for in-source tests."); } - const developmentEnvironment = await import("./development-environment"); + const developmentEnvironment = await import("../development-environment"); // Spy on getEnvironmentConfigWriteBlockReason directly, because spying on // isDevelopmentEnvironmentProject does not intercept intra-module calls diff --git a/apps/backend/src/lib/config/repo-agent.test.ts b/apps/backend/src/lib/config/repo-agent.test.ts new file mode 100644 index 000000000..01d1bc695 --- /dev/null +++ b/apps/backend/src/lib/config/repo-agent.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, ConfigRepoCommitConflictError, rebuildFilesFromDiff } from "./repo-agent"; + +describe("config repo agent commit conflict", () => { + it("uses a safe user-facing conflict message", () => { + // A concurrent push surfaces as this error (the pre-check mismatch or a 422 from + // the non-forced GitHub ref update); the message must stay in the safe allowlist. + expect(new ConfigRepoCommitConflictError().message).toMatchInlineSnapshot( + `"The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch."`, + ); + expect(CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR).toMatchInlineSnapshot( + `"The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch."`, + ); + }); +}); + +describe("rebuildFilesFromDiff", () => { + // git diff for a modified file (the config), a brand-new imported file, and a deletion — + // the kind of multi-file change the agent produces when the config pulls in other files. + const base = new Map([ + ["hexclave.config.ts", "import { theme } from \"./theme\";\nexport default { theme, signUp: false };\n"], + ["legacy.ts", "export const legacy = true;\n"], + ]); + const resolveBase = async (path: string) => base.get(path) ?? ""; + + const diff = [ + "diff --git a/hexclave.config.ts b/hexclave.config.ts", + "index 1111111..2222222 100644", + "--- a/hexclave.config.ts", + "+++ b/hexclave.config.ts", + "@@ -1,2 +1,2 @@", + " import { theme } from \"./theme\";", + "-export default { theme, signUp: false };", + "+export default { theme, signUp: true };", + "diff --git a/theme.ts b/theme.ts", + "new file mode 100644", + "index 0000000..3333333", + "--- /dev/null", + "+++ b/theme.ts", + "@@ -0,0 +1 @@", + "+export const theme = \"dark\";", + "diff --git a/legacy.ts b/legacy.ts", + "deleted file mode 100644", + "index 4444444..0000000", + "--- a/legacy.ts", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-export const legacy = true;", + "", + ].join("\n"); + + it("rebuilds a modified file, a new file, and a deletion across the whole repo", async () => { + const files = await rebuildFilesFromDiff(diff, resolveBase); + + expect(files).toContainEqual({ + path: "hexclave.config.ts", + newContent: "import { theme } from \"./theme\";\nexport default { theme, signUp: true };\n", + }); + // A new imported file the agent added — applied onto an empty base. + expect(files).toContainEqual({ path: "theme.ts", newContent: "export const theme = \"dark\";\n" }); + // A deleted file — recorded as a deletion, no content. + expect(files).toContainEqual({ path: "legacy.ts", deleted: true }); + expect(files).toHaveLength(3); + }); + + it("throws if a hunk cannot be applied onto the given base (stale/corrupt diff)", async () => { + const wrongBase = async () => "totally different contents\n"; + await expect(rebuildFilesFromDiff(diff, wrongBase)).rejects.toThrow(/Could not rebuild/); + }); +}); diff --git a/apps/backend/src/lib/config/repo-agent.ts b/apps/backend/src/lib/config/repo-agent.ts new file mode 100644 index 000000000..12970a016 --- /dev/null +++ b/apps/backend/src/lib/config/repo-agent.ts @@ -0,0 +1,754 @@ +/** + * Dashboard -> GitHub config write, full-repo-in-a-sandbox edition. + * + * A Claude agent edits the WHOLE repo (config can span files), not just the config + * file; we skip in-sandbox validation since the linked repo's GitHub Action + * re-validates on push. Vercel Sandbox can't boot a custom image, so we warm-boot + * from one SHARED, repo-independent base snapshot (agent runtime only, never a repo + * or token; build via scripts/config-agent/build-image.ts -> STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID, + * else cold-boot node24 + install the SDK inline) and take a FRESH shallow clone per write. + * + * The sandbox is short-lived: it exists only to run the agent and CAPTURE the change + * as a unified diff plus the base commit it was made against. As soon as that is + * captured the sandbox is stopped — the review window is then unbounded and free + * (nothing is kept alive). On confirm, `commitConfigUpdate` rebuilds the file contents + * by applying the stored diff onto the exact base files (fetched from GitHub) and + * commits via the git data REST API, so a slow reviewer, a closed tab, or a crashed + * browser can never strand a running sandbox. + * + * Token discipline: a fresh token is fetched per boot/commit (GithubTokenProvider). + * In the sandbox it is injected only into the clone remote URL (never the agent's env), + * redacted from thrown errors, and never snapshotted. At commit time it is sent only as + * an `Authorization` header to api.github.com, never persisted. + */ + +import { buildCompleteConfigAgentPrompt, CONFIG_AGENT_REPO_TOOLS } from "@hexclave/shared-backend/config-agent"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; +import { Sandbox } from "@vercel/sandbox"; +import { applyPatch, parsePatch } from "diff"; +import { PRODUCTION_AI_PROXY_BASE_URL } from "../ai/proxy-url"; + +const AGENT_SDK_VERSION = "0.2.73"; +const BASE = "/vercel/sandbox"; +const REPO_DIR = `${BASE}/repo`; +const TOOLS_DIR = BASE; // agent SDK + runner live here, separate from the repo +const DEFAULT_AGENT_MODEL = "anthropic/claude-haiku-4.5"; +const SANDBOX_TIMEOUT_MS = 900_000; +// Cap on the unified diff we persist (it is authoritative for the deferred commit, so +// it is never truncated — instead an oversized change fails at capture time). Config +// diffs are tiny; this only guards against a pathological agent rewriting the repo. +const MAX_CONFIG_DIFF_BYTES = 1_000_000; +const GIT_BOT_NAME = "Hexclave Config Bot"; +const GIT_BOT_EMAIL = "config-bot@hexclave.com"; + +export type GithubRepoRef = { owner: string, repo: string, branch: string }; + +/** + * Stages reported via `onStage` while a run is `running`. `awaiting_review` is + * deliberately excluded — it is set separately, outside the staged progress. + */ +export type ConfigAgentInFlightStage = "initializing_sandbox" | "cloning_repo" | "agent_making_changes"; + +/** + * Supplies a GitHub token at the moment it is needed (sandbox boot for clone, or + * commit time for the REST push) instead of capturing a single token for the whole + * flow, so a long-lived run always picks up the freshest token the caller can produce + * (the dashboard refetches the user's OAuth token per request). + */ +export type GithubTokenProvider = () => Promise; + +/** + * The lightweight change capture persisted for a deferred commit: the unified diff + * the agent produced and the commit it was made against. The diff doubles as the + * dashboard's review render and the commit source (applied onto the base on confirm). + */ +export type CapturedChange = { diff: string, baseSha: string }; + +export type ConfigUpdateCommitResult = { mode: "commit-to-branch", branch: string, commitUrl: string, commitSha: string }; +export const CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR = "The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch."; + +export class ConfigRepoAgentError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ConfigRepoAgentError"; + } +} + +export class ConfigRepoCommitConflictError extends ConfigRepoAgentError { + constructor(options?: { cause?: unknown }) { + super(CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, options); + this.name = "ConfigRepoCommitConflictError"; + } +} + +// --------------------------------------------------------------------------- +// Sandbox credentials + low-level command helpers +// --------------------------------------------------------------------------- + +type SandboxCreds = { teamId?: string, projectId?: string, token: string }; + +function sandboxCreds(): SandboxCreds { + const token = getEnvVariable("STACK_VERCEL_SANDBOX_TOKEN", ""); + if (!token || token === "vercel_sandbox_disabled_for_local_development") { + throw new ConfigRepoAgentError("Vercel Sandbox is not configured (STACK_VERCEL_SANDBOX_TOKEN); the config agent cannot run."); + } + return { + teamId: getEnvVariable("STACK_VERCEL_SANDBOX_TEAM_ID", "") || undefined, + projectId: getEnvVariable("STACK_VERCEL_SANDBOX_PROJECT_ID", "") || undefined, + token, + }; +} + +async function getConfigAgentSandbox(sandboxId: string): Promise { + const creds = sandboxCreds(); + return await Sandbox.get({ sandboxId, token: creds.token, teamId: creds.teamId, projectId: creds.projectId }); +} + +async function stopSandboxWithContext(sandboxId: string, context: string): Promise { + try { + const sandbox = await getConfigAgentSandbox(sandboxId); + await sandbox.stop(); + } catch (error) { + captureError(context, error); + } +} + +async function reportConfigAgentProgress(onProgress: AgentProgressSink | undefined, progress: string, context: string): Promise { + if (!onProgress) return; + try { + await onProgress(progress); + } catch (error) { + captureError(context, error); + } +} + +async function reportConfigAgentStage( + onStage: ((stage: ConfigAgentInFlightStage) => Promise) | undefined, + stage: ConfigAgentInFlightStage, +): Promise { + if (!onStage) return; + try { + await onStage(stage); + } catch (error) { + captureError("config-repo-agent-stage", error); + } +} + +/** + * Strip any tokenized remote URL (`https://x-access-token:@github.com/...`) + * out of a string before it can be thrown, captured, persisted, or logged. The + * clone command passes the tokenized URL as an argv, and git can echo the remote + * URL in its errors — without this the dashboard user's OAuth token could leak + * into the persisted run's `error` or Sentry. + */ +function redactTokens(text: string): string { + return text.replace(/x-access-token:[^@\s/]+@/g, "x-access-token:***@"); +} + +type RunResult = { exitCode: number, stdout: string, stderr: string }; + +async function runRaw(sandbox: Sandbox, cmd: string, args: string[], opts?: { cwd?: string, env?: Record, sudo?: boolean }): Promise { + const finished = await sandbox.runCommand({ cmd, args, ...opts }); + const [stdout, stderr] = await Promise.all([ + finished.stdout().catch(() => ""), + finished.stderr().catch(() => ""), + ]); + return { exitCode: finished.exitCode, stdout, stderr }; +} + +async function run(sandbox: Sandbox, cmd: string, args: string[], opts?: { cwd?: string, env?: Record, sudo?: boolean }): Promise { + const r = await runRaw(sandbox, cmd, args, opts); + if (r.exitCode !== 0) { + throw new ConfigRepoAgentError(redactTokens(`Command failed (exit ${r.exitCode}): ${cmd} ${args.join(" ")}\n${(r.stderr || r.stdout).slice(-1500)}`)); + } + return r; +} + +/** + * Booting from a snapshot can leave `/etc/ssl/certs/ca-certificates.crt` empty, + * which makes git/openssl fail with "error adding trust anchors" on any HTTPS + * remote. Rebuilding the bundle from the (snapshot-captured) CA material fixes + * it and needs no network. Best-effort — ignore failures on images without it. + */ +async function ensureTls(sandbox: Sandbox): Promise { + await runRaw(sandbox, "update-ca-certificates", [], { sudo: true }); +} + +// --------------------------------------------------------------------------- +// Git URLs (token injected only at call time, never persisted) +// --------------------------------------------------------------------------- + +function tokenUrl(token: string, ref: Pick): string { + return `https://x-access-token:${token}@github.com/${ref.owner}/${ref.repo}.git`; +} + +function tokenlessUrl(ref: Pick): string { + return `https://github.com/${ref.owner}/${ref.repo}.git`; +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +/** Runner executed INSIDE the sandbox (no token in its env). Reads input from a + * file and persists status to a file; process handlers catch the SDK's async errors. */ +function buildRunnerScript(): string { + return ` +import { writeFileSync, readFileSync } from "fs"; +const STATUS = ${JSON.stringify(`${TOOLS_DIR}/status.json`)}; +const errs = []; +const status = (o) => { try { writeFileSync(STATUS, JSON.stringify({ ...o, stderr: errs.join("").slice(-4000) })); } catch {} }; +process.on("uncaughtException", (e) => { status({ ok: false, error: "uncaught:" + String((e && e.stack) || e) }); process.exit(1); }); +process.on("unhandledRejection", (e) => { status({ ok: false, error: "unhandledRejection:" + String((e && e.stack) || e) }); process.exit(1); }); + +// SANITIZED progress only: the tool action + a file BASENAME, never tool inputs, +// file contents, results, or assistant text — so no config secret or token can +// leak. Bash shows only the first two tokens (program + subcommand), so args +// (which could carry a token/secret) are never emitted. Written to a JSON file the +// orchestrator polls (robust across the sandbox boundary; no stdout/encoding deps). +const PROGRESS = ${JSON.stringify(`${TOOLS_DIR}/progress.json`)}; +const recent = []; +const base = (p) => (typeof p === "string" ? (p.split("/").pop() || p) : ""); +// In-sandbox live-feed cap: each line trimmed to 100 chars, keep last 6. Storage +// cap is separate (see recordConfigAgentRunProgress). +const emit = (s) => { recent.push(String(s).replace(/[\\r\\n]+/g, " ").slice(0, 100)); while (recent.length > 6) recent.shift(); try { writeFileSync(PROGRESS, JSON.stringify(recent)); } catch {} }; +const describeTool = (name, inp) => { + inp = inp || {}; + switch (name) { + case "Read": return "Reading " + base(inp.file_path); + case "Edit": case "MultiEdit": return "Editing " + base(inp.file_path); + case "Write": return "Writing " + base(inp.file_path); + case "Glob": return "Listing files"; + case "Grep": return "Searching the repo"; + case "Bash": { const c = String(inp.command || "").trim().split(/\\s+/).slice(0, 2).join(" "); return c ? ("Running: " + c) : "Running a command"; } + default: return name || "Working"; + } +}; + +const input = JSON.parse(readFileSync(${JSON.stringify(`${TOOLS_DIR}/agent-input.json`)}, "utf-8")); +status({ ok: false, stage: "loaded" }); +const { query } = await import("@anthropic-ai/claude-agent-sdk"); +let resultText = "", sawResult = false; +for await (const m of query({ + prompt: input.prompt, + options: { + model: input.model, + allowedTools: ${JSON.stringify([...CONFIG_AGENT_REPO_TOOLS])}, + permissionMode: "dontAsk", + cwd: ${JSON.stringify(REPO_DIR)}, + env: { ...process.env, ANTHROPIC_BASE_URL: input.baseUrl, ANTHROPIC_API_KEY: input.apiKey, CLAUDECODE: "" }, + stderr: (d) => errs.push(String(d)), + }, +})) { + if (m.type === "assistant" && m.message && Array.isArray(m.message.content)) { + for (const block of m.message.content) { + if (block && block.type === "tool_use") emit(describeTool(block.name, block.input)); + } + } + if (m.type === "result") { + if ("result" in m) { sawResult = true; resultText = m.result; } + else { status({ ok: false, error: "agent-failure:" + m.subtype }); process.exit(0); } + } +} +status({ ok: sawResult, resultText }); +`; +} + +async function installAgentSdk(sandbox: Sandbox): Promise { + await sandbox.writeFiles([ + { path: `${TOOLS_DIR}/package.json`, content: Buffer.from(JSON.stringify({ name: "config-agent-tools", private: true, type: "module" }), "utf-8") }, + ]); + await run(sandbox, "npm", ["install", "--no-save", `@anthropic-ai/claude-agent-sdk@${AGENT_SDK_VERSION}`], { cwd: TOOLS_DIR }); +} + +/** A sanitized live-activity callback (recent agent actions joined by newlines). */ +export type AgentProgressSink = (activity: string) => Promise; + +const PROGRESS_POLL_MS = 1500; + +/** + * Polls the runner's `progress.json` (last few sanitized tool actions) while the + * detached command runs and forwards changes to `onProgress`. File-based rather + * than stdout-streamed so it's robust across the sandbox boundary (the same + * `readFileToBuffer` path used for `status.json`). `redactTokens` is a 2nd layer. + */ +async function pollAgentProgress( + sandbox: Sandbox, + command: { wait: () => Promise }, + onProgress: AgentProgressSink, +): Promise { + let finished = false; + const markFinished = () => { + finished = true; + }; + const waiter = command.wait().then(markFinished, markFinished); + let last = ""; + const readOnce = async () => { + const buf = await sandbox.readFileToBuffer({ path: `${TOOLS_DIR}/progress.json` }).catch(() => null); + if (!buf) return; + let lines: unknown; + try { + lines = JSON.parse(buf.toString()); + } catch { + return; + } + if (!Array.isArray(lines)) return; + const text = redactTokens(lines.map((l) => String(l)).join("\n")).trim(); + if (text && text !== last) { + last = text; + await reportConfigAgentProgress(onProgress, text, "config-repo-agent-progress-record"); + } + }; + while (!finished) { + await Promise.race([waiter, new Promise((r) => setTimeout(r, PROGRESS_POLL_MS))]); + await readOnce(); + } + await readOnce(); // capture the final state +} + +async function runAgent(sandbox: Sandbox, prompt: string, onProgress?: AgentProgressSink): Promise { + const agentInput = { + prompt, + model: getEnvVariable("STACK_CONFIG_AGENT_MODEL", DEFAULT_AGENT_MODEL), + baseUrl: getEnvVariable("STACK_CLAUDE_PROXY_URL", PRODUCTION_AI_PROXY_BASE_URL), + apiKey: "stack-auth-proxy", + }; + // Write runner.mjs fresh each run (not baked into the base snapshot) so changes + // here take effect immediately instead of being frozen into an old base image. + await sandbox.writeFiles([ + { path: `${TOOLS_DIR}/runner.mjs`, content: Buffer.from(buildRunnerScript(), "utf-8") }, + { path: `${TOOLS_DIR}/agent-input.json`, content: Buffer.from(JSON.stringify(agentInput), "utf-8") }, + ]); + // Run detached so we can poll the runner's progress file while it works; status + // is read from status.json afterwards (the exit code isn't authoritative here). + const command = await sandbox.runCommand({ cmd: "node", args: [`${TOOLS_DIR}/runner.mjs`], detached: true }); + if (onProgress) { + await pollAgentProgress(sandbox, command, onProgress).catch((e) => captureError("config-repo-agent-progress", e)); + } else { + await command.wait().catch(() => {}); + } + const statusBuf = await sandbox.readFileToBuffer({ path: `${TOOLS_DIR}/status.json` }).catch(() => null); + const status = statusBuf ? JSON.parse(statusBuf.toString()) : null; + if (!status?.ok) { + const detail = status?.error != null ? redactTokens(String(status.error)) : status?.error; + captureError("config-repo-agent", new ConfigRepoAgentError("Sandbox agent did not complete", { cause: { error: detail, stage: status?.stage } })); + throw new ConfigRepoAgentError("The config agent could not apply the changes inside the sandbox."); + } +} + +// --------------------------------------------------------------------------- +// Sandbox boot +// --------------------------------------------------------------------------- + +async function gitHead(sandbox: Sandbox): Promise { + return (await run(sandbox, "git", ["-C", REPO_DIR, "rev-parse", "HEAD"])).stdout.trim(); +} + +/** + * True if a unified diff contains a binary-file change. We can't reconstruct binary + * edits from a textual diff (git emits a "Binary files … differ" stub, not content), + * so such a change is rejected at capture time. `--no-renames` keeps it to add/modify/ + * delete, which `parsePatch` maps cleanly when we rebuild the contents on commit. + */ +function diffHasBinaryChange(diff: string): boolean { + return /^Binary files .* differ$/m.test(diff) || diff.includes("GIT binary patch"); +} + +/** + * Boots a config-agent sandbox with the agent SDK available. If a prebuilt base + * snapshot is configured (`STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID`) the SDK is already + * baked in and we warm-boot from it; otherwise we cold-boot a node24 sandbox and + * install the SDK inline (slower — used locally / before the image is built). + * The returned sandbox has NO repo cloned yet (the caller clones fresh). + */ +async function bootAgentSandbox(creds: SandboxCreds): Promise { + const baseSnapshotId = getEnvVariable("STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID", ""); + if (baseSnapshotId) { + const sandbox = await Sandbox.create({ + source: { type: "snapshot", snapshotId: baseSnapshotId }, + resources: { vcpus: 4 }, + timeout: SANDBOX_TIMEOUT_MS, + teamId: creds.teamId, + projectId: creds.projectId, + token: creds.token, + }); + // Snapshot boots can ship an empty CA bundle; rebuild it before any HTTPS git. + await ensureTls(sandbox); + return sandbox; + } + const sandbox = await Sandbox.create({ + resources: { vcpus: 4 }, + timeout: SANDBOX_TIMEOUT_MS, + runtime: "node24", + teamId: creds.teamId, + projectId: creds.projectId, + token: creds.token, + }); + await installAgentSdk(sandbox); + return sandbox; +} + +// --------------------------------------------------------------------------- +// Base snapshot build (one-off, via scripts/config-agent/build-image.ts) +// --------------------------------------------------------------------------- + +/** + * Builds the shared, repo-independent base snapshot: a node24 sandbox with the + * Claude Agent SDK + git bot identity baked in. Reused (read-only) by every config + * update via `STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID`. This is the closest thing Vercel + * Sandbox has to a custom image — it contains NO repo and NO token. Run the build + * script, then set the printed id as the env var. + */ +export async function buildConfigAgentBaseSnapshot(onProgress?: (msg: string) => void): Promise<{ snapshotId: string }> { + const creds = sandboxCreds(); + const step = (m: string) => onProgress?.(m); + step("Starting a sandbox…"); + const sandbox = await Sandbox.create({ + resources: { vcpus: 4 }, + timeout: SANDBOX_TIMEOUT_MS, + runtime: "node24", + teamId: creds.teamId, + projectId: creds.projectId, + token: creds.token, + }); + try { + step("Configuring git…"); + await run(sandbox, "git", ["config", "--global", "user.email", GIT_BOT_EMAIL]); + await run(sandbox, "git", ["config", "--global", "user.name", GIT_BOT_NAME]); + step("Installing the config agent SDK…"); + await installAgentSdk(sandbox); + step("Creating the snapshot…"); + const snap = await sandbox.snapshot(); + step("Snapshot ready."); + return { snapshotId: snap.snapshotId }; + } finally { + // `snapshot()` already stops the sandbox; this is a best-effort safety net. + await sandbox.stop().catch(() => {}); + } +} + +// --------------------------------------------------------------------------- +// Apply update (on save): run the agent, capture the change set, stop the sandbox +// --------------------------------------------------------------------------- + +/** + * The result of `applyConfigUpdate`. On `awaiting_review` the sandbox has ALREADY + * been stopped and the change is captured as a diff + base commit in `change`, so the + * caller only needs to persist it; the commit is rebuilt + pushed later via + * {@link commitConfigUpdate}. + */ +export type ConfigUpdateApplyResult = + | { + mode: "awaiting_review", + /** The agent's unified diff (review render + commit source) and its base commit. */ + change: CapturedChange, + } + | { mode: "no-change" }; + +export async function applyConfigUpdate(options: { + getGithubToken: GithubTokenProvider, + ref: GithubRepoRef, + completeConfig: Record, + onSandboxId?: (sandboxId: string) => Promise, + onStage?: (stage: ConfigAgentInFlightStage) => Promise, + onProgress?: AgentProgressSink, +}): Promise { + const { getGithubToken, ref, completeConfig, onSandboxId, onStage, onProgress } = options; + const creds = sandboxCreds(); + const step = async (msg: string) => { + await reportConfigAgentProgress(onProgress, msg, "config-repo-agent-step-record"); + }; + const githubToken = await getGithubToken(); // fresh token for the clone + + await reportConfigAgentStage(onStage, "initializing_sandbox"); + await step("Initializing the sandbox…"); + const sandbox = await bootAgentSandbox(creds); + try { + // Record the id so a concurrent cancel (a separate invocation) can hard-stop the + // sandbox while the agent is still running. + await onSandboxId?.(sandbox.sandboxId); + + // Fresh shallow clone of just the target branch. The tokenized URL is used + // only for the clone; immediately after, we reset `origin` to a tokenless URL + // so the agent (which has Bash access) cannot read the token from `.git/config` + // or `git remote -v`. We never push from the sandbox, so the token is not needed again. + await reportConfigAgentStage(onStage, "cloning_repo"); + await step(`Cloning ${ref.owner}/${ref.repo}@${ref.branch}…`); + await run(sandbox, "git", ["clone", "--depth", "1", "--single-branch", "--branch", ref.branch, tokenUrl(githubToken, ref), REPO_DIR]); + await run(sandbox, "git", ["-C", REPO_DIR, "remote", "set-url", "origin", tokenlessUrl(ref)]); + + // Agent writes the COMPLETE config to the file — no dependency install, no + // typecheck (the linked repo's CI validates the committed change). + await reportConfigAgentStage(onStage, "agent_making_changes"); + await step("Agent editing config…"); + await runAgent(sandbox, buildCompleteConfigAgentPrompt({ + scope: { mode: "repo" }, + completeConfig, + commandPolicy: "Do NOT install dependencies, run builds, or run a type check. The repository's own CI validates the change after we push, and dependencies are intentionally not installed in this sandbox.", + }), onProgress); + + // Stage everything so new/renamed files are captured too, then check for changes. + await run(sandbox, "git", ["-C", REPO_DIR, "add", "-A"]); + const dirty = (await runRaw(sandbox, "git", ["-C", REPO_DIR, "status", "--porcelain"])).stdout.trim(); + if (dirty === "") { + return { mode: "no-change" }; + } + + // `add -A` does not move HEAD, so this is still the commit we cloned — the base + // the diff is rebuilt against, and our fast-forward conflict check, at commit time. + const baseSha = await gitHead(sandbox); + // The diff drives BOTH the review render and the commit (`--no-renames` keeps it to + // add/modify/delete; `--cached HEAD` includes newly created files). Captured VERBATIM: + // it is the authoritative commit source, so it must never be altered. The GitHub token + // can't appear here anyway — it lives only in `.git/config` (which `git diff` never + // reads) and is reset to a tokenless URL before the agent runs, so tracked content + // never contains it. (Token scrubbing stays on the error/log paths, where the tokenized + // clone URL genuinely can surface.) + const diff = (await runRaw(sandbox, "git", ["-c", "core.quotePath=false", "-C", REPO_DIR, "diff", "--cached", "--no-renames", "HEAD"])).stdout; + if (diff.trim() === "") { + return { mode: "no-change" }; + } + if (diffHasBinaryChange(diff)) { + throw new ConfigRepoAgentError("The config change includes a binary file, which can't be committed from the dashboard."); + } + if (Buffer.byteLength(diff, "utf-8") > MAX_CONFIG_DIFF_BYTES) { + throw new ConfigRepoAgentError("The config change is too large to commit."); + } + return { mode: "awaiting_review", change: { diff, baseSha } }; + } finally { + // The sandbox's whole job is done once the change set is captured; the commit is + // replayed later via the GitHub API, so we never keep it alive for review. + await stopSandboxWithContext(sandbox.sandboxId, "config-repo-agent-apply-stop"); + } +} + +// --------------------------------------------------------------------------- +// Commit (on confirm): rebuild contents from the diff + base, push via the REST API +// --------------------------------------------------------------------------- + +const GITHUB_API_BASE = "https://api.github.com"; +// Git tree mode for the files we write. The config agent only edits regular text +// files; mode changes aren't carried by the textual diff, so everything is 100644. +const TREE_FILE_MODE = "100644"; + +type GithubFetchResult = { ok: boolean, status: number, json: any }; + +/** One reconstructed file ready for the commit tree. Deletions carry no content. */ +export type CommitFile = { path: string, newContent: string } | { path: string, deleted: true }; + +/** Branch ref path segment, encoding each component but keeping `/` literal (refs can be nested). */ +function encodeBranchPath(branch: string): string { + return branch.split("/").map(encodeURIComponent).join("/"); +} + +/** Encode a repo file path for a URL, keeping `/` literal between segments. */ +function encodeFilePath(path: string): string { + return path.split("/").map(encodeURIComponent).join("/"); +} + +/** Authenticated api.github.com request. The token is sent only as a header, never in a URL. */ +async function githubFetch(token: string, method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${GITHUB_API_BASE}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "hexclave-config-agent", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + let json: any = null; + try { + json = await res.json(); + } catch { + json = null; + } + return { ok: res.ok, status: res.status, json }; +} + +/** Like {@link githubFetch} but throws on a non-2xx response. Error messages never include the token. */ +async function githubJson(token: string, method: string, path: string, body?: unknown): Promise { + const r = await githubFetch(token, method, path, body); + if (!r.ok) { + const detail = typeof r.json?.message === "string" ? `: ${r.json.message}` : ""; + throw new ConfigRepoAgentError(`GitHub API ${method} ${path} failed (${r.status})${detail}`); + } + return r.json; +} + +/** Strip git's `a/` / `b/` diff prefix from a filename (and treat `/dev/null` as absent). */ +function stripDiffPrefix(name: string | undefined): string | null { + if (!name || name === "/dev/null") return null; + return name.replace(/^[ab]\//, ""); +} + +/** + * Fetches a file's content at a specific commit from GitHub, as a UTF-8 string. Returns + * "" if the file does not exist at that commit (a freshly added file). Falls back to the + * blob API when the contents API declines to inline a large file. + */ +async function fetchBaseFileContent(token: string, repoPath: string, filePath: string, baseSha: string): Promise { + const r = await githubFetch(token, "GET", `${repoPath}/contents/${encodeFilePath(filePath)}?ref=${baseSha}`); + if (r.status === 404) return ""; + if (!r.ok) { + const detail = typeof r.json?.message === "string" ? `: ${r.json.message}` : ""; + throw new ConfigRepoAgentError(`GitHub API GET ${repoPath}/contents failed (${r.status})${detail}`); + } + if (r.json?.encoding === "base64" && typeof r.json?.content === "string") { + return Buffer.from(r.json.content, "base64").toString("utf-8"); + } + // Large files: the contents API returns no inline content; fetch the blob by sha. + if (typeof r.json?.sha === "string") { + const blob = await githubJson(token, "GET", `${repoPath}/git/blobs/${r.json.sha}`); + if (blob?.encoding === "base64" && typeof blob?.content === "string") { + return Buffer.from(blob.content, "base64").toString("utf-8"); + } + } + throw new ConfigRepoAgentError(`Could not read the base content of ${filePath} from GitHub.`); +} + +/** + * Rebuilds the changed files by applying a unified diff onto the base content of each + * file, which `getBaseContent` resolves by path (new files apply onto ""). Pure aside + * from that resolver, so it is unit-testable. Because the base matches the diff's + * context lines exactly, `applyPatch` is deterministic. Deletions are recorded as such. + * Throws (→ a retryable commit error) if a hunk fails to apply. + */ +export async function rebuildFilesFromDiff(diff: string, getBaseContent: (path: string) => Promise): Promise { + const files: CommitFile[] = []; + for (const patch of parsePatch(diff)) { + const oldPath = stripDiffPrefix(patch.oldFileName); + const newPath = stripDiffPrefix(patch.newFileName); + if (newPath === null) { + // Deletion (newFileName is /dev/null). + if (oldPath !== null) files.push({ path: oldPath, deleted: true }); + continue; + } + // Added file: oldPath is /dev/null → base is empty. Otherwise resolve the base. + const base = oldPath === null ? "" : await getBaseContent(oldPath); + const applied = applyPatch(base, patch); + if (applied === false) { + throw new ConfigRepoAgentError(`Could not rebuild ${newPath} from the stored diff.`); + } + files.push({ path: newPath, newContent: applied }); + } + return files; +} + +/** + * Commits a captured change to the linked branch via GitHub's git data API — no + * sandbox required, so it works no matter how long the review took. The change is + * stored only as a diff + base commit; here we rebuild the file contents by applying + * that diff onto the base, then: verify the branch still points at the base (fast- + * forward guard), create a blob per file, build a tree on the base tree (deletions via + * `sha: null`), create the commit, and fast-forward the branch ref. A concurrent push + * surfaces as {@link ConfigRepoCommitConflictError} (the pre-check mismatch or a 422 + * from the non-forced ref update). The token is fetched fresh — the user may have been + * reviewing for a while. + */ +export async function commitConfigUpdate(options: { + getGithubToken: GithubTokenProvider, + ref: GithubRepoRef, + commitMessage: string, + change: CapturedChange, +}): Promise { + const { ref, commitMessage, change } = options; + const token = await options.getGithubToken(); + const repoPath = `/repos/${ref.owner}/${ref.repo}`; + const encodedBranch = encodeBranchPath(ref.branch); + + // 1. Fast-forward guard: the branch must still be at the commit we cloned. (The + // non-forced ref update in step 6 guards the remaining race window too.) + const refData = await githubJson(token, "GET", `${repoPath}/git/ref/heads/${encodedBranch}`); + if (refData?.object?.sha !== change.baseSha) { + throw new ConfigRepoCommitConflictError(); + } + + // 2. Rebuild every file the agent changed from the diff applied onto the exact base + // files. The diff spans the whole repo (config file + any imports/codegen it pulls + // in), so this reproduces the agent's full change, not just the config file. + const files = await rebuildFilesFromDiff(change.diff, (path) => fetchBaseFileContent(token, repoPath, path, change.baseSha)); + if (files.length === 0) { + throw new ConfigRepoAgentError("The stored diff produced no file changes to commit."); + } + + // 3. Resolve the base tree so we only have to specify changed entries. + const baseCommit = await githubJson(token, "GET", `${repoPath}/git/commits/${change.baseSha}`); + const baseTreeSha = baseCommit?.tree?.sha; + if (typeof baseTreeSha !== "string") { + throw new ConfigRepoAgentError("Could not resolve the base tree for the config commit."); + } + + // 4. Create a blob per non-deleted file and build the tree entries. + const treeEntries: Array<{ path: string, mode: string, type: "blob", sha: string | null }> = []; + for (const file of files) { + if ("deleted" in file) { + treeEntries.push({ path: file.path, mode: TREE_FILE_MODE, type: "blob", sha: null }); + continue; + } + const blob = await githubJson(token, "POST", `${repoPath}/git/blobs`, { + content: Buffer.from(file.newContent, "utf-8").toString("base64"), + encoding: "base64", + }); + if (typeof blob?.sha !== "string") { + throw new ConfigRepoAgentError("GitHub did not return a blob sha for a config file."); + } + treeEntries.push({ path: file.path, mode: TREE_FILE_MODE, type: "blob", sha: blob.sha }); + } + + // 5. Build the new tree on top of the base. + const tree = await githubJson(token, "POST", `${repoPath}/git/trees`, { base_tree: baseTreeSha, tree: treeEntries }); + if (typeof tree?.sha !== "string") { + throw new ConfigRepoAgentError("GitHub did not return a tree sha for the config commit."); + } + + // 6. Create the commit object with the bot identity as author and committer. + const identity = { name: GIT_BOT_NAME, email: GIT_BOT_EMAIL }; + const commitObj = await githubJson(token, "POST", `${repoPath}/git/commits`, { + message: commitMessage, + tree: tree.sha, + parents: [change.baseSha], + author: identity, + committer: identity, + }); + const commitSha = commitObj?.sha; + if (typeof commitSha !== "string") { + throw new ConfigRepoAgentError("GitHub did not return a commit sha."); + } + + // 7. Fast-forward the branch. force:false → GitHub rejects a non-fast-forward + // (someone pushed since our pre-check) with 422, which is a commit conflict. + const update = await githubFetch(token, "PATCH", `${repoPath}/git/refs/heads/${encodedBranch}`, { sha: commitSha, force: false }); + if (!update.ok) { + if (update.status === 422) { + throw new ConfigRepoCommitConflictError(); + } + const detail = typeof update.json?.message === "string" ? `: ${update.json.message}` : ""; + throw new ConfigRepoAgentError(`Failed to update the branch ref (${update.status})${detail}`); + } + + return { + mode: "commit-to-branch", + branch: ref.branch, + commitUrl: `https://github.com/${ref.owner}/${ref.repo}/commit/${commitSha}`, + commitSha, + }; +} + +// --------------------------------------------------------------------------- +// Cancel (hard-stop an in-flight run's sandbox) +// --------------------------------------------------------------------------- + +/** + * Hard-stops an in-flight run's sandbox by id (called from the cancel route, a + * different invocation than the one running the agent). Best-effort: a sandbox + * that already finished/stopped just no-ops. Only `running` runs have a live + * sandbox now — once captured (`awaiting_review`) the sandbox is already gone, so + * cancelling then just flips the row to terminal with nothing to stop. + */ +export async function stopConfigAgentSandbox(sandboxId: string): Promise { + await stopSandboxWithContext(sandboxId, "config-repo-agent-cancel-stop"); +} diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index 47ef81c02..dc523efab 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -1,8 +1,7 @@ import { globalPrismaClient } from "@/prisma-client"; import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring"; -import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; -import { parseHexclaveConfigFileContent } from "@hexclave/shared/dist/hexclave-config-file"; -import { isValidConfig } from "@hexclave/shared/dist/config/format"; +import { detectImportPackageFromDir, evalConfigFileContent, type ParsedConfigValue } from "@hexclave/shared/dist/config-eval"; +import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@hexclave/shared/dist/local-emulator"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { StatusError } from "@hexclave/shared/dist/utils/errors"; @@ -18,7 +17,7 @@ export const LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE = export const LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV = "STACK_LOCAL_EMULATOR_HOST_MOUNT_ROOT"; export const LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE = showOnboardingHexclaveConfigValue; -type LocalEmulatorConfigValue = Record | typeof LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE; +type LocalEmulatorConfigValue = ParsedConfigValue; export function isLocalEmulatorEnabled() { return getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true"; @@ -76,7 +75,10 @@ async function readConfigContent(filePath: string): Promise { async function readConfigValueFromFile(filePath: string): Promise { const content = await readConfigContent(filePath); try { - return parseHexclaveConfigFileContent(content, filePath); + // `evalConfigFileContent` already guarantees the value is a config object or + // the `"show-onboarding"` sentinel (it throws otherwise), so no further + // shape check is needed here. + return evalConfigFileContent(content, filePath); } catch (e) { const message = e instanceof Error ? e.message : String(e); throw new StatusError(StatusError.BadRequest, `Error evaluating config in ${filePath}: ${message}`); diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index c0c4716e7..e890e0185 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -9,9 +9,9 @@ import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction, type PrismaClientTransaction } from '@/prisma-client'; import { runAsynchronouslyAndWaitUntil } from '@/utils/background-tasks'; import { ALL_APPS } from '@hexclave/shared/dist/apps/apps-config'; +import { type Config } from '@hexclave/shared/dist/config/format'; import { DEFAULT_EMAIL_THEME_ID } from '@hexclave/shared/dist/helpers/emails'; import { type AdminUserProjectsCrud, type ProjectsCrud } from '@hexclave/shared/dist/interface/crud/projects'; -import { type Config } from '@hexclave/shared/dist/config/format'; import { DayInterval } from '@hexclave/shared/dist/utils/dates'; import { getEnvVariable } from '@hexclave/shared/dist/utils/env'; import { throwErr } from '@hexclave/shared/dist/utils/errors'; @@ -2174,12 +2174,12 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis branchId: DEFAULT_BRANCH_ID, source: { type: "pushed-from-github", - owner: "stack-auth", - repo: "dummy-config-repo", + owner: "hexclave", + repo: "config-test", branch: "main", commit_hash: "abc123def456789", - config_file_path: "stack.config.json", - workflow_path: ".github/workflows/stack-auth-config-sync.yml", + config_file_path: "hexclave.config.ts", + workflow_path: ".github/workflows/hexclave-config-sync.yml", }, })], globalPrismaClient.project.update({ diff --git a/apps/backend/src/oauth/ssrf-protection.test.ts b/apps/backend/src/oauth/ssrf-protection.test.ts index 68b001db3..47f94cd32 100644 --- a/apps/backend/src/oauth/ssrf-protection.test.ts +++ b/apps/backend/src/oauth/ssrf-protection.test.ts @@ -1,9 +1,9 @@ import { StatusError } from "@hexclave/shared/dist/utils/errors"; -import { describe, expect, it, vi } from "vitest"; import dns from "node:dns"; +import { describe, expect, it, vi } from "vitest"; import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress, safeOAuthDnsLookup } from "./ssrf-protection"; -async function withProductionNodeEnv(callback: () => Promise): Promise { +async function withProductionOAuthSsrfProtection(callback: () => Promise): Promise { vi.stubEnv("NODE_ENV", "production"); try { return await callback(); @@ -12,6 +12,17 @@ async function withProductionNodeEnv(callback: () => Promise): Promise } } +type SingleLookupResult = { + error: NodeJS.ErrnoException | null, + address: string | dns.LookupAddress[], + family?: number, +}; + +type AllLookupResult = { + error: NodeJS.ErrnoException | null, + addresses: string | dns.LookupAddress[], +}; + describe("isBlockedOAuthIpAddress", () => { it("blocks AWS metadata, loopback, and private IPv4 ranges", () => { expect(isBlockedOAuthIpAddress("169.254.169.254")).toBe(true); @@ -62,23 +73,27 @@ describe("assertSafeOAuthResolvedAddress", () => { describe("safeOAuthDnsLookup", () => { it("reports blocked single-address lookup results through the callback", async () => { - const error = await withProductionNodeEnv(async () => await new Promise((resolve) => { - safeOAuthDnsLookup("127.0.0.1", {}, (lookupError) => { - resolve(lookupError); - }); - })); + const result = await withProductionOAuthSsrfProtection(async () => + await new Promise((resolve) => { + safeOAuthDnsLookup("127.0.0.1", { all: false }, (error, address, family) => { + resolve({ error, address, family }); + }); + })); - expect(error).toBeInstanceOf(StatusError); + expect(result.error).toBeInstanceOf(StatusError); + expect(result.address).toBe(""); + expect(result.family).toBe(0); }); it("reports blocked all-address lookup results through the callback", async () => { - const error = await withProductionNodeEnv(async () => await new Promise((resolve) => { - safeOAuthDnsLookup("127.0.0.1", { all: true, verbatim: true } satisfies dns.LookupAllOptions, (lookupError) => { - resolve(lookupError); - }); - })); + const result = await withProductionOAuthSsrfProtection(async () => + await new Promise((resolve) => { + safeOAuthDnsLookup("127.0.0.1", { all: true, verbatim: true } satisfies dns.LookupAllOptions, (error, addresses) => { + resolve({ error, addresses }); + }); + })); - expect(error).toBeInstanceOf(StatusError); + expect(result.error).toBeInstanceOf(StatusError); + expect(result.addresses).toEqual([]); }); }); - diff --git a/apps/backend/src/oauth/ssrf-protection.ts b/apps/backend/src/oauth/ssrf-protection.ts index d9ee4b9d4..e9f2a2021 100644 --- a/apps/backend/src/oauth/ssrf-protection.ts +++ b/apps/backend/src/oauth/ssrf-protection.ts @@ -1,7 +1,7 @@ +import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; import dns from "node:dns"; import net from "node:net"; -import { StatusError } from "@hexclave/shared/dist/utils/errors"; -import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; const OAUTH_SSRF_PROTECTION_ERROR = "OAuth provider URLs must use HTTPS and resolve only to public internet addresses."; @@ -108,9 +108,14 @@ export async function assertSafeOAuthUrl(urlString: string): Promise { } export function assertSafeOAuthResolvedAddress(address: string): void { - if (isBlockedOAuthIpAddress(address)) { - throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR); - } + const error = getUnsafeOAuthResolvedAddressError(address); + if (error != null) throw error; +} + +function getUnsafeOAuthResolvedAddressError(address: string): StatusError | null { + return isBlockedOAuthIpAddress(address) + ? new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR) + : null; } type DnsLookupCallback = ( diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 0db91e513..afd7f571f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.72", + "@pierre/diffs": "^1.2.11", "@assistant-ui/react": "^0.10.24", "@assistant-ui/react-ai-sdk": "^0.10.14", "@assistant-ui/react-markdown": "^0.10.5", diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.test.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.test.ts index 063a51232..219fb7d42 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.test.ts @@ -17,10 +17,10 @@ describe("buildWorkflowYaml", () => { expect(workflowYaml).toContain(` - ${JSON.stringify(branch)}`); expect(workflowYaml).toContain(` - ${JSON.stringify(configPath)}`); expect(workflowYaml).toContain(` - ${JSON.stringify(WORKFLOW_FILE_PATH)}`); - expect(workflowYaml).toContain(` STACK_AUTH_CONFIG_PATH: ${JSON.stringify(configPath)}`); - expect(workflowYaml).toContain(` STACK_AUTH_SOURCE_REPO: \${{ github.repository }}`); - expect(workflowYaml).toContain(` STACK_AUTH_SOURCE_WORKFLOW_PATH: ${JSON.stringify(WORKFLOW_FILE_PATH)}`); - expect(workflowYaml).toContain("run: npx --yes @hexclave/cli@latest config push --config-file \"$STACK_AUTH_CONFIG_PATH\" --source github --source-repo \"$STACK_AUTH_SOURCE_REPO\" --source-path \"$STACK_AUTH_CONFIG_PATH\" --source-workflow-path \"$STACK_AUTH_SOURCE_WORKFLOW_PATH\""); + expect(workflowYaml).toContain(` HEXCLAVE_CONFIG_PATH: ${JSON.stringify(configPath)}`); + expect(workflowYaml).toContain(` HEXCLAVE_SOURCE_REPO: \${{ github.repository }}`); + expect(workflowYaml).toContain(` HEXCLAVE_SOURCE_WORKFLOW_PATH: ${JSON.stringify(WORKFLOW_FILE_PATH)}`); + expect(workflowYaml).toContain("run: npx --yes @hexclave/cli@latest config push --config-file \"$HEXCLAVE_CONFIG_PATH\" --source github --source-repo \"$HEXCLAVE_SOURCE_REPO\" --source-path \"$HEXCLAVE_CONFIG_PATH\" --source-workflow-path \"$HEXCLAVE_SOURCE_WORKFLOW_PATH\""); expect(workflowYaml).not.toContain(`--config-file "${configPath}"`); }); @@ -32,9 +32,9 @@ describe("buildWorkflowYaml", () => { }); it("uses the GitHub Actions runtime repository context for --source-repo", () => { - const workflowYaml = buildWorkflowYaml("main", "stack.config.ts"); - expect(workflowYaml).toContain("STACK_AUTH_SOURCE_REPO: ${{ github.repository }}"); - expect(workflowYaml).not.toMatch(/STACK_AUTH_SOURCE_REPO:\s+"[^$]/); + const workflowYaml = buildWorkflowYaml("main", "hexclave.config.ts"); + expect(workflowYaml).toContain("HEXCLAVE_SOURCE_REPO: ${{ github.repository }}"); + expect(workflowYaml).not.toMatch(/HEXCLAVE_SOURCE_REPO:\s+"[^$]/); }); }); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.ts index e78451d8b..0a1bb931e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.ts @@ -1,7 +1,7 @@ -export const WORKFLOW_FILE_NAME = "stack-auth-config-sync.yml"; +export const WORKFLOW_FILE_NAME = "hexclave-config-sync.yml"; export const WORKFLOW_FILE_PATH = `.github/workflows/${WORKFLOW_FILE_NAME}`; -export const GITHUB_PROJECT_ID_SECRET_NAME = "STACK_AUTH_PROJECT_ID"; -export const GITHUB_SECRET_SERVER_KEY_SECRET_NAME = "STACK_AUTH_SECRET_SERVER_KEY"; +export const GITHUB_PROJECT_ID_SECRET_NAME = "HEXCLAVE_PROJECT_ID"; +export const GITHUB_SECRET_SERVER_KEY_SECRET_NAME = "HEXCLAVE_SECRET_SERVER_KEY"; function encodeYamlScalar(value: string): string { return JSON.stringify(value); @@ -41,7 +41,7 @@ on: - ${encodedWorkflowPath} jobs: - push-stack-config: + push-hexclave-config: runs-on: ubuntu-latest permissions: contents: read @@ -54,11 +54,11 @@ jobs: node-version: "20" - name: Push Hexclave config env: - STACK_PROJECT_ID: \${{ secrets.${GITHUB_PROJECT_ID_SECRET_NAME} }} - STACK_SECRET_SERVER_KEY: \${{ secrets.${GITHUB_SECRET_SERVER_KEY_SECRET_NAME} }} - STACK_AUTH_CONFIG_PATH: ${encodedConfigPath} - STACK_AUTH_SOURCE_REPO: \${{ github.repository }} - STACK_AUTH_SOURCE_WORKFLOW_PATH: ${encodedWorkflowPath} - run: npx --yes @hexclave/cli@latest config push --config-file "$STACK_AUTH_CONFIG_PATH" --source github --source-repo "$STACK_AUTH_SOURCE_REPO" --source-path "$STACK_AUTH_CONFIG_PATH" --source-workflow-path "$STACK_AUTH_SOURCE_WORKFLOW_PATH" + HEXCLAVE_PROJECT_ID: \${{ secrets.${GITHUB_PROJECT_ID_SECRET_NAME} }} + HEXCLAVE_SECRET_SERVER_KEY: \${{ secrets.${GITHUB_SECRET_SERVER_KEY_SECRET_NAME} }} + HEXCLAVE_CONFIG_PATH: ${encodedConfigPath} + HEXCLAVE_SOURCE_REPO: \${{ github.repository }} + HEXCLAVE_SOURCE_WORKFLOW_PATH: ${encodedWorkflowPath} + run: npx --yes @hexclave/cli@latest config push --config-file "$HEXCLAVE_CONFIG_PATH" --source github --source-repo "$HEXCLAVE_SOURCE_REPO" --source-path "$HEXCLAVE_CONFIG_PATH" --source-workflow-path "$HEXCLAVE_SOURCE_WORKFLOW_PATH" `; } diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx index b6be3e3be..7a2f60ce3 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx @@ -143,7 +143,7 @@ function readPersistedLinkExistingState(projectId: string): PersistedLinkExistin selectedGithubAccountId: typeof selectedGithubAccountIdField === "string" ? selectedGithubAccountIdField : null, selectedRepositoryFullName: getObjectString(parsed, "selectedRepositoryFullName") ?? "", selectedBranch: getObjectString(parsed, "selectedBranch") ?? "", - configPathInput: getObjectString(parsed, "configPathInput") ?? "stack.config.ts", + configPathInput: getObjectString(parsed, "configPathInput") ?? "hexclave.config.ts", packageRunner, }; } catch { @@ -483,7 +483,7 @@ export function LinkExistingOnboarding(props: Props) { const [loadingConfigPathSuggestions, setLoadingConfigPathSuggestions] = useState(false); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [commitMessage, setCommitMessage] = useState("chore(stack-auth): add Hexclave config sync workflow"); - const [commitDescription, setCommitDescription] = useState("Add a GitHub Actions workflow that pushes stack.config to Hexclave whenever it changes."); + const [commitDescription, setCommitDescription] = useState("Add a GitHub Actions workflow that pushes hexclave.config to Hexclave whenever it changes."); const [isSettingUpGithubWorkflow, setIsSettingUpGithubWorkflow] = useState(false); const [isCheckingSource, setIsCheckingSource] = useState(false); const [isAwaitingLocalPush, setIsAwaitingLocalPush] = useState(false); @@ -500,7 +500,7 @@ export function LinkExistingOnboarding(props: Props) { const githubLogsAutoPollingKeyRef = useRef(null); const repositoriesLoadedAccountRef = useRef(null); const loadRepositoriesRunIdRef = useRef(0); - const [configPathInput, setConfigPathInput] = useState(persistedState?.configPathInput ?? "stack.config.ts"); + const [configPathInput, setConfigPathInput] = useState(persistedState?.configPathInput ?? "hexclave.config.ts"); const [packageRunner, setPackageRunner] = useState(persistedState?.packageRunner ?? "npx"); const [repoSearchQuery, setRepoSearchQuery] = useState(""); const [repoSearchResults, setRepoSearchResults] = useState([]); @@ -786,6 +786,7 @@ export function LinkExistingOnboarding(props: Props) { cause: error, }); } + appendLog("Config push detected. You can continue."); return; } @@ -1364,7 +1365,7 @@ export function LinkExistingOnboarding(props: Props) { }, [branchSearchQuery, githubFetch, selectedRepository, step]); let title = "Link an existing config"; - let subtitle = "Connect GitHub automation or push your local stack.config file."; + let subtitle = "Connect GitHub automation or push your local hexclave.config file."; let content: React.ReactNode; let primaryAction: React.ReactNode; let secondaryAction: React.ReactNode | undefined; @@ -1405,7 +1406,7 @@ export function LinkExistingOnboarding(props: Props) {
Connect with GitHub - Configure an automated workflow for stack.config pushes. + Configure an automated workflow for hexclave.config pushes.
@@ -1701,7 +1702,7 @@ export function LinkExistingOnboarding(props: Props) { )} @@ -1722,7 +1723,7 @@ export function LinkExistingOnboarding(props: Props) { setConfigPathInputWithPersistence(event.target.value)} - placeholder="stack.config.ts" + placeholder="hexclave.config.ts" /> {configPathSuggestions.length > 0 && (
@@ -1744,7 +1745,7 @@ export function LinkExistingOnboarding(props: Props) {
)} - The path to your stack.config.ts file. If you don't have one yet,{" "} + The path to your hexclave.config.ts file. If you don't have one yet,{" "} + ); +} + +export type UnsavedChangesFooterProps = { + hasChanges: boolean, + adminApp: StackAdminApp, + configUpdate: () => Promise, + pushable: boolean, + onDiscard: () => void, + onSaved?: () => void | Promise, + actionType?: "save" | "create", +}; + +export function UnsavedChangesFooter({ + hasChanges, + adminApp, + configUpdate, + pushable, + onDiscard, + onSaved, + actionType = "save", +}: UnsavedChangesFooterProps) { + if (!hasChanges) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/config-update/progress-content.tsx b/apps/dashboard/src/components/config-update/progress-content.tsx new file mode 100644 index 000000000..d37af3eb9 --- /dev/null +++ b/apps/dashboard/src/components/config-update/progress-content.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { captureError } from "@hexclave/shared/dist/utils/errors"; +import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; +import { ArrowsClockwise } from "@phosphor-icons/react"; +import React, { useEffect, useState } from "react"; +import type { FileDiffProps } from "@pierre/diffs/react"; +import type { FileDiffMetadata } from "@pierre/diffs"; + +import { useThemeWatcher } from "@/lib/theme"; +import { currentEpochMsFromPerformance, type AgentStage } from "./shared"; + +type StepDef = { key: AgentStage, label: string }; + +const STAGE_STEPS: StepDef[] = [ + { key: "initializing_sandbox", label: "Initializing agent" }, + { key: "cloning_repo", label: "Cloning repo" }, + { key: "agent_making_changes", label: "Generating changes" }, + { key: "awaiting_review", label: "Ready to review" }, +]; + +function stageIndex(stage: AgentStage | null | undefined): number { + if (stage == null) return -1; + return STAGE_STEPS.findIndex((s) => s.key === stage); +} + +/** + * Live "seconds since the run started" counter. The run's `startedAt` is a + * wall-clock epoch value, with `0` as the "not started yet" sentinel — until a + * real start time arrives the counter stays at 0 (otherwise it would briefly + * read ~epoch-since-1970). For a real `startedAt` we capture the start→now offset + * against a fresh monotonic anchor and advance on that monotonic clock. Both are + * recomputed whenever `startedAt` changes (e.g. when a flow renders the box + * before its real start timestamp is known), so the counter resets instead of + * freezing a stale offset. Recomputing the offset every render — which the old + * code did — re-added the elapsed time on top of the monotonic delta and made + * the timer tick at ~2× speed. + */ +function elapsedOffsetMs(startedAt: number): number { + return startedAt > 0 ? Math.max(0, currentEpochMsFromPerformance() - startedAt) : 0; +} + +function useElapsedSeconds(startedAt: number): number { + const [elapsedMs, setElapsedMs] = useState(() => elapsedOffsetMs(startedAt)); + useEffect(() => { + const offsetMs = elapsedOffsetMs(startedAt); + setElapsedMs(offsetMs); + if (startedAt <= 0) return; + const anchorPerfMs = performance.now(); + const t = setInterval(() => setElapsedMs(offsetMs + (performance.now() - anchorPerfMs)), 1000); + return () => clearInterval(t); + }, [startedAt]); + return Math.max(0, Math.floor(elapsedMs / 1000)); +} + +/** + * Generic centered "spinner + title + elapsed" box. Shared by every config-apply + * flow that has a non-interactive "working…" state (the GitHub agent preview and + * the CLI/RDE local apply), so they look and tick identically. + */ +export function ConfigApplyProgressBox({ + title, + detail, + activity, + startedAt, +}: { + title: string, + /** Optional sub-line shown before the elapsed counter, e.g. "2/4: Cloning repo". */ + detail?: string | null, + /** Optional live activity log; only the last non-empty line is shown. */ + activity?: string | null, + /** Unix ms timestamp of when the run started. */ + startedAt: number, +}) { + const elapsed = useElapsedSeconds(startedAt); + const lastActivityLine = activity?.split("\n").filter((l) => l.trim()).at(-1); + + return ( +
+ +
{title}
+
+ {detail != null && detail.length > 0 ? `${detail} — ` : ""}{elapsed}s +
+ {lastActivityLine != null && lastActivityLine.trim().length > 0 && ( +
+ + {lastActivityLine} +
+ )} +
+ ); +} + +/** + * Loader rendered inside the GitHub "Commit preview" box while the agent runs. + * Once the run reaches `awaiting_review` the box swaps this out for the diff, so + * the surrounding layout stays identical between the running and review states. + */ +export function ConfigAgentPreviewProgress({ + stage, + startedAt, + activity, +}: { + stage: AgentStage | null | undefined, + /** Unix ms timestamp of when the run started (from the run's started_at). */ + startedAt: number, + activity?: string | null, +}) { + const idx = stageIndex(stage); + const stepNumber = idx < 0 ? 1 : idx + 1; + const stepLabel = (idx < 0 ? STAGE_STEPS[0] : STAGE_STEPS[idx]).label; + + return ( + + ); +} + +/** + * Lazy-loaded diff viewer. We parse the sandbox's full `git diff` into file + * diffs, then render each file with Pierre's React renderer. `PatchDiff` only + * accepts a single-file patch, while the config agent may legitimately edit + * helpers/imported config files too. + */ +export function AgentDiffViewer({ diff }: { diff: string }) { + // Pierre renders into a shadow-DOM `diffs-container`; it picks light vs dark + // tokens from the host's `color-scheme`, which it derives from `themeType`. + // The dashboard toggles theme via a `.dark` class (not OS preference), so we + // feed it the resolved theme explicitly — otherwise the diff would follow the + // OS and mismatch the rest of the page. The chrome colors (surface, additions, + // deletions, gutter) are remapped to the project tokens via the + // `config-agent-diff` class in globals.css. + const { theme } = useThemeWatcher(); + const [renderer, setRenderer] = useState<{ + FileDiff: React.ComponentType>, + files: FileDiffMetadata[], + } | null>(null); + + useEffect(() => { + const cancelToken = { cancelled: false }; + runAsynchronously(async () => { + try { + const [{ parsePatchFiles }, reactMod] = await Promise.all([ + import("@pierre/diffs"), + import("@pierre/diffs/react"), + ]); + if (cancelToken.cancelled) return; + const files = parsePatchFiles(diff, "config-agent-review", true).flatMap((patch) => patch.files); + if (files.length === 0) return; + setRenderer({ FileDiff: reactMod.FileDiff, files }); + } catch (error) { + if (cancelToken.cancelled) return; + // Renderer failed to load/parse — fall back to raw diff text, but report it. + captureError("config-agent-diff-viewer-render", error); + } + }); + return () => { + cancelToken.cancelled = true; + }; + }, [diff]); + + if (renderer != null) { + const { FileDiff } = renderer; + return ( +
+ {renderer.files.map((fileDiff, index) => ( + + ))} +
+ ); + } + + return ( +
+      {diff}
+    
+ ); +} diff --git a/apps/dashboard/src/components/config-update/rde-apply-dialog.tsx b/apps/dashboard/src/components/config-update/rde-apply-dialog.tsx new file mode 100644 index 000000000..e974193ff --- /dev/null +++ b/apps/dashboard/src/components/config-update/rde-apply-dialog.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { DesignAlert, DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components"; +import type { StackAdminApp } from "@hexclave/next"; +import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; +import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; +import { ArrowsClockwise, Terminal } from "@phosphor-icons/react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import { ConfigApplyProgressBox } from "./progress-content"; +import { currentEpochMsFromPerformance } from "./shared"; + +type RdeApplyDialogProps = { + open: boolean, + adminApp: StackAdminApp | null, + configUpdate: EnvironmentConfigOverrideOverride | null, + onSettle: (result: boolean) => void, +}; + +// CLI / remote-development-environment apply. Unlike the GitHub flow there's no +// review step — the change is written straight to the local config file the CLI +// manages and we wait for it to sync. We reuse the same running/cancelling +// presentation so dashboard config applies feel identical across backends. +// "running": apply in flight (non-dismissible; Cancel aborts the request). +// "cancelling": user clicked Cancel, request is aborting. +// "error": apply failed; resting state with a retry. +type RdePhase = "running" | "cancelling" | "error"; + +/** + * Drives a CLI/RDE local config apply with the shared progress UI. Auto-starts + * on open and settles `true` on success, `false` on cancel/redirect. + */ +export function RdeApplyDialog({ open, adminApp, configUpdate, onSettle }: RdeApplyDialogProps) { + const [phase, setPhase] = useState("running"); + const [startedAt, setStartedAt] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + + const abortRef = useRef(null); + const autoStartedRef = useRef(false); + + const isNonDismissible = phase === "running" || phase === "cancelling"; + + const startApply = useCallback(() => { + if (adminApp == null || configUpdate == null) { + setErrorMessage("No configuration changes to apply."); + setPhase("error"); + return; + } + // Lazily import so this client chunk doesn't pull the RDE client into the + // common config-update bundle. + const controller = new AbortController(); + abortRef.current = controller; + setErrorMessage(null); + setStartedAt(currentEpochMsFromPerformance()); + setPhase("running"); + + runAsynchronously(async () => { + try { + const { updateRemoteDevelopmentEnvironmentConfigFile } = await import("./remote-development-environment"); + const result = await updateRemoteDevelopmentEnvironmentConfigFile(adminApp, configUpdate, { signal: controller.signal }); + if (controller.signal.aborted) { + onSettle(false); + return; + } + // "redirecting": the browser secret flow took over; treat as not-applied. + onSettle(result === "updated"); + } catch (error) { + if (controller.signal.aborted) { + onSettle(false); + return; + } + captureError("config-update-rde-apply", error); + setErrorMessage(error instanceof Error ? error.message : "Failed to apply the change to your local development environment."); + setPhase("error"); + } + }); + }, [adminApp, configUpdate, onSettle]); + + // Auto-start once on open; a failed apply lands in "error" and is retried + // explicitly via the Retry button (which calls startApply again). + useEffect(() => { + if (!open || autoStartedRef.current) return; + autoStartedRef.current = true; + startApply(); + }, [open, startApply]); + + const handleCancel = useCallback(() => { + setPhase("cancelling"); + abortRef.current?.abort(); + // The in-flight apply observes the aborted signal and settles `false`. + }, []); + + const description = (() => { + switch (phase) { + case "running": + case "cancelling": { + return "Applying your change to the local configuration file managed by the Hexclave CLI."; + } + case "error": { + return "The change could not be applied to your local development environment."; + } + } + })(); + + const footer = (() => { + if (phase === "error") { + return ( +
+ + { onSettle(false); }}> + Close + + + { startApply(); }}> + + Retry + +
+ ); + } + // running / cancelling + return ( + { handleCancel(); }} + > + {phase === "cancelling" ? "Cancelling…" : "Cancel"} + + ); + })(); + + return ( + { + if (o || isNonDismissible) return; + onSettle(false); + }} + size="lg" + icon={Terminal} + title="Apply configuration" + description={description} + hideTopCloseButton={isNonDismissible} + footer={footer} + contentProps={{ onPointerDownOutside: isNonDismissible ? (e) => e.preventDefault() : undefined, onEscapeKeyDown: isNonDismissible ? (e) => e.preventDefault() : undefined }} + > +
+
+ {phase === "cancelling" ? ( +

Cancelling the update…

+ ) : phase === "error" ? ( +

No changes were applied. You can retry the update.

+ ) : ( + + )} +
+ + {errorMessage != null && ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/config-update/remote-development-environment.ts b/apps/dashboard/src/components/config-update/remote-development-environment.ts new file mode 100644 index 000000000..6ede51f0a --- /dev/null +++ b/apps/dashboard/src/components/config-update/remote-development-environment.ts @@ -0,0 +1,37 @@ +import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret, RemoteDevelopmentEnvironmentBrowserSecretRedirectingError } from "@/app/remote-development-environment-browser-secret-client"; +import type { StackAdminApp } from "@hexclave/next"; +import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; + +export async function updateRemoteDevelopmentEnvironmentConfigFile( + adminApp: StackAdminApp, + configUpdate: EnvironmentConfigOverrideOverride, + options?: { signal?: AbortSignal }, +): Promise<"updated" | "redirecting"> { + // Combine the hard timeout with the caller's cancel signal (the apply dialog's + // Cancel button) so either can abort the in-flight sync. + const signals = [AbortSignal.timeout(130_000), options?.signal].filter((s): s is AbortSignal => s != null); + try { + const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/remote-development-environment/config/apply-update", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + project_id: adminApp.projectId, + config_update: configUpdate, + wait_for_sync: true, + }), + signal: AbortSignal.any(signals), + }); + if (!response.ok) { + throw new Error(`Failed to update local development environment config (${response.status}): ${await response.text()}`); + } + return "updated"; + } catch (error) { + if (error instanceof RemoteDevelopmentEnvironmentBrowserSecretRedirectingError) { + return "redirecting"; + } + throw error; + } +} diff --git a/apps/dashboard/src/components/config-update/shared.tsx b/apps/dashboard/src/components/config-update/shared.tsx new file mode 100644 index 000000000..8cb40a29d --- /dev/null +++ b/apps/dashboard/src/components/config-update/shared.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { PushedConfigSource, StackAdminApp } from "@hexclave/next"; +import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; +import type { HexclaveAdminInterface } from "@hexclave/shared/dist/interface/admin-interface"; +import type { ConfigAgentRunApi } from "@hexclave/shared/dist/schema-fields"; +import { createContext } from "react"; + +/** Live state of a dashboard→GitHub config agent run (mirrors the API schema). */ +export type ConfigAgentRun = ConfigAgentRunApi; +export type ConfigAgentRunStatus = ConfigAgentRun["status"]; +export type AgentStage = NonNullable; + +export type GithubPushedSource = Extract; + +export function currentEpochMsFromPerformance(): number { + return performance.timeOrigin + performance.now(); +} + +/** + * Reaches the admin app's underlying `HexclaveAdminInterface`, which carries the + * config-agent endpoints (`applyConfigViaAgent`, `cancelConfigAgentRun`, + * `getConfigAgentRun`, `getPushedConfigSource`) we call directly — rather than via + * generated app methods — to keep this feature self-contained. `_interface` is a + * protected member, so we read it reflectively (the same pattern the SDK's own + * cross-domain tests use). Returns `null` if the app doesn't expose one. + */ +export function getAdminInterface(adminApp: StackAdminApp | null | undefined): HexclaveAdminInterface | null { + if (adminApp == null) return null; + // `Reflect.get` returns `any`; the typed annotation documents the contract + // without an explicit cast (and without an `instanceof`, which is unreliable + // across package-boundary copies of the class). + const iface: HexclaveAdminInterface | undefined = Reflect.get(adminApp, "_interface"); + return iface ?? null; +} + +export const ConfigUpdateDialogContext = createContext<{ + showPushableDialog: (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride) => Promise, + showRdeApplyDialog: (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride) => Promise, +} | null>(null); diff --git a/apps/dashboard/src/components/data-table/payment-product-table.tsx b/apps/dashboard/src/components/data-table/payment-product-table.tsx index c4b5b2d28..1bd92836d 100644 --- a/apps/dashboard/src/components/data-table/payment-product-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-product-table.tsx @@ -2,7 +2,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { ProductDialog } from "@/components/payments/product-dialog"; import { ActionCell, ActionDialog, toast } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { branchPaymentsSchema } from "@hexclave/shared/dist/config/schema"; import { typedEntries, typedFromEntries } from "@hexclave/shared/dist/utils/objects"; import { diff --git a/apps/dashboard/src/components/email-verification-setting.tsx b/apps/dashboard/src/components/email-verification-setting.tsx index b97e26ede..2ac072659 100644 --- a/apps/dashboard/src/components/email-verification-setting.tsx +++ b/apps/dashboard/src/components/email-verification-setting.tsx @@ -3,7 +3,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { SettingSwitch } from "@/components/settings"; import { ActionDialog, Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { EnvelopeSimpleIcon } from "@phosphor-icons/react"; import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; diff --git a/apps/dashboard/src/components/payments/product-dialog.tsx b/apps/dashboard/src/components/payments/product-dialog.tsx index 32aab7f8a..29692acc2 100644 --- a/apps/dashboard/src/components/payments/product-dialog.tsx +++ b/apps/dashboard/src/components/payments/product-dialog.tsx @@ -6,7 +6,7 @@ import { CheckboxField, InputField, SelectField } from "@/components/form-fields import { IncludedItemEditorField } from "@/components/payments/included-item-editor"; import { PriceEditorField } from "@/components/payments/price-editor"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, SimpleTooltip, toast } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { AdminProject } from "@hexclave/next"; import { pricesSchema, productSchema, userSpecifiedIdSchema, yupRecord } from "@hexclave/shared/dist/schema-fields"; import { has } from "@hexclave/shared/dist/utils/objects"; diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx deleted file mode 100644 index 76d7cff6f..000000000 --- a/apps/dashboard/src/lib/config-update.tsx +++ /dev/null @@ -1,806 +0,0 @@ -'use client'; - -import { Link } from "@/components/link"; -import { ActionDialog } from "@/components/ui/action-dialog"; -import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret, RemoteDevelopmentEnvironmentBrowserSecretRedirectingError } from "@/app/remote-development-environment-browser-secret-client"; -import { useDashboardInternalUser } from "@/lib/dashboard-user"; -import { getPublicEnvVar } from "@/lib/env"; -import type { OAuthConnection, PushedConfigSource, StackAdminApp } from "@hexclave/next"; -import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; -import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors"; -import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; -import React, { createContext, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; - -import { createGithubFetch, GITHUB_SCOPE_REQUIREMENTS } from "./github-api"; -import { pushConfigUpdateToGitHub } from "./github-config-push"; - -type GithubPushedSource = Extract; - -type ConfigUpdateDialogState = { - isOpen: boolean, - adminApp: StackAdminApp | null, - configUpdate: EnvironmentConfigOverrideOverride | null, - resolve: ((result: boolean) => void) | null, - source: PushedConfigSource | null, - isLoadingSource: boolean, -}; - -const ConfigUpdateDialogContext = createContext<{ - showPushableDialog: (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride) => Promise, -} | null>(null); - -/** - * Provider component that enables the config update dialog functionality. - * Wrap your app or page with this provider to use the `updateConfig` utility. - */ -export function ConfigUpdateDialogProvider({ children }: { children: React.ReactNode }) { - const [dialogState, setDialogState] = useState({ - isOpen: false, - adminApp: null, - configUpdate: null, - resolve: null, - source: null, - isLoadingSource: false, - }); - - const showPushableDialog = useCallback(async (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride): Promise => { - // Fetch the source first - const project = await adminApp.getProject(); - const source = await project.getPushedConfigSource(); - - let shouldUpdate = true; - if (source.type !== "unlinked") { - shouldUpdate = await new Promise((resolve) => { - setDialogState({ - isOpen: true, - adminApp, - configUpdate, - resolve, - source, - isLoadingSource: false, - }); - }); - } - - if (shouldUpdate) { - await project.updatePushedConfig(configUpdate); - if (!project.isDevelopmentEnvironment) { - await project.resetConfigOverrideKeys("environment", Object.keys(configUpdate)); - } - return true; - } - return false; - }, []); - - const settleDialog = useCallback((result: boolean) => { - // Pull `resolve` out before the state update so we never invoke it from - // inside a setState updater — React strict mode double-invokes updaters, - // which would call `resolve` twice. Promise resolution is idempotent so - // this was harmless in practice, but the pattern is wrong. - const resolve = dialogState.resolve; - setDialogState({ - isOpen: false, - adminApp: null, - configUpdate: null, - resolve: null, - source: null, - isLoadingSource: false, - }); - resolve?.(result); - }, [dialogState.resolve]); - - const projectId = dialogState.adminApp?.projectId; - - // Render the appropriate dialog based on source type - const renderDialog = () => { - if (!dialogState.isOpen || !dialogState.source) { - return null; - } - - switch (dialogState.source.type) { - case "pushed-from-github": { - return ( - - ); - } - - case "pushed-from-unknown": { - return ( - settleDialog(false)} - title="Configuration Managed by CLI" - description="This project's configuration was pushed via the Hexclave CLI." - okButton={{ - label: "Go to Project Settings", - onClick: async () => { - // Navigate to project settings - window.location.href = `/projects/${projectId}/project-settings`; - }, - }} - cancelButton={{ - label: "Cancel", - onClick: async () => { - settleDialog(false); - }, - }} - > -
-

- To make changes, you can either: -

-
    -
  • Push updates through the Hexclave CLI
  • -
  • Unlink the CLI in Project Settings to edit directly on this dashboard
  • -
-
-
- ); - } - - default: { - // This shouldn't happen since unlinked saves directly, but handle it anyway - return null; - } - } - }; - - return ( - - {children} - {renderDialog()} - - ); -} - -function useConfigUpdateDialog() { - const context = useContext(ConfigUpdateDialogContext); - if (!context) { - throw new Error("useConfigUpdateDialog must be used within a ConfigUpdateDialogProvider"); - } - return context; -} - -type GithubPushDialogProps = { - open: boolean, - source: GithubPushedSource, - configUpdate: EnvironmentConfigOverrideOverride | null, - projectId: string | undefined, - onSettle: (result: boolean) => void, -}; - -/** - * Renders the "Push to GitHub" dialog. Detects whether the dashboard user has - * a GitHub account connected; if not, walks them through linking one first. - * Once a connection is available, commits a config-file edit to the linked - * repo/branch via the Contents API. - * - * On success, `onSettle(true)` is called so the surrounding - * `ConfigUpdateDialogProvider` then mirrors the change into Hexclave's - * cloud config for immediate UI feedback. Eventually the GitHub Actions - * workflow will re-push the canonical config from the freshly-committed file. - */ -type ScopeCheck = - | { status: "no-account" } - | { status: "checking" } - | { status: "ok", account: OAuthConnection } - | { status: "missing-scopes" }; - -type GithubPushHandlers = { - push: () => Promise<"prevent-close" | undefined>, - connect: () => Promise<"prevent-close" | undefined>, -}; - -function projectSettingsHref(projectId: string | undefined): string { - return `/projects/${projectId}/project-settings`; -} - -/** - * Outer shell. Renders `ActionDialog` synchronously (no suspending hooks) so - * opening the dialog doesn't bubble a Suspense promise up to the dashboard - * root and blank the page. The suspending pieces (current user, connected - * accounts, OAuth token probe) live in `GithubPushBody`, wrapped in a local - * `Suspense` boundary whose fallback mirrors the dialog body except that the - * "Push to GitHub" button stays disabled while we resolve. - */ -function GithubPushDialog({ open, source, configUpdate, projectId, onSettle }: GithubPushDialogProps) { - // Status starts as "checking" so the initial render shows a disabled - // "Push to GitHub" button — matching what we want during Suspense fallback. - const [scopeStatus, setScopeStatus] = useState("checking"); - const handlersRef = useRef(null); - - const dispatch = useCallback( - (key: keyof GithubPushHandlers) => async (): Promise<"prevent-close" | undefined> => { - // While the Suspense fallback is showing, handlers aren't registered - // yet. In that window the button is disabled anyway, but we guard - // defensively and prevent close if somehow clicked. - return (await handlersRef.current?.[key]()) ?? "prevent-close"; - }, - [], - ); - - const okButton = (() => { - switch (scopeStatus) { - case "no-account": { - return { label: "Connect with GitHub", onClick: dispatch("connect") }; - } - case "checking": { - return { - label: "Push to GitHub", - onClick: async (): Promise<"prevent-close" | undefined> => "prevent-close", - props: { disabled: true }, - }; - } - case "ok": { - return { label: "Push to GitHub", onClick: dispatch("push") }; - } - case "missing-scopes": { - return { label: "Reconnect with GitHub", onClick: dispatch("connect") }; - } - } - })(); - - const description = (() => { - switch (scopeStatus) { - case "no-account": { - return "Connect a GitHub account to push configuration changes to this repository."; - } - case "checking": { - return "Checking GitHub permissions..."; - } - case "ok": { - return `This will commit your change to ${source.owner}/${source.repo}@${source.branch}.`; - } - case "missing-scopes": { - return "Your linked GitHub account is missing the \"repo\" and \"workflow\" permissions required to push configuration changes. Reconnect to grant them."; - } - } - })(); - - return ( - onSettle(false)} - title="Push Configuration to GitHub" - description={description} - okButton={okButton} - cancelButton={{ - label: "Cancel", - onClick: async () => { - onSettle(false); - }, - }} - > - }> - - - - ); -} - -function GithubPushBodyFallback({ projectId }: { projectId: string | undefined }) { - // Static body shown during the initial Suspense — no commit input yet - // (we don't know whether push is even available), just the unlink hint - // so the dialog "looks normal except the button is disabled". - return ( -
-

- - If your configuration is no longer on GitHub, you can unlink it in{" "} - - Project Settings - . - -

-
- ); -} - -type GithubPushBodyProps = { - source: GithubPushedSource, - configUpdate: EnvironmentConfigOverrideOverride | null, - projectId: string | undefined, - onSettle: (result: boolean) => void, - onScopeStatusChange: (status: ScopeCheck["status"]) => void, - handlersRef: React.MutableRefObject, -}; - -function GithubPushBody({ - source, - configUpdate, - projectId, - onSettle, - onScopeStatusChange, - handlersRef, -}: GithubPushBodyProps) { - const user = useDashboardInternalUser(); - const githubAccounts = user.useConnectedAccounts().filter((account) => account.provider === "github"); - - // Stable dep for the scope-check effect — re-run only when the set of - // connections actually changes, not on every parent render. - const githubAccountsKey = githubAccounts.map((a) => a.providerAccountId).join("|"); - - const [scopeCheck, setScopeCheck] = useState( - githubAccounts.length === 0 ? { status: "no-account" } : { status: "checking" }, - ); - const [commitMessage, setCommitMessage] = useState(""); - const [errorMessage, setErrorMessage] = useState(null); - - const placeholderCommitMessage = "Update Hexclave configuration"; - - // Sync our local status string up to the dialog shell so it can pick the - // right button label / description without itself needing to suspend. - // `useLayoutEffect` (not `useEffect`) so the shell's "checking" placeholder - // never reaches the screen for users whose initial state is actually - // "no-account" — the sync runs before the browser paints the first frame - // after the Suspense fallback resolves. - useLayoutEffect(() => { - onScopeStatusChange(scopeCheck.status); - }, [scopeCheck.status, onScopeStatusChange]); - - // Probe each connected GitHub account for a token that already covers - // `repo` + `workflow`. The dashboard user may have multiple GitHub - // connections; only one needs to carry the elevated scopes. We pre-flight - // here (rather than on Push click) so the user doesn't waste a typed commit - // message on a redirect, since `linkConnectedAccount` is a full page nav. - useEffect(() => { - if (githubAccounts.length === 0) { - setScopeCheck({ status: "no-account" }); - return; - } - // Mutable holder rather than a `let` so TS sees the reassignment in the - // cleanup callback as a real write; otherwise its flow analysis narrows - // the closure read to its initial value and the `cancelled` checks below - // are flagged as constant-condition errors. - const cancelToken = { cancelled: false }; - setScopeCheck({ status: "checking" }); - runAsynchronously(async () => { - for (const account of githubAccounts) { - let tokenResult; - try { - tokenResult = await account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); - } catch { - // Transport/cache failures — fall through and try the next account. - continue; - } - if (cancelToken.cancelled) return; - if (tokenResult.status === "ok") { - setScopeCheck({ status: "ok", account }); - return; - } - } - if (!cancelToken.cancelled) setScopeCheck({ status: "missing-scopes" }); - }); - return () => { - cancelToken.cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- githubAccountsKey is the stable identity for githubAccounts - }, [githubAccountsKey]); - - const githubFetch = useMemo( - () => (scopeCheck.status === "ok" ? createGithubFetch(scopeCheck.account) : null), - [scopeCheck], - ); - - const handlePush = useCallback(async (): Promise<"prevent-close" | undefined> => { - if (configUpdate == null) { - setErrorMessage("No configuration changes to push."); - return "prevent-close"; - } - if (githubFetch == null) { - setErrorMessage("Connect a GitHub account with the required scopes before pushing changes."); - return "prevent-close"; - } - setErrorMessage(null); - try { - await pushConfigUpdateToGitHub({ - source, - configUpdate, - commitMessage: commitMessage.trim().length > 0 ? commitMessage : placeholderCommitMessage, - githubFetch, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error pushing to GitHub."; - captureError("config-update-github-push", { - projectId, - owner: source.owner, - repo: source.repo, - branch: source.branch, - configFilePath: source.configFilePath, - cause: error, - }); - setErrorMessage(message); - return "prevent-close"; - } - onSettle(true); - return undefined; - }, [commitMessage, configUpdate, githubFetch, onSettle, projectId, source]); - - const handleConnect = useCallback(async (): Promise<"prevent-close" | undefined> => { - // Full-page redirect to the OAuth provider. When scopes are missing on - // an existing connection, `getOrLinkConnectedAccount` still redirects - // because none of the present tokens satisfies the scope set. Returning - // `prevent-close` is defensive — in practice the redirect happens first. - try { - await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error connecting to GitHub."; - setErrorMessage(message); - return "prevent-close"; - } - return "prevent-close"; - }, [user]); - - // Expose the latest handlers to the dialog shell. A ref (rather than - // calling up via state) avoids re-rendering the shell on every handler - // identity change, which would also reset the okButton onClick reference. - useEffect(() => { - handlersRef.current = { push: handlePush, connect: handleConnect }; - }, [handlersRef, handlePush, handleConnect]); - - return ( -
- {scopeCheck.status === "ok" && ( -
- - setCommitMessage(e.target.value)} - /> -

- Committing to {source.configFilePath} on{" "} - {source.branch}. -

-
- )} - {errorMessage != null && ( -

- {errorMessage} -

- )} -

- - If your configuration is no longer on GitHub, you can unlink it in{" "} - - Project Settings - . - -

-
- ); -} - -async function updateRemoteDevelopmentEnvironmentConfigFile( - adminApp: StackAdminApp, - configUpdate: EnvironmentConfigOverrideOverride, -): Promise<"updated" | "redirecting"> { - try { - const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/remote-development-environment/config/apply-update", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - project_id: adminApp.projectId, - config_update: configUpdate, - wait_for_sync: true, - }), - signal: AbortSignal.timeout(130_000), - }); - if (!response.ok) { - throw new Error(`Failed to update local development environment config (${response.status}): ${await response.text()}`); - } - return "updated"; - } catch (error) { - if (error instanceof RemoteDevelopmentEnvironmentBrowserSecretRedirectingError) { - return "redirecting"; - } - throw error; - } -} - -/** - * Options for the updateConfig utility function. - */ -export type UpdateConfigOptions = { - /** - * The admin app instance to use for updating the config. - */ - adminApp: StackAdminApp, - /** - * The configuration update to apply. - */ - configUpdate: EnvironmentConfigOverrideOverride, - /** - * Whether this configuration can be pushed (i.e., it's a branch-level config). - * If true, shows a confirmation dialog before applying (based on source type). - * If false, the update is applied directly to the environment config. - */ - pushable: boolean, -}; - -/** - * Hook that returns a function to update config with optional confirmation dialog. - * - * For pushable configs, the behavior depends on the branch config source: - * - `unlinked`: Saves directly without a dialog - * - `pushed-from-github`: Shows a dialog to push changes to GitHub - * - `pushed-from-unknown`: Shows a dialog explaining CLI management - * - * For non-pushable configs, updates the environment config directly. - * - * @example - * ```tsx - * const updateConfig = useUpdateConfig(); - * - * // Update environment config (no dialog) - * await updateConfig({ - * adminApp, - * configUpdate: { 'auth.oauth.providers.google.clientSecret': 'secret' }, - * pushable: false, - * }); - * - * // Update pushed config (dialog depends on source) - * await updateConfig({ - * adminApp, - * configUpdate: { 'teams.allowClientTeamCreation': true }, - * pushable: true, - * }); - * ``` - */ -export function useUpdateConfig() { - const { showPushableDialog } = useConfigUpdateDialog(); - - return useCallback(async (options: UpdateConfigOptions): Promise => { - const { adminApp, configUpdate, pushable } = options; - - if (getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true") { - if (!pushable) { - throw new HexclaveAssertionError("These settings are read-only in a development environment. Update them in your production deployment instead."); - } - - if (await updateRemoteDevelopmentEnvironmentConfigFile(adminApp, configUpdate) === "redirecting") { - return false; - } - return true; - } - - if (pushable) { - // Show dialog (or save directly if unlinked) based on source type - return await showPushableDialog(adminApp, configUpdate); - } else { - // Update environment config directly - const project = await adminApp.getProject(); - if (project.isDevelopmentEnvironment) { - alert("These settings are read-only in a development environment. Update them in your production deployment instead."); - return false; - } - // eslint-disable-next-line no-restricted-syntax -- this is the hook implementation itself - await project.updateConfig(configUpdate); - return true; - } - }, [showPushableDialog]); -} - -/** - * Props for the ConfigUpdateButton component. - */ -export type ConfigUpdateButtonProps = { - /** - * The admin app instance to use for updating the config. - */ - adminApp: StackAdminApp, - /** - * An async function that returns the configuration update to apply. - * Called when the button is clicked. - */ - configUpdate: () => Promise, - /** - * Whether this configuration can be pushed (i.e., it's a branch-level config). - * If true, shows a confirmation dialog before applying. - * If false, the update is applied directly to the environment config. - */ - pushable: boolean, - /** - * Optional callback called after the config is successfully updated. - */ - onUpdated?: () => void | Promise, - /** - * The type of action this button represents. - * - "save": Shows "Save changes" (for updating existing config) - * - "create": Shows "Create" (for creating new config entries) - */ - actionType: "save" | "create", - /** - * Whether the button should be disabled. - */ - disabled?: boolean, - /** - * Additional class names for the button. - */ - className?: string, - /** - * Button variant. - */ - variant?: "default" | "secondary" | "outline" | "ghost" | "destructive" | "link", - /** - * Button size. - */ - size?: "default" | "sm" | "lg" | "icon", -}; - -/** - * A button component for saving configuration changes. - * - * Shows "Save changes" or "Create" based on the `actionType` prop and handles - * the configuration update flow, including the confirmation dialog for pushable configs. - * - * @example - * ```tsx - * ({ - * 'teams.allowClientTeamCreation': true, - * })} - * pushable={true} - * onUpdated={() => toast({ title: "Settings saved" })} - * actionType="save" - * /> - * ``` - */ -export function ConfigUpdateButton({ - adminApp, - configUpdate, - pushable, - onUpdated, - actionType, - disabled, - className, - variant = "default", - size = "default", -}: ConfigUpdateButtonProps) { - const updateConfig = useUpdateConfig(); - - const handleClick = async () => { - const configUpdateValue = await configUpdate(); - const success = await updateConfig({ - adminApp, - configUpdate: configUpdateValue, - pushable, - }); - if (success) { - await onUpdated?.(); - } - }; - - const label = actionType === "save" ? "Save changes" : "Create"; - - // Import Button locally to avoid circular dependency issues - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Button } = require("@/components/ui") as typeof import("@/components/ui"); - - return ( - - ); -} - -/** - * Props for components that use the unsaved changes pattern. - */ -export type UnsavedChangesFooterProps = { - /** - * Whether there are unsaved changes. - */ - hasChanges: boolean, - /** - * The admin app instance. - */ - adminApp: StackAdminApp, - /** - * An async function that returns the configuration update to apply. - */ - configUpdate: () => Promise, - /** - * Whether this configuration can be pushed. - */ - pushable: boolean, - /** - * Callback to discard changes (reset to original values). - */ - onDiscard: () => void, - /** - * Optional callback called after the config is successfully updated. - */ - onSaved?: () => void | Promise, - /** - * The action type. - */ - actionType?: "save" | "create", -}; - -/** - * A footer component that shows Save/Discard buttons when there are unsaved changes. - * - * Use this at the bottom of a card or section to provide a consistent pattern - * for saving configuration changes. - * - * @example - * ```tsx - * const [localValue, setLocalValue] = useState(config.someValue); - * const hasChanges = localValue !== config.someValue; - * - * ({ 'some.config.key': localValue })} - * pushable={true} - * onDiscard={() => setLocalValue(config.someValue)} - * onSaved={() => toast({ title: "Settings saved" })} - * /> - * ``` - */ -export function UnsavedChangesFooter({ - hasChanges, - adminApp, - configUpdate, - pushable, - onDiscard, - onSaved, - actionType = "save", -}: UnsavedChangesFooterProps) { - // Import Button locally to avoid circular dependency issues - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Button } = require("@/components/ui") as typeof import("@/components/ui"); - - if (!hasChanges) { - return null; - } - - return ( -
- - -
- ); -} diff --git a/apps/dashboard/src/lib/github-config-push.test.ts b/apps/dashboard/src/lib/github-config-push.test.ts deleted file mode 100644 index f6529cacf..000000000 --- a/apps/dashboard/src/lib/github-config-push.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isObject } from "./github-api"; -import { buildUpdatedConfigFileContent, pushConfigUpdateToGitHub } from "./github-config-push"; - -function getStringField(value: Record, key: string): string { - const field = value[key]; - if (typeof field !== "string") { - throw new Error(`Expected request body field ${key} to be a string.`); - } - return field; -} - -function snapshotGithubCall(call: { path: string, init?: RequestInit }) { - if (call.init == null) { - return { path: call.path }; - } - const body = call.init.body; - if (body == null) { - return { - path: call.path, - init: call.init, - }; - } - if (typeof body !== "string") { - throw new Error("Expected request body to be a JSON string."); - } - const parsedBody: unknown = JSON.parse(body); - if (!isObject(parsedBody)) { - throw new Error("Expected request body to parse as an object."); - } - const content = getStringField(parsedBody, "content"); - return { - path: call.path, - method: call.init.method, - headers: call.init.headers, - body: { - ...parsedBody, - content: Buffer.from(content, "base64").toString("utf-8"), - }, - }; -} - -describe("buildUpdatedConfigFileContent", () => { - it("merges a flat dot-notation update into the existing config", () => { - const current = `import type { HexclaveConfig } from "@hexclave/next"; - -export const config: HexclaveConfig = { - teams: { allowClientTeamCreation: false }, -}; -`; - const result = buildUpdatedConfigFileContent(current, { "teams.allowClientTeamCreation": true }); - expect(result).toMatchInlineSnapshot(` - "import type { HexclaveConfig } from "@hexclave/next/config"; - - export const config: HexclaveConfig = { - "teams": { - "allowClientTeamCreation": true - } - }; - " - `); - }); - - it("preserves the existing @hexclave/* import package when re-rendering", () => { - const current = `import type { HexclaveConfig } from "@hexclave/react"; - -export const config: HexclaveConfig = {}; -`; - const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }); - expect(result).toMatchInlineSnapshot(` - "import type { HexclaveConfig } from "@hexclave/react/config"; - - export const config: HexclaveConfig = { - "auth": { - "allowSignUp": true - } - }; - " - `); - }); - - it("preserves a legacy @stackframe/* import package when re-rendering", () => { - // Projects pinned to the last @stackframe/* release (before the Hexclave - // rebrand) still have config files importing from the legacy scope. The - // dashboard must not silently rewrite their imports — keep what's there. - const current = `import type { StackConfig } from "@stackframe/react"; - -export const config: StackConfig = {}; -`; - const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }); - expect(result).toMatchInlineSnapshot(` - "import type { HexclaveConfig } from "@stackframe/react"; - - export const config: HexclaveConfig = { - "auth": { - "allowSignUp": true - } - }; - " - `); - }); - - it("defaults to @hexclave/js when no recognizable import is present", () => { - const current = `export const config = {};\n`; - const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }); - expect(result).toMatchInlineSnapshot(` - "import type { HexclaveConfig } from "@hexclave/js/config"; - - export const config: HexclaveConfig = { - "auth": { - "allowSignUp": true - } - }; - " - `); - }); - - it("adds new top-level keys to an empty config", () => { - const current = `import type { HexclaveConfig } from "@hexclave/js"; -export const config: HexclaveConfig = {}; -`; - const result = buildUpdatedConfigFileContent(current, { - "payments.items.todos.displayName": "Todos", - "payments.items.todos.customerType": "user", - }); - expect(result).toMatchInlineSnapshot(` - "import type { HexclaveConfig } from "@hexclave/js/config"; - - export const config: HexclaveConfig = { - "payments": { - "items": { - "todos": { - "displayName": "Todos", - "customerType": "user" - } - } - } - }; - " - `); - }); - - it("replaces an existing nested value via dot notation", () => { - const current = `import type { HexclaveConfig } from "@hexclave/js"; -export const config: HexclaveConfig = { - payments: { items: { todos: { displayName: "Old" } } }, -}; -`; - const result = buildUpdatedConfigFileContent(current, { - "payments.items.todos.displayName": "New", - }); - expect(result).toMatchInlineSnapshot(` - "import type { HexclaveConfig } from "@hexclave/js/config"; - - export const config: HexclaveConfig = { - "payments": { - "items": { - "todos": { - "displayName": "New" - } - } - } - }; - " - `); - }); - - it("refuses to mutate a show-onboarding placeholder file", () => { - const current = `export const config = "show-onboarding";`; - expect(() => buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true })) - .toThrowErrorMatchingInlineSnapshot(`[Error: The config file currently exports the onboarding placeholder. Finish setting up Hexclave in your repo before pushing dashboard changes.]`); - }); - - it("throws when the file does not export a `config` binding", () => { - expect(() => buildUpdatedConfigFileContent(`export const other = {};`, { "a": 1 })) - .toThrowErrorMatchingInlineSnapshot(`[Error: Invalid config in stack.config.ts. The file must export a plain \`config\` object or "show-onboarding".]`); - }); -}); - -describe("pushConfigUpdateToGitHub", () => { - function buildFakeFetch(initialContent: string) { - const base64 = Buffer.from(initialContent, "utf-8").toString("base64"); - const calls: { path: string, init?: RequestInit }[] = []; - const fn = async (path: string, init?: RequestInit) => { - calls.push({ path, init }); - if (init?.method === "PUT") { - return { commit: { sha: "newsha" } }; - } - return { - type: "file", - encoding: "base64", - content: base64, - sha: "oldsha", - }; - }; - return { fn, calls }; - } - - const baseSource = { - type: "pushed-from-github" as const, - owner: "myorg", - repo: "my-repo", - branch: "main", - commitHash: "abc", - configFilePath: "stack.config.ts", - }; - - it("fetches the existing file, merges the update, and PUTs the new content", async () => { - const { fn, calls } = buildFakeFetch(`import type { HexclaveConfig } from "@hexclave/js"; -export const config: HexclaveConfig = { teams: { allowClientTeamCreation: false } }; -`); - await pushConfigUpdateToGitHub({ - source: baseSource, - configUpdate: { "teams.allowClientTeamCreation": true }, - commitMessage: "feat: enable team creation", - githubFetch: fn, - }); - expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(` - [ - { - "init": { - "cache": "no-store", - }, - "path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main", - }, - { - "body": { - "branch": "main", - "content": "import type { HexclaveConfig } from "@hexclave/js/config"; - - export const config: HexclaveConfig = { - "teams": { - "allowClientTeamCreation": true - } - }; - ", - "message": "feat: enable team creation", - "sha": "oldsha", - }, - "headers": { - "content-type": "application/json", - }, - "method": "PUT", - "path": "/repos/myorg/my-repo/contents/stack.config.ts", - }, - ] - `); - }); - - it("falls back to a default commit message when none is provided", async () => { - const { fn, calls } = buildFakeFetch(`export const config = {};\n`); - await pushConfigUpdateToGitHub({ - source: baseSource, - configUpdate: { "auth.allowSignUp": true }, - commitMessage: " ", - githubFetch: fn, - }); - expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(` - [ - { - "init": { - "cache": "no-store", - }, - "path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main", - }, - { - "body": { - "branch": "main", - "content": "import type { HexclaveConfig } from "@hexclave/js/config"; - - export const config: HexclaveConfig = { - "auth": { - "allowSignUp": true - } - }; - ", - "message": "chore(stack-auth): update config from dashboard", - "sha": "oldsha", - }, - "headers": { - "content-type": "application/json", - }, - "method": "PUT", - "path": "/repos/myorg/my-repo/contents/stack.config.ts", - }, - ] - `); - }); - - it("skips the commit when the new rendered file is identical to the old one", async () => { - const same = `import type { HexclaveConfig } from "@hexclave/js/config"; - -export const config: HexclaveConfig = { - "teams": { - "allowClientTeamCreation": true - } -}; -`; - const { fn, calls } = buildFakeFetch(same); - await pushConfigUpdateToGitHub({ - source: baseSource, - configUpdate: { "teams.allowClientTeamCreation": true }, - commitMessage: "no-op", - githubFetch: fn, - }); - expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(` - [ - { - "init": { - "cache": "no-store", - }, - "path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main", - }, - ] - `); - }); - - it("surfaces a clear error when the config file is missing on the branch", async () => { - const fn = async () => { - throw new Error("Not Found"); - }; - await expect( - pushConfigUpdateToGitHub({ - source: baseSource, - configUpdate: { "auth.allowSignUp": true }, - commitMessage: "x", - githubFetch: fn, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find stack.config.ts on myorg/my-repo@main. Check that the config file still exists in the linked branch.]`); - }); - - it("propagates non-404 GitHub errors", async () => { - const fn = async () => { - throw new Error("Bad credentials"); - }; - await expect( - pushConfigUpdateToGitHub({ - source: baseSource, - configUpdate: { "auth.allowSignUp": true }, - commitMessage: "x", - githubFetch: fn, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Bad credentials]`); - }); -}); diff --git a/apps/dashboard/src/lib/github-config-push.ts b/apps/dashboard/src/lib/github-config-push.ts deleted file mode 100644 index 5c1fdee10..000000000 --- a/apps/dashboard/src/lib/github-config-push.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Pure logic for taking a config update produced by the dashboard, merging it - * into the user's GitHub-stored `stack.config.ts` file, and committing the - * result back to GitHub via the Contents API. - * - * `buildUpdatedConfigFileContent` is the pure heart of this module — it's - * directly unit-testable, takes the current file content and a config update, - * and returns the new file content. The orchestrator `pushConfigUpdateToGitHub` - * wires it up to GitHub's REST API. - */ - -import type { PushedConfigSource } from "@hexclave/next"; -import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; -import { isValidConfig, override } from "@hexclave/shared/dist/config/format"; -import { parseHexclaveConfigFileContent, renderConfigFileContent, showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/hexclave-config-file"; - -import { - commitFile, - getFileContent, - type GithubFetch, -} from "./github-api"; - -/** - * Detects the `@hexclave/*` or legacy `@stackframe/*` import package used by - * the existing config file so the re-rendered file keeps the same import - * line. Falls back to `@hexclave/js` when the file is empty or the import - * cannot be detected. - */ -function detectImportPackage(currentFileContent: string): string | undefined { - // Match `from "@hexclave/"` or `from "@stackframe/"` — single - // or double quotes, with an optional `/config` subpath suffix (the lightweight - // entrypoint newer config files import from). We return the bare package name; - // the renderer re-appends `/config` for Hexclave packages. Hexclave preferred - // when both appear. - const hexclave = currentFileContent.match(/from\s+["']@hexclave\/([a-z0-9-]+)(?:\/config)?["']/i); - if (hexclave) return `@hexclave/${hexclave[1]}`; - const stackframe = currentFileContent.match(/from\s+["']@stackframe\/([a-z0-9-]+)(?:\/config)?["']/i); - return stackframe ? `@stackframe/${stackframe[1]}` : undefined; -} - -/** - * Pure: given the existing contents of a `stack.config.ts` file and a config - * update (the same dot-notation override shape that flows through - * `updatePushedConfig`), returns the new file contents. - * - * The existing import line is preserved when the source file imports - * `StackConfig` from a known `@hexclave/*` or legacy `@stackframe/*` package; - * otherwise the renderer uses its own default. - */ -export function buildUpdatedConfigFileContent( - currentFileContent: string, - configUpdate: EnvironmentConfigOverrideOverride, -): string { - const parsed = parseHexclaveConfigFileContent(currentFileContent, "stack.config.ts"); - if (parsed === showOnboardingHexclaveConfigValue) { - throw new Error( - "The config file currently exports the onboarding placeholder. Finish setting up Hexclave in your repo before pushing dashboard changes." - ); - } - if (!isValidConfig(parsed)) { - throw new Error("Existing GitHub config file does not parse as a valid Hexclave config object."); - } - const merged = override(parsed, configUpdate); - const importPackage = detectImportPackage(currentFileContent); - return renderConfigFileContent(merged, importPackage); -} - -export type PushConfigUpdateOptions = { - source: Extract, - configUpdate: EnvironmentConfigOverrideOverride, - commitMessage: string, - githubFetch: GithubFetch, -}; - -/** - * Pushes a config update to GitHub by editing the user's `stack.config.ts` - * file in place via the Contents API. The accompanying GitHub Actions workflow - * (added in onboarding) will pick up the commit and re-push the canonical - * config back to Hexclave. - * - * Commits the updated config file when needed; returns once GitHub accepts the - * write. - */ -export async function pushConfigUpdateToGitHub(options: PushConfigUpdateOptions): Promise { - const { source, configUpdate, commitMessage, githubFetch } = options; - const { owner, repo, branch, configFilePath } = source; - - const existing = await getFileContent(githubFetch, { owner, repo, branch, path: configFilePath }); - if (existing == null) { - throw new Error( - `Could not find ${configFilePath} on ${owner}/${repo}@${branch}. Check that the config file still exists in the linked branch.` - ); - } - - const newContent = buildUpdatedConfigFileContent(existing.text, configUpdate); - if (newContent === existing.text) { - // Nothing changed in the rendered file — no need to commit. The dashboard - // will still update the cloud-side override for immediate feedback. - return; - } - - const trimmedMessage = commitMessage.trim().length > 0 - ? commitMessage.trim() - : "chore(stack-auth): update config from dashboard"; - - await commitFile(githubFetch, { - owner, - repo, - branch, - path: configFilePath, - content: newContent, - message: trimmedMessage, - sha: existing.sha, - }); -} 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 7683eec0a..ecf1b3a58 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 @@ -215,6 +215,18 @@ describe("remote development environment config file", () => { ); }); + it("throws a helpful error when the config file is syntactically invalid", async () => { + const configPath = writeTempConfig(` + export const config = { + `); + + const { readConfigFile } = await import("./config-file"); + + await expect(readConfigFile(configPath)).rejects.toThrow( + `Failed to load config file ${configPath}.` + ); + }); + it("rejects modules without a valid config export", async () => { const configPath = writeTempConfig(` export const config = () => ({ auth: { allowSignUp: true } }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index 66a29dfa3..2da0a0ee7 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -421,7 +421,7 @@ describe("oauth config", () => { expect(invalidTypeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 400, - "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch", + "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch, custom_oidc", "headers": Headers {