diff --git a/AGENTS.md b/AGENTS.md index 46c548faf5..dbfa1d2e2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Package manager is **pnpm** (`pnpm@10.33.0`), Node **22+**. Use `pnpm`, not `npm - `dev` is the integration branch. Always create and work from a feature branch off `dev` (never commit directly to `dev` or `main`). - Use descriptive branch names such as `feat/`, `fix/`, or `chore/`. - Merge changes into `dev` only via pull requests after required CI checks pass (not into `main`). - - Push branches to `origin` (the `numnx/codeux` fork) and target it for PRs. `upstream` is `codeux-ai/codeux`. + - Open pull requests against `codeux-ai/codeux` with base `dev`. - Use GitHub CLI (`gh`) for PR workflow when available (for example `gh pr create --base dev`, `gh pr view`, `gh pr merge`). - PRs should include: - What changed and why. @@ -165,7 +165,7 @@ Release note rules: - Default working flow for our collaboration: - Start every change on a new feature branch off `dev`. - Implement and validate locally (`pnpm run build` minimum; `pnpm run ci` preferred). - - Open a PR into `dev` against `origin` (the `numnx/codeux` fork) using GitHub CLI. + - Open a PR into `codeux-ai/codeux:dev` using GitHub CLI. - Monitor CI continuously after opening the PR. - Merge only through PR after all required CI checks pass without errors. - Delete merged feature branches to keep the branch list clean. diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 3dcd0aa9a2..19943814b4 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -60,6 +60,7 @@ import type { GuardrailJobType, GuardrailJobConfig, GuardrailOnLimitAction, + PreviewEnvironmentVariable, SprintPreviewSettings, SprintPreviewPortMapping, SprintPreviewSession, @@ -247,6 +248,7 @@ export type { GuardrailJobType, GuardrailJobConfig, GuardrailOnLimitAction, + PreviewEnvironmentVariable, SprintPreviewSettings, SprintPreviewPortMapping, SprintPreviewSession, diff --git a/dashboard/src/v2/BrowserPage.tsx b/dashboard/src/v2/BrowserPage.tsx index 7bf5647c6b..8ce453c866 100644 --- a/dashboard/src/v2/BrowserPage.tsx +++ b/dashboard/src/v2/BrowserPage.tsx @@ -6,8 +6,10 @@ import { RefreshCw, RotateCcw, Save, + SlidersHorizontal, Square, FileCode2, + X, } from "lucide-preact"; import { useProjectData } from "./context/project-data.js"; import { useSprints } from "../hooks/useSprints.js"; @@ -17,6 +19,7 @@ import { fetchPreviewScript, removePreviewSession, rebuildPreviewSession, + savePreviewEnvironmentOverrides, savePreviewScript, startPreviewSession, stopPreviewSession, @@ -32,9 +35,11 @@ import { } from "./lib/preview-origin.js"; import { usePreviewSessions } from "./hooks/use-preview-sessions.js"; import { useProjectEffectiveSettings } from "./hooks/use-project-effective-settings.js"; +import { saveProjectPreviewEnvironmentVariables } from "./lib/settings-api.js"; import { PreviewSessionSlider } from "./components/browser/PreviewSessionSlider.js"; import { PreviewWindowChrome } from "./components/browser/PreviewWindowChrome.js"; import { LaunchContainerPanel } from "./components/browser/LaunchContainerPanel.js"; +import { PreviewEnvironmentEditor } from "./components/browser/PreviewEnvironmentEditor.js"; import { useActionFeedback } from "./hooks/use-action-feedback.js"; import { ActionFeedbackRegion } from "./components/ui/ActionFeedbackRegion.js"; import { PageContainer } from "./components/layout/PageContainer.js"; @@ -43,6 +48,7 @@ import { getSafeUrl } from "./lib/safe-url.js"; const PREVIEW_MESSAGE_TYPE = "sprint-preview:state"; const PREVIEW_NAVIGATION_TYPE = "sprint-preview:navigate"; +const EMPTY_PREVIEW_ENVIRONMENT: SprintPreviewSession["environmentOverrides"] = []; const getSessionPortPathKey = (sessionId: string, containerPort: number): string => `${sessionId}:${containerPort}`; @@ -51,7 +57,7 @@ export const BrowserPage: FunctionComponent = () => { const currentPathRef = useRef("/"); const { selectedProject } = useProjectData(); const { data: sprints, selectedSprint, selectedSprintId } = useSprints(selectedProject?.id || null); - const { data: effectiveSettings } = useProjectEffectiveSettings(selectedProject?.id || null); + const { data: effectiveSettings, refresh: refreshEffectiveSettings } = useProjectEffectiveSettings(selectedProject?.id || null); const [script, setScript] = useState(null); const [scriptDraft, setScriptDraft] = useState(""); @@ -64,12 +70,17 @@ export const BrowserPage: FunctionComponent = () => { const [launching, setLaunching] = useState(false); const [pendingSessionAction, setPendingSessionAction] = useState<"rebuild" | "stop" | null>(null); const [savingScript, setSavingScript] = useState(false); + const [savingEnvironment, setSavingEnvironment] = useState(false); + const [savingDefaultEnvironment, setSavingDefaultEnvironment] = useState(false); const [navigationPending, setNavigationPending] = useState(false); const [removingSessionIds, setRemovingSessionIds] = useState([]); const [error, setError] = useState(null); const [addressValue, setAddressValue] = useState("/"); const [currentPath, setCurrentPath] = useState("/"); const [showScriptEditor, setShowScriptEditor] = useState(false); + const [environmentDraft, setEnvironmentDraft] = useState([]); + const [defaultEnvironmentDraft, setDefaultEnvironmentDraft] = useState([]); + const [environmentModalSessionId, setEnvironmentModalSessionId] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); const [launchSprintId, setLaunchSprintId] = useState(""); const [frameSrc, setFrameSrc] = useState(""); @@ -84,6 +95,8 @@ export const BrowserPage: FunctionComponent = () => { const launchingRef = useRef(false); const pendingSessionActionRef = useRef<"rebuild" | "stop" | null>(null); const savingScriptRef = useRef(false); + const savingEnvironmentRef = useRef(false); + const savingDefaultEnvironmentRef = useRef(false); const removingSessionIdsRef = useRef>(new Set()); const logsCacheRef = useRef>(new Map()); const logsRef = useRef(""); @@ -119,6 +132,7 @@ export const BrowserPage: FunctionComponent = () => { const previewEnabled = effectiveSettings?.settings.sprintPreview.enabled ?? true; const showInAppBrowser = effectiveSettings?.settings.sprintPreview.showInAppBrowser ?? true; const launchEnabled = previewEnabled && showInAppBrowser; + const defaultEnvironmentVariables = effectiveSettings?.settings.sprintPreview.environmentVariables ?? EMPTY_PREVIEW_ENVIRONMENT; const visibleSelectedSession = selectedSession && !removingSessionIdSet.has(selectedSession.id) ? selectedSession : null; @@ -143,6 +157,9 @@ export const BrowserPage: FunctionComponent = () => { const sessionCards = sessions.filter((session) => (!selectedProject || session.projectId === selectedProject.id) && !removingSessionIdSet.has(session.id) ); + const environmentModalSession = environmentModalSessionId + ? sessionCards.find((session) => session.id === environmentModalSessionId) ?? null + : null; const navigationDisabledReason = navigationPending ? "Preview navigation is sending the previous command. Wait for the control to become available before submitting another navigation command." : !visibleSelectedSession @@ -203,6 +220,7 @@ export const BrowserPage: FunctionComponent = () => { useEffect(() => { if (visibleSelectedSession) { + setEnvironmentDraft(visibleSelectedSession.environmentOverrides ?? []); const nextPrimary = getPrimaryPreviewPortMapping(visibleSelectedSession); if (nextPrimary) { setSelectedPortBySessionId((current) => ( @@ -212,6 +230,19 @@ export const BrowserPage: FunctionComponent = () => { } }, [visibleSelectedSession?.id]); + useEffect(() => { + setDefaultEnvironmentDraft(defaultEnvironmentVariables); + }, [defaultEnvironmentVariables, selectedProject?.id]); + + useEffect(() => { + if (!environmentModalSessionId) { + return; + } + if (!environmentModalSession) { + setEnvironmentModalSessionId(null); + } + }, [environmentModalSession?.id, environmentModalSessionId]); + useEffect(() => { if (visibleSelectedSession && selectedPortMapping) { setActiveSessionId(visibleSelectedSession.id); @@ -627,6 +658,60 @@ export const BrowserPage: FunctionComponent = () => { } }; + const handleOpenEnvironmentOverrides = (sessionId: string) => { + const nextSession = sessionCards.find((session) => session.id === sessionId); + if (!nextSession) { + return; + } + setActiveSessionId(sessionId); + setEnvironmentDraft(nextSession.environmentOverrides ?? []); + setEnvironmentModalSessionId(sessionId); + }; + + const handleSaveEnvironmentOverrides = async () => { + const targetSession = environmentModalSession ?? visibleSelectedSession; + if (!targetSession) return; + if (savingEnvironmentRef.current) return; + savingEnvironmentRef.current = true; + setSavingEnvironment(true); + browserFeedback.setPending("Saving preview environment overrides..."); + try { + const updated = await savePreviewEnvironmentOverrides( + targetSession.projectId, + targetSession.sprintId, + targetSession.id, + environmentDraft, + ); + setEnvironmentDraft(updated.environmentOverrides ?? []); + await refreshSessions(true); + browserFeedback.setSuccess("Preview environment saved. Rebuild the container to apply changes."); + } catch (actionError) { + browserFeedback.setError(`Failed to save preview environment: ${actionError instanceof Error ? actionError.message : String(actionError)}`); + } finally { + savingEnvironmentRef.current = false; + setSavingEnvironment(false); + } + }; + + const handleSaveDefaultEnvironmentVariables = async () => { + if (!selectedProject) return; + if (savingDefaultEnvironmentRef.current) return; + savingDefaultEnvironmentRef.current = true; + setSavingDefaultEnvironment(true); + browserFeedback.setPending("Saving preview environment defaults..."); + try { + const updated = await saveProjectPreviewEnvironmentVariables(selectedProject.id, defaultEnvironmentDraft); + setDefaultEnvironmentDraft(updated.settings.sprintPreview.environmentVariables ?? []); + await refreshEffectiveSettings(); + browserFeedback.setSuccess("Preview environment defaults saved. Rebuild containers to apply changes."); + } catch (actionError) { + browserFeedback.setError(`Failed to save preview environment defaults: ${actionError instanceof Error ? actionError.message : String(actionError)}`); + } finally { + savingDefaultEnvironmentRef.current = false; + setSavingDefaultEnvironment(false); + } + }; + const navigate = () => { if (!navigationEnabled || navigationPendingRef.current) { return; @@ -736,6 +821,7 @@ export const BrowserPage: FunctionComponent = () => { } }} onRemoveSession={(sessionId) => void handleRemove(sessionId)} + onManageEnvironment={handleOpenEnvironmentOverrides} removingSessionIds={removingSessionIds} /> @@ -788,6 +874,8 @@ export const BrowserPage: FunctionComponent = () => { {pendingSessionAction === "rebuild" ? " Rebuilding preview container." : ""} {pendingSessionAction === "stop" ? " Stopping preview container." : ""} {savingScript ? " Saving preview script." : ""} + {savingEnvironment ? " Saving preview environment overrides." : ""} + {savingDefaultEnvironment ? " Saving preview environment defaults." : ""} {launching ? " Launching preview container." : ""} {navigationPending ? " Preview navigation command is being sent." : ""} {!navigationEnabled && navigationDisabledReason ? ` ${navigationDisabledReason}` : ""} @@ -910,6 +998,51 @@ export const BrowserPage: FunctionComponent = () => { +
+
+
+
Environment
+
+ {defaultEnvironmentDraft.length} default{defaultEnvironmentDraft.length === 1 ? "" : "s"} for all preview containers +
+
+
+
+ These project-wide variables are injected into every preview container after its next rebuild. Use each container card's Env button for overrides. +
+
+ +
+
+ {savingDefaultEnvironment + ? "Saving preview environment defaults." + : selectedProject + ? "Save defaults, then rebuild containers to apply them." + : "Select a project before editing preview defaults."} +
+ +
+
+
+
Runtime notes
@@ -1011,6 +1144,73 @@ export const BrowserPage: FunctionComponent = () => {
)} + {environmentModalSession && ( +
+
+
+
+
Container overrides
+

+ {environmentModalSession.sprintName} +

+
+ +
+

+ Overrides apply only to this preview container after its next rebuild. Disabled overrides suppress matching defaults. +

+
+ +
+
+
+ {savingEnvironment ? "Saving environment overrides." : "Save overrides, then rebuild this container to apply them."} +
+
+ + +
+
+
+
+ )} ); }; diff --git a/dashboard/src/v2/components/browser/PreviewEnvironmentEditor.tsx b/dashboard/src/v2/components/browser/PreviewEnvironmentEditor.tsx new file mode 100644 index 0000000000..3bff331d2c --- /dev/null +++ b/dashboard/src/v2/components/browser/PreviewEnvironmentEditor.tsx @@ -0,0 +1,104 @@ +import type { FunctionComponent } from "preact"; +import { Plus, Trash2 } from "lucide-preact"; +import type { PreviewEnvironmentVariable } from "../../../types.js"; + +const isSecretKey = (key: string): boolean => /(TOKEN|KEY|SECRET|PASSWORD|AUTH|CREDENTIAL)/i.test(key); + +const emptyVariable = (): PreviewEnvironmentVariable => ({ key: "", value: "", enabled: true }); + +export const PreviewEnvironmentEditor: FunctionComponent<{ + variables: PreviewEnvironmentVariable[]; + onChange: (variables: PreviewEnvironmentVariable[]) => void; + disabled?: boolean; + inheritedVariables?: PreviewEnvironmentVariable[]; + addLabel?: string; + valueLabel?: string; +}> = ({ + variables, + onChange, + disabled = false, + inheritedVariables = [], + addLabel = "Add variable", + valueLabel = "Environment variable value", +}) => { + const rows = variables.length > 0 ? variables : []; + const updateRow = (index: number, patch: Partial): void => { + onChange(rows.map((row, rowIndex) => rowIndex === index ? { ...row, ...patch } : row)); + }; + + return ( +
+ {inheritedVariables.length > 0 ? ( +
+
Inherited defaults
+
+ {inheritedVariables.filter((variable) => variable.enabled !== false).map((variable) => ( + + {variable.key}={isSecretKey(variable.key) ? "••••" : variable.value || "\"\""} + + ))} +
+
+ ) : null} + +
+ {rows.map((variable, index) => { + const valueInputType = isSecretKey(variable.key) ? "password" : "text"; + return ( +
+ +
+ updateRow(index, { key: (event.currentTarget as HTMLInputElement).value })} + className="h-10 min-w-0 rounded-xl border border-black/[0.08] bg-white/80 px-3 font-mono text-xs text-slate-800 outline-none transition focus:border-signal-500/50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/[0.08] dark:bg-void-950 dark:text-slate-100" + /> + updateRow(index, { value: (event.currentTarget as HTMLInputElement).value })} + className="h-10 min-w-0 rounded-xl border border-black/[0.08] bg-white/80 px-3 font-mono text-xs text-slate-800 outline-none transition focus:border-signal-500/50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/[0.08] dark:bg-void-950 dark:text-slate-100" + /> + +
+
+ ); + })} +
+ + +
+ ); +}; diff --git a/dashboard/src/v2/components/browser/PreviewSessionSlider.tsx b/dashboard/src/v2/components/browser/PreviewSessionSlider.tsx index 7bb52a8580..df28587426 100644 --- a/dashboard/src/v2/components/browser/PreviewSessionSlider.tsx +++ b/dashboard/src/v2/components/browser/PreviewSessionSlider.tsx @@ -1,6 +1,6 @@ import type { FunctionComponent } from "preact"; import { useRef } from "preact/hooks"; -import { ChevronLeft, ChevronRight, ExternalLink, Globe, Trash2, Loader2, CheckCircle2 } from "lucide-preact"; +import { ChevronLeft, ChevronRight, ExternalLink, Globe, Trash2, Loader2, CheckCircle2, SlidersHorizontal } from "lucide-preact"; import type { SprintPreviewSession } from "../../../types.js"; import { buildPreviewOrigin, formatPreviewPortMappingsSummary, getPrimaryPreviewPortMapping } from "../../lib/preview-origin.js"; import { getSafeUrl } from "../../lib/safe-url.js"; @@ -11,6 +11,7 @@ interface PreviewSessionSliderProps { selectedSessionId: string | null; onSelectSession: (id: string) => void; onRemoveSession: (sessionId: string) => void; + onManageEnvironment: (sessionId: string) => void; removingSessionIds?: string[]; } @@ -55,6 +56,7 @@ export const PreviewSessionSlider: FunctionComponent selectedSessionId, onSelectSession, onRemoveSession, + onManageEnvironment, removingSessionIds = [], }) => { const scrollContainerRef = useRef(null); @@ -214,31 +216,46 @@ export const PreviewSessionSlider: FunctionComponent
- +
+ + +
+ + update({ + sprintPreview: { + ...settings.sprintPreview, + environmentVariables, + }, + })} + addLabel="Add default" + valueLabel="Preview environment default value" + /> +
diff --git a/dashboard/src/v2/components/settings/panels/SettingsBrowserPanel.tsx b/dashboard/src/v2/components/settings/panels/SettingsBrowserPanel.tsx index 5d1b900b41..417ba4a6d5 100644 --- a/dashboard/src/v2/components/settings/panels/SettingsBrowserPanel.tsx +++ b/dashboard/src/v2/components/settings/panels/SettingsBrowserPanel.tsx @@ -2,7 +2,8 @@ import type { FunctionComponent } from "preact"; import type { SettingsPageState } from "../../../hooks/use-settings-page-state.js"; import { NumberInput, Row, TextInput, Toggle } from "../SettingsFormFields.js"; import { SectionCard, getBadge as getBadgeHelper, getFieldBadge as getFieldBadgeHelper } from "./SharedPanelComponents.js"; -import { Eye, Gauge } from "lucide-preact"; +import { Eye, Gauge, SlidersHorizontal } from "lucide-preact"; +import { PreviewEnvironmentEditor } from "../../browser/PreviewEnvironmentEditor.js"; export const SettingsBrowserPanel: FunctionComponent<{ state: SettingsPageState }> = ({ state }) => { const { @@ -149,6 +150,28 @@ export const SettingsBrowserPanel: FunctionComponent<{ state: SettingsPageState /> + + }> + + updateEditableSettings((current) => ({ + ...current, + sprintPreview: { + ...current.sprintPreview, + environmentVariables, + }, + }))} + addLabel="Add default" + valueLabel="Preview environment default value" + /> + + ); }; diff --git a/dashboard/src/v2/lib/browser-api.ts b/dashboard/src/v2/lib/browser-api.ts index db46ef2f80..6a3fb18b03 100644 --- a/dashboard/src/v2/lib/browser-api.ts +++ b/dashboard/src/v2/lib/browser-api.ts @@ -1,4 +1,4 @@ -import type { SprintPreviewScript, SprintPreviewSession } from "../../types.js"; +import type { PreviewEnvironmentVariable, SprintPreviewScript, SprintPreviewSession } from "../../types.js"; import { fetchJson } from "../../lib/api/fetch-json.js"; export const fetchPreviewSessions = async (projectId: string): Promise => { @@ -54,6 +54,19 @@ export const savePreviewScript = async ( }); }; +export const savePreviewEnvironmentOverrides = async ( + projectId: string, + sprintId: string, + sessionId: string, + environmentOverrides: PreviewEnvironmentVariable[], +): Promise => { + return fetchJson(buildScopedPreviewSessionPath(projectId, sprintId, sessionId, "/environment"), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ environmentOverrides }), + }); +}; + export const fetchPreviewLogs = async (projectId: string, sprintId: string, sessionId: string, tail = 200): Promise<{ logs: string }> => { const url = new URL(buildScopedPreviewSessionPath(projectId, sprintId, sessionId, "/logs"), window.location.origin); url.searchParams.set("tail", String(tail)); diff --git a/dashboard/src/v2/lib/settings-api.ts b/dashboard/src/v2/lib/settings-api.ts index 4f5ad6f1e4..c5c48e54e5 100644 --- a/dashboard/src/v2/lib/settings-api.ts +++ b/dashboard/src/v2/lib/settings-api.ts @@ -1,6 +1,7 @@ import type { EffectiveSettingsResponse, DesignGuidanceSettings, + PreviewEnvironmentVariable, ProjectSettings, SystemSettings, TechstackSelectionSettings, @@ -110,6 +111,21 @@ export const saveProjectSettings = async (projectId: string, settings: ProjectSe } }; +export const saveProjectPreviewEnvironmentVariables = async ( + projectId: string, + environmentVariables: PreviewEnvironmentVariable[], +): Promise => { + const effective = await fetchProjectEffectiveSettings(projectId, { cache: "reload" }); + await saveProjectSettings(projectId, { + ...(effective.settings as ProjectSettings), + sprintPreview: { + ...effective.settings.sprintPreview, + environmentVariables, + }, + }); + return fetchProjectEffectiveSettings(projectId, { cache: "reload" }); +}; + export const saveProjectTechstackSettings = async ( projectId: string, techstack: TechstackSelectionSettings, diff --git a/dashboard/src/v2/lib/settings/project-overrides.ts b/dashboard/src/v2/lib/settings/project-overrides.ts index 738145a8ad..fdcadc45a2 100644 --- a/dashboard/src/v2/lib/settings/project-overrides.ts +++ b/dashboard/src/v2/lib/settings/project-overrides.ts @@ -93,6 +93,12 @@ const cloneDesignGuidance = ( cloneDesignGuidanceSettings(designGuidance ?? DEFAULT_DASHBOARD_SETTINGS.designGuidance) ); +const cloneSprintPreviewSettings = (settings: ProjectSettings["sprintPreview"]): ProjectSettings["sprintPreview"] => ({ + ...settings, + ...("containerAppPorts" in settings ? { containerAppPorts: [...(settings.containerAppPorts ?? [])] } : {}), + ...("environmentVariables" in settings ? { environmentVariables: (settings.environmentVariables ?? []).map((variable) => ({ ...variable })) } : {}), +}); + export const cloneProjectProviders = ( providers: ProjectSettings["aiProvider"]["providers"], ): ProjectSettings["aiProvider"]["providers"] => ( @@ -201,9 +207,7 @@ export const dashboardSettingsToProjectSettings = (settings: DashboardSettings): cliWorkflow: { ...settings.cliWorkflow, }, - sprintPreview: { - ...settings.sprintPreview, - }, + sprintPreview: cloneSprintPreviewSettings(settings.sprintPreview), workers: { ...settings.workers, }, @@ -251,9 +255,7 @@ export const cloneProjectSettings = (settings: ProjectSettings): ProjectSettings cliWorkflow: { ...settings.cliWorkflow, }, - sprintPreview: { - ...settings.sprintPreview, - }, + sprintPreview: cloneSprintPreviewSettings(settings.sprintPreview), workers: { ...settings.workers, }, diff --git a/docs-web/architecture/security.md b/docs-web/architecture/security.md index 06f0cdfe51..2b3929188d 100644 --- a/docs-web/architecture/security.md +++ b/docs-web/architecture/security.md @@ -36,6 +36,7 @@ Two listeners: - If exposing remotely, **front with a reverse proxy** that handles auth (basic auth, OAuth proxy, mTLS, …). - The WebSocket inherits the same security posture. - Interactive dashboard-login containers keep OAuth callback ports bound to host loopback, for example `127.0.0.1::`, and do not switch to public callback ports when the dashboard listener is public. +- Browser Preview user-defined environment variables are validated before they enter the Docker env-file. Runtime-owned names such as `HOST`, `PORT`, `HOME`, `DASHBOARD_PORT`, `SPRINT_PREVIEW_*`, and `CODE_UX_GIT_USER_*` are reserved for Code UX routing. ### MCP HTTP gateway diff --git a/docs-web/content/docs/architecture-security.mdx b/docs-web/content/docs/architecture-security.mdx index 51e3081f4a..54e01e387b 100644 --- a/docs-web/content/docs/architecture-security.mdx +++ b/docs-web/content/docs/architecture-security.mdx @@ -36,6 +36,7 @@ Two listeners: - If exposing remotely, **front with a reverse proxy** that handles auth (basic auth, OAuth proxy, mTLS, …). - The WebSocket inherits the same security posture. - Interactive dashboard-login containers keep OAuth callback ports bound to host loopback, for example `127.0.0.1::`, and do not switch to public callback ports when the dashboard listener is public. +- Browser Preview user-defined environment variables are validated before they enter the Docker env-file. Runtime-owned names such as `HOST`, `PORT`, `HOME`, `DASHBOARD_PORT`, `SPRINT_PREVIEW_*`, and `CODE_UX_GIT_USER_*` are reserved for Code UX routing. ### MCP HTTP gateway diff --git a/docs-web/content/docs/developer-settings-reference.mdx b/docs-web/content/docs/developer-settings-reference.mdx index 297a4a670e..eaa0baaf82 100644 --- a/docs-web/content/docs/developer-settings-reference.mdx +++ b/docs-web/content/docs/developer-settings-reference.mdx @@ -142,7 +142,7 @@ Project and sprint settings own design guidance: } ``` -The backend catalog always includes `none`, the generic Code UX award-winning styleguide, additional default styleguides, and a small tech-stack guidance catalog. Saved selections resolve to a known default or custom id; invalid ids fall back to `none`. `hideDefaultStyleguides` only affects presentation and does not remove backend defaults. Existing and imported projects inherit `none`; new local and new remote project initialization writes an explicit project override for the Code UX styleguide. Planning and Project Setup prompts resolve selected entries from effective project settings and omit inactive `none` catalog entries. Project Setup prompts also include a setup-only styling investigation notice whenever the styleguide selection is `none`, including when tech-stack guidance is also `none`. +The backend catalog always includes `none`, the built-in `Code UX` styleguide, additional default styleguides, and a small tech-stack guidance catalog. Saved selections resolve to a known default or custom id; invalid ids fall back to `none`. `hideDefaultStyleguides` only affects presentation and does not remove backend defaults. Existing and imported projects inherit `none`; new local and new remote project initialization writes an explicit project override for the Code UX styleguide. Planning and Project Setup prompts resolve selected entries from effective project settings and omit inactive `none` catalog entries. Project Setup prompts also include a setup-only styling investigation notice whenever the styleguide selection is `none`, including when tech-stack guidance is also `none`. The dashboard Guidance panel manages this block through the normal settings save flows. System scope edits `system.defaults.designGuidance`; project scope edits the active project override. Built-in catalog entries can be selected but cannot be edited or deleted. Custom entries can be added, edited, and deleted; deleting a selected custom entry clears that selector back to `none`. diff --git a/docs-web/content/docs/user-dashboard-browser-preview.mdx b/docs-web/content/docs/user-dashboard-browser-preview.mdx index fee4fa01f9..c08a595bb3 100644 --- a/docs-web/content/docs/user-dashboard-browser-preview.mdx +++ b/docs-web/content/docs/user-dashboard-browser-preview.mdx @@ -27,6 +27,15 @@ This is invaluable for visually verifying changes a sprint has made (UI work, AP Preview startup does not install Playwright browsers by default. Provider coding containers can get Chromium from setup-cache images at `/ms-playwright`, but preview scripts that use Playwright should install Chromium in the preview startup path or use a custom image/script that already provides it. +Preview containers can also receive custom environment variables: +- Set project-wide defaults from the Browser page right sidebar or **Settings → Browser Preview → Preview Environment**. +- Set selected-container overrides from the preview container card's **Env** action, which opens an override modal. +- Overrides apply after the next rebuild or start. +- Runtime-owned names such as `PORT`, `HOST`, `HOME`, `DASHBOARD_PORT`, `SPRINT_PREVIEW_*`, and `CODE_UX_GIT_USER_*` are reserved for Code UX routing. +- Disabled override rows suppress an inherited default with the same key. + +For example, a Code UX app running inside a preview container can set `CODE_UX_ALLOW_PUBLIC_DASHBOARD=1` as a container override while Code UX still binds the host-facing preview port to `127.0.0.1`. + ## Using the browser pane The pane is an iframe-like container with toolbar buttons: diff --git a/docs-web/developer/settings-reference.md b/docs-web/developer/settings-reference.md index 297a4a670e..eaa0baaf82 100644 --- a/docs-web/developer/settings-reference.md +++ b/docs-web/developer/settings-reference.md @@ -142,7 +142,7 @@ Project and sprint settings own design guidance: } ``` -The backend catalog always includes `none`, the generic Code UX award-winning styleguide, additional default styleguides, and a small tech-stack guidance catalog. Saved selections resolve to a known default or custom id; invalid ids fall back to `none`. `hideDefaultStyleguides` only affects presentation and does not remove backend defaults. Existing and imported projects inherit `none`; new local and new remote project initialization writes an explicit project override for the Code UX styleguide. Planning and Project Setup prompts resolve selected entries from effective project settings and omit inactive `none` catalog entries. Project Setup prompts also include a setup-only styling investigation notice whenever the styleguide selection is `none`, including when tech-stack guidance is also `none`. +The backend catalog always includes `none`, the built-in `Code UX` styleguide, additional default styleguides, and a small tech-stack guidance catalog. Saved selections resolve to a known default or custom id; invalid ids fall back to `none`. `hideDefaultStyleguides` only affects presentation and does not remove backend defaults. Existing and imported projects inherit `none`; new local and new remote project initialization writes an explicit project override for the Code UX styleguide. Planning and Project Setup prompts resolve selected entries from effective project settings and omit inactive `none` catalog entries. Project Setup prompts also include a setup-only styling investigation notice whenever the styleguide selection is `none`, including when tech-stack guidance is also `none`. The dashboard Guidance panel manages this block through the normal settings save flows. System scope edits `system.defaults.designGuidance`; project scope edits the active project override. Built-in catalog entries can be selected but cannot be edited or deleted. Custom entries can be added, edited, and deleted; deleting a selected custom entry clears that selector back to `none`. diff --git a/docs-web/user/dashboard/browser-preview.md b/docs-web/user/dashboard/browser-preview.md index cd31d75404..4f17e9f769 100644 --- a/docs-web/user/dashboard/browser-preview.md +++ b/docs-web/user/dashboard/browser-preview.md @@ -27,6 +27,15 @@ This is invaluable for visually verifying changes a sprint has made (UI work, AP Preview startup does not install Playwright browsers by default. Provider coding containers can get Chromium from setup-cache images at `/ms-playwright`, but preview scripts that use Playwright should install Chromium in the preview startup path or use a custom image/script that already provides it. +Preview containers can also receive custom environment variables: +- Set project-wide defaults from the Browser page right sidebar or **Settings → Browser Preview → Preview Environment**. +- Set selected-container overrides from the preview container card's **Env** action, which opens an override modal. +- Overrides apply after the next rebuild or start. +- Runtime-owned names such as `PORT`, `HOST`, `HOME`, `DASHBOARD_PORT`, `SPRINT_PREVIEW_*`, and `CODE_UX_GIT_USER_*` are reserved for Code UX routing. +- Disabled override rows suppress an inherited default with the same key. + +For example, a Code UX app running inside a preview container can set `CODE_UX_ALLOW_PUBLIC_DASHBOARD=1` as a container override while Code UX still binds the host-facing preview port to `127.0.0.1`. + ## Using the browser pane The pane is an iframe-like container with toolbar buttons: diff --git a/docs/architecture/sprint-preview-browser.md b/docs/architecture/sprint-preview-browser.md index a1880258d0..3f3c2baaf0 100644 --- a/docs/architecture/sprint-preview-browser.md +++ b/docs/architecture/sprint-preview-browser.md @@ -35,6 +35,7 @@ Key rules: - one preview session/container can expose multiple container-to-host port mappings, and one host-facing port is allocated from `sprintPreview.hostPortRangeStart..hostPortRangeEnd` for each configured container app port - host ports bind to `127.0.0.1` only - preview startup injects `HOST`, `PORT`, `DASHBOARD_HOST`, `DASHBOARD_PORT`, and `SPRINT_PREVIEW_WORKSPACE` so containerized apps can bind to the published preview port and boot from the exported snapshot directory. The primary compatibility variables still point at the first mapping, and `SPRINT_PREVIEW_CONTAINER_PORTS`, `SPRINT_PREVIEW_HOST_PORTS`, and `SPRINT_PREVIEW_PORT_MAPPINGS` expose the full routing list. +- Browser Preview settings and the Browser page right sidebar can define default preview environment variables for every container in the project scope, and each preview container card can open a modal for per-session overrides. These user variables are written through the preview Docker env-file path alongside provider env, while runtime-owned names such as `HOST`, `PORT`, `HOME`, `DASHBOARD_PORT`, `SPRINT_PREVIEW_*`, and `CODE_UX_GIT_USER_*` remain reserved. - preview startup is serialized per `(projectId, sprintId)` so manual starts, rebuilds, and auto-start reconciliation cannot spawn duplicate session containers - if the previewed app still binds a loopback-only internal port, the generated preview bootstrap keeps a dedicated in-container bridge open on the published preview proxy port and forwards requests to the live app listener - containers are labeled with sprint-preview metadata so runtime reconciliation can rediscover them @@ -133,6 +134,13 @@ Rebuild behaviors: These behaviors are controlled through scoped settings under `sprintPreview`. +Preview environment behavior: +- scoped defaults live in `sprintPreview.environmentVariables` +- selected-container overrides live on the `sprint_preview_sessions.environment_overrides_json` row and are edited from the preview container card's Env override modal +- enabled overrides replace defaults by key; disabled override rows suppress an inherited default for that key +- values must be single-line Docker env-file values +- saved environment changes apply on the next start or rebuild because the container process receives its environment at creation time + Current preview controls include: - `enabled` - `showInAppBrowser` @@ -208,6 +216,7 @@ Preview endpoints are implemented in `src/server/dashboard-server.ts`. - `DELETE /api/browser/sessions/:sessionId` - `GET /api/projects/:projectId/sprints/:sprintId/preview/script` - `PUT /api/projects/:projectId/sprints/:sprintId/preview/script` +- `PUT /api/projects/:projectId/sprints/:sprintId/preview/sessions/:sessionId/environment` - `GET /api/projects/:projectId/sprints/:sprintId/preview/sessions/:sessionId/logs` - `GET /api/browser/sessions/:sessionId/logs` - `ALL /api/browser/sessions/:sessionId/proxy/*` diff --git a/docs/dashboard/browser-preview.md b/docs/dashboard/browser-preview.md index f2623df2b5..2c6048705c 100644 --- a/docs/dashboard/browser-preview.md +++ b/docs/dashboard/browser-preview.md @@ -25,6 +25,7 @@ The browser preview provides an integrated environment for interacting with runn - Interactive elements (session menus, sliders, actions) must be fully keyboard accessible. - Iframes and embedded views must have descriptive titles indicating their purpose and target. - Window controls, navigation controls, session removal, external open, rebuild, stop, and script save actions must use explicit accessible names instead of relying on `title` text or icon shape. +- Preview environment defaults in the right sidebar and container override modals from preview cards must be keyboard reachable, expose labeled key/value fields, announce save progress, and make clear that saved environment changes apply on the next rebuild or start. - Live regions should transparently report loading, starting, running, stopped, reconnecting/unavailable, stale-log, saving, launching, empty-session, and error states without overwhelming screen readers. - The address form must keep a programmatic label, describe why it is disabled when the preview container is unavailable, and announce submitted navigation attempts while keeping focus in the address field. - Session rails must keep horizontal overflow inside the rail, expose the active session state, and remain keyboard reachable at narrow widths. diff --git a/docs/operations/security-hardening.md b/docs/operations/security-hardening.md index b5a9db885a..0e27291f7c 100644 --- a/docs/operations/security-hardening.md +++ b/docs/operations/security-hardening.md @@ -76,6 +76,7 @@ While Code UX trusts the developer and any connected systems, several specific p - **MCP Gateway Log Hygiene:** Unauthorized, invalid-header, rate-limit, inactive-session, session-cap, idle-cleanup, and startup gateway logs omit bearer values, supplied session ids, supplied agent ids, raw request bodies, provider credentials, and login tokens. They retain only bounded operational metadata such as method, path, host, port, auth-required state, active-session counts, configured limits, and timeout values. - **Settings Secret Inputs:** Dashboard settings fields that store provider API keys, Git host tokens, Jira API tokens, and external embedding API keys render as masked secret inputs by default. Operators must explicitly use the reveal control to inspect a value. - **Docker Secret Transport:** Provider and preview Docker launches write selected host/provider environment variables to temporary `0600` env-files and pass those files via `--env-file`. Provider argv and generated provider MCP/config artifacts are also staged in restrictive temporary files and mounted into the container instead of being inlined into `docker run` arguments or labels. Provider credentials use isolated credential mounts rather than broad workspace root exposure, ensuring secrets are strictly bounded and not inadvertently captured in workspace logs. This keeps API keys, MCP bearer tokens, and Git tokens out of the host `docker run` argv visible through process listings while preserving the same container environment. +- **Preview Environment Boundaries:** Browser Preview user-defined environment variables are validated before they are written to the Docker env-file. Keys must be shell-style env names, values must be single-line env-file values, and runtime-owned routing names such as `HOST`, `PORT`, `HOME`, `DASHBOARD_PORT`, `SPRINT_PREVIEW_*`, and `CODE_UX_GIT_USER_*` are reserved so UI edits cannot override Code UX port routing or container identity. - **Dashboard Login Port Binding:** Interactive dashboard-login containers do not use Docker host networking by default. Codex and Claude Code OAuth callback ports are published only on host loopback as `127.0.0.1::`; other provider login containers publish no host ports unless a provider-specific flow explicitly requires it. Public dashboard binding does not change this callback-port rule. ### Subprocess & Settings Mutation Safety diff --git a/docs/settings/configuration-and-storage.md b/docs/settings/configuration-and-storage.md index d188cf2b48..d8798f11ac 100644 --- a/docs/settings/configuration-and-storage.md +++ b/docs/settings/configuration-and-storage.md @@ -45,7 +45,7 @@ Techstack settings are split across scopes: The built-in catalog always includes the Code UX Stack (`code-ux-internal`) with Preact, TanStack Router, GSAP, Three.js, and Lucide Icons. Catalog sanitization trims ids and labels, drops malformed or duplicate ids, preserves the built-in entry, and falls back `defaultTechstackId` to `code-ux-internal` if the saved default is missing or invalid. Project defaults intentionally keep `techstack.selectedTechstackId = null` and `techstack.applicationKind = null`; existing and imported projects therefore do not inherit the built-in stack automatically. New-project flows must apply a catalog default explicitly when they need one. -Design guidance is an inheritable scoped setting under `designGuidance`. System defaults, project overrides, and sprint overrides all participate in the normal effective settings resolution, and source metadata reports whether a guidance field came from `system`, `project`, or `sprint`. The block stores selected tech-stack guidance, selected styleguide guidance, `hideDefaultStyleguides`, and custom tech stack/styleguide entries with stable `id`, `name`, `summary`, and `instructionMarkdown` fields. System defaults resolve both selections to `none`, so existing and imported projects receive no design styleguide by inheritance. New local and new remote project initialization writes an explicit project override selecting the generic Code UX award-winning styleguide; imported local or Git projects remain at `none` until an operator or setup flow changes them. Planning prompts receive a compact `Project Guidance` section only for selected non-`none` entries, so generated tasks can reflect active guidance without duplicating inactive defaults. Project Setup prompts use the same selected-entry section and also include a setup-only styling investigation notice whenever the styleguide selection is `none`, including when tech-stack guidance is also `none`. +Design guidance is an inheritable scoped setting under `designGuidance`. System defaults, project overrides, and sprint overrides all participate in the normal effective settings resolution, and source metadata reports whether a guidance field came from `system`, `project`, or `sprint`. The block stores selected tech-stack guidance, selected styleguide guidance, `hideDefaultStyleguides`, and custom tech stack/styleguide entries with stable `id`, `name`, `summary`, and `instructionMarkdown` fields. System defaults resolve both selections to `none`, so existing and imported projects receive no design styleguide by inheritance. New local and new remote project initialization writes an explicit project override selecting the built-in `Code UX` styleguide; imported local or Git projects remain at `none` until an operator or setup flow changes them. Planning prompts receive a compact `Project Guidance` section only for selected non-`none` entries, so generated tasks can reflect active guidance without duplicating inactive defaults. Project Setup prompts use the same selected-entry section and also include a setup-only styling investigation notice whenever the styleguide selection is `none`, including when tech-stack guidance is also `none`. For `.code-ux/settings.json` (used primarily for credential hints during initial onboarding), search roots include: - current working directory diff --git a/src/app/lifecycle/dashboard-lifecycle-service.ts b/src/app/lifecycle/dashboard-lifecycle-service.ts index 26885e7d9f..4d6f04f5f2 100644 --- a/src/app/lifecycle/dashboard-lifecycle-service.ts +++ b/src/app/lifecycle/dashboard-lifecycle-service.ts @@ -17,6 +17,7 @@ import type { OnboardingDependencyInstallerResult, OnboardingDependencyInstallMode, OnboardingRuntimeReadiness, + PreviewEnvironmentVariable, ProjectLiveDashboardSnapshot, ProjectStatsQuery, ReadinessProbeStatus, @@ -147,6 +148,7 @@ export interface BootDashboardDeps { removeSprintPreviewSessionForProjectSprint: (projectId: string, sprintId: string, sessionId: string) => Promise; getSprintPreviewScript: (projectId: string, sprintId: string) => Promise; saveSprintPreviewScript: (projectId: string, sprintId: string, content: string) => Promise; + updateSprintPreviewEnvironmentOverrides: (projectId: string, sprintId: string, sessionId: string, environmentOverrides: PreviewEnvironmentVariable[]) => Promise; getSprintPreviewLogs: (sessionId: string, tail?: number) => Promise<{ logs: string }>; getSprintPreviewLogsForProjectSprint: (projectId: string, sprintId: string, sessionId: string, tail?: number) => Promise<{ logs: string }>; proxySprintPreviewRequest: (args: { @@ -818,6 +820,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise= 1 && value <= 65535; } diff --git a/src/server/code-ux-server.ts b/src/server/code-ux-server.ts index 656eb2017d..8839068bbd 100644 --- a/src/server/code-ux-server.ts +++ b/src/server/code-ux-server.ts @@ -1359,6 +1359,7 @@ export class CodeUxServer { removeSprintPreviewSessionForProjectSprint: (projectId, sprintId, sessionId) => this.sprintPreviewService.removeSessionForProjectSprint(projectId, sprintId, sessionId), getSprintPreviewScript: (projectId, sprintId) => this.sprintPreviewService.getScript(projectId, sprintId), saveSprintPreviewScript: (projectId, sprintId, content) => this.sprintPreviewService.saveScript(projectId, sprintId, content), + updateSprintPreviewEnvironmentOverrides: (projectId, sprintId, sessionId, environmentOverrides) => this.sprintPreviewService.updateEnvironmentOverridesForProjectSprint(projectId, sprintId, sessionId, environmentOverrides), getSprintPreviewLogs: (sessionId, tail) => this.sprintPreviewService.getLogs(sessionId, tail), getSprintPreviewLogsForProjectSprint: (projectId, sprintId, sessionId, tail) => this.sprintPreviewService.getLogsForProjectSprint(projectId, sprintId, sessionId, tail), proxySprintPreviewRequest: (args) => this.sprintPreviewService.proxyRequest(args), diff --git a/src/server/dashboard-server.ts b/src/server/dashboard-server.ts index 8cb46404bb..b25869a97f 100644 --- a/src/server/dashboard-server.ts +++ b/src/server/dashboard-server.ts @@ -14,6 +14,7 @@ import type { OnboardingDependencyInstallMode, OnboardingRuntimeReadiness, OverviewTelemetrySnapshot, + PreviewEnvironmentVariable, ProjectExecutionStatsSnapshot, ProjectLiveDashboardSnapshot, ProjectStatsQuery, @@ -316,6 +317,7 @@ export interface DashboardServerOptions { saveSprintPreviewScript?: (projectId: string, sprintId: string, content: string) => Promise | SprintPreviewScript; getSprintPreviewLogs?: (sessionId: string, tail?: number) => Promise<{ logs: string }> | { logs: string }; getSprintPreviewLogsForProjectSprint?: (projectId: string, sprintId: string, sessionId: string, tail?: number) => Promise<{ logs: string }> | { logs: string }; + updateSprintPreviewEnvironmentOverrides?: (projectId: string, sprintId: string, sessionId: string, environmentOverrides: PreviewEnvironmentVariable[]) => Promise | SprintPreviewSession; proxySprintPreviewRequest?: (args: { sessionId: string; method: string; diff --git a/src/server/preview-routes.ts b/src/server/preview-routes.ts index cbe364adf4..7338242800 100644 --- a/src/server/preview-routes.ts +++ b/src/server/preview-routes.ts @@ -115,6 +115,18 @@ export function registerPreviewRoutes(app: Express, deps: DashboardDependencies) )); })); + app.put("/api/projects/:projectId/sprints/:sprintId/preview/sessions/:sessionId/environment", asyncRoute(async (req, res) => { + if (!deps.updateSprintPreviewEnvironmentOverrides) { + throw new Error("Sprint preview runtime is unavailable."); + } + res.json(await deps.updateSprintPreviewEnvironmentOverrides( + requireTrimmedString(req.params.projectId, "projectId"), + requireTrimmedString(req.params.sprintId, "sprintId"), + requireTrimmedString(req.params.sessionId, "sessionId"), + Array.isArray(req.body?.environmentOverrides) ? req.body.environmentOverrides : [], + )); + })); + app.get("/api/projects/:projectId/sprints/:sprintId/preview/sessions/:sessionId/logs", asyncRoute(async (req, res) => { if (!deps.getSprintPreviewLogsForProjectSprint) { throw new Error("Sprint preview runtime is unavailable."); diff --git a/src/services/settings-resolution-service.ts b/src/services/settings-resolution-service.ts index eabcc6e2b5..467547cbcd 100644 --- a/src/services/settings-resolution-service.ts +++ b/src/services/settings-resolution-service.ts @@ -60,6 +60,7 @@ import { cloneDesignGuidanceSettings, sanitizeDesignGuidanceSettings, } from "../domain/settings/design-guidance-catalog.js"; +import { sanitizePreviewEnvironmentVariables } from "../shared/preview-environment.js"; function cloneSkills(skills: SkillToggle[]): SkillToggle[] { return skills.map((skill) => ({ ...skill })); @@ -734,6 +735,7 @@ function sanitizeSprintPreviewSettings(value: unknown): ProjectSettings["sprintP } return raw; })(), + environmentVariables: sanitizePreviewEnvironmentVariables(input.environmentVariables), }; } diff --git a/src/services/sprint-preview-service.ts b/src/services/sprint-preview-service.ts index d43572704e..ae673c51ae 100644 --- a/src/services/sprint-preview-service.ts +++ b/src/services/sprint-preview-service.ts @@ -8,6 +8,7 @@ import os from "os"; import { fileURLToPath } from "url"; import type { CliWorkflowSettings, + PreviewEnvironmentVariable, SprintPreviewScript, SprintPreviewPortMapping, SprintPreviewSession, @@ -48,6 +49,7 @@ import { buildSprintPreviewDockerCreateArgs, CONTAINER_PREVIEW_PROXY_PORT, CONTA import { ensureDefaultCodeUxAssetsInstalled } from "./code-ux-default-assets-service.js"; import { fetchOriginIfAvailable } from "./git-branch-sync-service.js"; import { buildGitHttpAuthEnvForRepoWithFallbacks, type GitHttpAuthOptions } from "./git-http-auth.js"; +import { mergePreviewEnvironmentVariables, sanitizePreviewEnvironmentVariables } from "../shared/preview-environment.js"; const BUNDLED_CONTAINER_SETUP_SCRIPT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -287,7 +289,14 @@ export class SprintPreviewService { const envFileTempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-preview-env-")); try { const envFilePath = path.join(envFileTempRoot, "preview.env"); - await writeDockerEnvFile(envFilePath, pickContainerEnv(process.env)); + const previewEnvironment = mergePreviewEnvironmentVariables( + settings.environmentVariables ?? [], + session.environmentOverrides ?? [], + ); + await writeDockerEnvFile(envFilePath, [ + ...pickContainerEnv(process.env), + ...previewEnvironment, + ]); const dockerArgs = buildSprintPreviewDockerCreateArgs({ projectId, sprintId, @@ -581,6 +590,18 @@ export class SprintPreviewService { return await this.getScript(projectId, sprintId); } + async updateEnvironmentOverridesForProjectSprint( + projectId: string, + sprintId: string, + sessionId: string, + environmentOverrides: PreviewEnvironmentVariable[], + ): Promise { + const session = await this.requireScopedSession(projectId, sprintId, sessionId); + return this.deps.sprintPreviewRepository.updateSession(session.id, { + environmentOverrides: sanitizePreviewEnvironmentVariables(environmentOverrides), + }); + } + async proxyRequest(args: { sessionId: string; method: string; diff --git a/src/shared/preview-environment.ts b/src/shared/preview-environment.ts new file mode 100644 index 0000000000..e998e0745b --- /dev/null +++ b/src/shared/preview-environment.ts @@ -0,0 +1,92 @@ +import type { PreviewEnvironmentVariable } from "../contracts/app-types.js"; + +const PREVIEW_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const MAX_PREVIEW_ENV_VARS = 100; +const MAX_PREVIEW_ENV_KEY_LENGTH = 128; +const MAX_PREVIEW_ENV_VALUE_LENGTH = 4096; + +const RESERVED_PREVIEW_ENV_KEYS = new Set([ + "HOME", + "HOST", + "PORT", + "DASHBOARD_PORT", + "SPRINT_PREVIEW_PORT", + "SPRINT_PREVIEW_PRIMARY_CONTAINER_PORT", + "SPRINT_PREVIEW_PRIMARY_HOST_PORT", + "SPRINT_PREVIEW_CONTAINER_PORTS", + "SPRINT_PREVIEW_HOST_PORTS", + "SPRINT_PREVIEW_PORT_MAPPINGS", + "SPRINT_PREVIEW_PROXY_PORT", + "SPRINT_PREVIEW_WORKSPACE", + "SPRINT_PREVIEW_WORKTREE", + "SPRINT_PREVIEW_INSTALL_COMMAND", + "SPRINT_PREVIEW_BUILD_COMMAND", + "SPRINT_PREVIEW_RUN_COMMAND", + "SPRINT_PREVIEW_SOURCE_COMMIT", + "CODE_UX_GIT_USER_NAME", + "CODE_UX_GIT_USER_EMAIL", +]); + +export function isReservedPreviewEnvironmentKey(key: string): boolean { + return RESERVED_PREVIEW_ENV_KEYS.has(key) || key.startsWith("SPRINT_PREVIEW_"); +} + +export function isValidPreviewEnvironmentKey(key: string): boolean { + return key.length > 0 + && key.length <= MAX_PREVIEW_ENV_KEY_LENGTH + && PREVIEW_ENV_KEY_PATTERN.test(key) + && !isReservedPreviewEnvironmentKey(key); +} + +export function sanitizePreviewEnvironmentVariables(input: unknown): PreviewEnvironmentVariable[] { + if (!Array.isArray(input)) { + return []; + } + + const byKey = new Map(); + for (const item of input) { + if (!item || typeof item !== "object") { + continue; + } + const raw = item as Partial; + const key = typeof raw.key === "string" ? raw.key.trim() : ""; + const value = typeof raw.value === "string" ? raw.value : ""; + if (!isValidPreviewEnvironmentKey(key)) { + continue; + } + if (value.includes("\n") || value.includes("\r") || value.length > MAX_PREVIEW_ENV_VALUE_LENGTH) { + continue; + } + byKey.delete(key); + byKey.set(key, { + key, + value, + enabled: raw.enabled !== false, + }); + if (byKey.size >= MAX_PREVIEW_ENV_VARS) { + break; + } + } + + return [...byKey.values()]; +} + +export function mergePreviewEnvironmentVariables( + defaults: PreviewEnvironmentVariable[], + overrides: PreviewEnvironmentVariable[], +): Array<{ key: string; value: string }> { + const merged = new Map(); + for (const variable of sanitizePreviewEnvironmentVariables(defaults)) { + if (variable.enabled !== false) { + merged.set(variable.key, variable.value); + } + } + for (const variable of sanitizePreviewEnvironmentVariables(overrides)) { + if (variable.enabled === false) { + merged.delete(variable.key); + } else { + merged.set(variable.key, variable.value); + } + } + return [...merged].map(([key, value]) => ({ key, value })); +} diff --git a/tests/backend/repositories/settings-sanitizer.test.ts b/tests/backend/repositories/settings-sanitizer.test.ts index 0463290cd0..a2476abb42 100644 --- a/tests/backend/repositories/settings-sanitizer.test.ts +++ b/tests/backend/repositories/settings-sanitizer.test.ts @@ -343,6 +343,26 @@ describe("settings-sanitizer", () => { expect(settings.sprintPreview.containerAppPorts).toEqual([5173, 6006, 7007, 9000]); }); + it("sanitizes preview container environment variables", () => { + const settings = sanitizeSettings({ + sprintPreview: { + environmentVariables: [ + { key: " CODE_UX_ALLOW_PUBLIC_DASHBOARD ", value: "1", enabled: true }, + { key: "SPRINT_PREVIEW_PORT", value: "9999", enabled: true }, + { key: "BAD-NAME", value: "bad", enabled: true }, + { key: "MULTILINE", value: "one\ntwo", enabled: true }, + { key: "FEATURE_FLAG", value: "old", enabled: true }, + { key: "FEATURE_FLAG", value: "new", enabled: false }, + ], + }, + }); + + expect(settings.sprintPreview.environmentVariables).toEqual([ + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }, + { key: "FEATURE_FLAG", value: "new", enabled: false }, + ]); + }); + it("preserves valid appearance background image and pattern settings", () => { const settings = sanitizeSettings({ appearance: { diff --git a/tests/backend/repositories/sprint-preview-repository.test.ts b/tests/backend/repositories/sprint-preview-repository.test.ts index 53a7e40da0..af633222fe 100644 --- a/tests/backend/repositories/sprint-preview-repository.test.ts +++ b/tests/backend/repositories/sprint-preview-repository.test.ts @@ -106,6 +106,37 @@ describe("SprintPreviewRepository", () => { expect(withHostPort.portMappings).toEqual([{ containerPort: 3000, hostPort: 6100, isPrimary: true }]); }); + it("persists sanitized environment overrides", async () => { + const { repository, projectId, sprintId } = await createFixture(); + const session = repository.createSession({ + projectId, + sprintId, + status: "starting", + containerAppPort: 3000, + startupScriptPath: ".code-ux/browser/start-preview.sh", + startupMode: "auto", + environmentOverrides: [ + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }, + { key: "SPRINT_PREVIEW_PORT", value: "9999", enabled: true }, + ], + }); + + expect(session.environmentOverrides).toEqual([ + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }, + ]); + + const updated = repository.updateSession(session.id, { + environmentOverrides: [ + { key: "API_BASE_URL", value: "http://api.local", enabled: true }, + { key: "API_BASE_URL", value: "", enabled: false }, + ], + }); + + expect(repository.getSession(updated.id)?.environmentOverrides).toEqual([ + { key: "API_BASE_URL", value: "", enabled: false }, + ]); + }); + it("falls back to the first mapping as primary when none is marked", async () => { const { repository, projectId, sprintId } = await createFixture(); diff --git a/tests/backend/server/preview-routes.test.ts b/tests/backend/server/preview-routes.test.ts index c3b9aab346..8e65bab91c 100644 --- a/tests/backend/server/preview-routes.test.ts +++ b/tests/backend/server/preview-routes.test.ts @@ -79,6 +79,34 @@ describe("preview routes", () => { expect(rebuildSprintPreviewSessionForProjectSprint).toHaveBeenCalledWith("project-a", "sprint-a", "session-1"); }); + it("updates environment overrides through the scoped project route", async () => { + const updateSprintPreviewEnvironmentOverrides = vi.fn(async () => ({ + id: "session-1", + projectId: "project-a", + sprintId: "sprint-a", + environmentOverrides: [{ key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }], + })); + const app = express(); + app.use(express.json()); + registerPreviewRoutes(app, { updateSprintPreviewEnvironmentOverrides } as any); + + const response = await request(app) + .put("/api/projects/project-a/sprints/sprint-a/preview/sessions/session-1/environment") + .send({ + environmentOverrides: [ + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }, + ], + }); + + expect(response.status).toBe(200); + expect(updateSprintPreviewEnvironmentOverrides).toHaveBeenCalledWith( + "project-a", + "sprint-a", + "session-1", + [{ key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }], + ); + }); + it("hides foreign preview sessions behind a generic not found response", async () => { const stopSprintPreviewSessionForProjectSprint = vi.fn(async () => { throw new EntityNotFoundError("Sprint preview session not found."); diff --git a/tests/backend/services/sprint-preview-service-unit.test.ts b/tests/backend/services/sprint-preview-service-unit.test.ts index b7d774a9a5..3cb842b6ef 100644 --- a/tests/backend/services/sprint-preview-service-unit.test.ts +++ b/tests/backend/services/sprint-preview-service-unit.test.ts @@ -95,6 +95,7 @@ import { SprintPreviewService } from "../../../src/services/sprint-preview-servi import { runCommandStrict } from "../../../src/services/cli-process-runner.js"; import { fetchOriginIfAvailable } from "../../../src/services/git-branch-sync-service.js"; import { resolveDockerRuntimeRoot } from "../../../src/infrastructure/providers/cli/docker-runtime-paths.js"; +import { writeDockerEnvFile } from "../../../src/services/cli-docker-utils.js"; import { normalizePreviewPath, readOptionalSprintPreviewScript } from "../../../src/services/sprint-preview-utils.js"; function makePreviewSettings(overrides: Record = {}) { @@ -111,6 +112,7 @@ function makePreviewSettings(overrides: Record = {}) { containerAppPort: 3000, containerAppPorts: [3000], startupScriptPath: ".code-ux/browser/start-preview.sh", + environmentVariables: [], ...overrides, }; } @@ -136,6 +138,7 @@ function makeSession(overrides: Partial = {}): SprintPrevi installCommand: "npm ci", buildCommand: "npm run build", runCommand: "npm start", + environmentOverrides: [], lastCompletedTaskCount: 0, lastSeenSprintStatus: "running", lastKnownPath: "/", @@ -481,6 +484,49 @@ describe("SprintPreviewService unit tests", () => { vi.unstubAllGlobals(); }); + it("writes project defaults and session overrides to the preview env-file", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true }))); + const existingSession = makeSession({ + environmentOverrides: [ + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }, + { key: "API_BASE_URL", value: "", enabled: false }, + ], + }); + deps.sprintPreviewRepository.getSessionByProjectSprint.mockReturnValue(existingSession); + deps.sprintPreviewRepository.getSession.mockReturnValue(existingSession); + deps.settingsRepository.resolveSprintDashboardSettings.mockReturnValue({ + settings: { ...DEFAULT_DASHBOARD_SETTINGS, + sprintPreview: makePreviewSettings({ + environmentVariables: [ + { key: "API_BASE_URL", value: "http://api.local", enabled: true }, + { key: "FEATURE_FLAG", value: "enabled", enabled: true }, + ], + }), + git: { githubMode: "REMOTE", defaultBranch: "main", sprintBranchScheme: "feature/sprint-{number}" }, + cliWorkflow: { containerImage: "", containerCacheSetupScriptImage: false, containerSetupScriptPath: "" }, + }, + }); + vi.mocked(runCommandStrict).mockImplementation(async (cmd, args) => { + if (cmd === "docker" && args[0] === "create") { + return { exitCode: 0, stdout: "cid123\n", stderr: "", durationMs: 1 }; + } + return { exitCode: 0, stdout: "", stderr: "", durationMs: 1 }; + }); + + const service = new SprintPreviewService(deps as any); + await service.startSession("proj-1", "sprint-1", { rebuild: true }); + + expect(writeDockerEnvFile).toHaveBeenCalledWith( + expect.stringContaining("preview.env"), + expect.arrayContaining([ + { key: "FEATURE_FLAG", value: "enabled" }, + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1" }, + ]), + ); + expect(vi.mocked(writeDockerEnvFile).mock.calls.at(-1)?.[1]).not.toContainEqual({ key: "API_BASE_URL", value: "http://api.local" }); + vi.unstubAllGlobals(); + }); + it("fails before container creation when the host port range cannot cover all container ports", async () => { deps.settingsRepository.resolveSprintDashboardSettings.mockReturnValue({ settings: { ...DEFAULT_DASHBOARD_SETTINGS, diff --git a/tests/dashboard/lib/settings-view-models.test.ts b/tests/dashboard/lib/settings-view-models.test.ts index 3dd8f62847..8431a15e9a 100644 --- a/tests/dashboard/lib/settings-view-models.test.ts +++ b/tests/dashboard/lib/settings-view-models.test.ts @@ -907,7 +907,7 @@ describe("settings guidance view models", () => { const visibleIds = getVisibleDesignGuidanceEntries(settings, "styleguide").map((entry) => entry.id); expect(visibleIds).toEqual([DESIGN_GUIDANCE_NONE_ID, "custom-style"]); - expect(getDesignGuidanceActiveLabel(settings, "styleguide")).toBe("Code UX Award-Winning Product UI"); + expect(getDesignGuidanceActiveLabel(settings, "styleguide")).toBe("Code UX"); expect(isSelectedDefaultStyleguideHidden(settings)).toBe(true); }); diff --git a/tests/dashboard/v2/browser-page.test.tsx b/tests/dashboard/v2/browser-page.test.tsx index 9967db0df3..6bd2cdec5a 100644 --- a/tests/dashboard/v2/browser-page.test.tsx +++ b/tests/dashboard/v2/browser-page.test.tsx @@ -8,7 +8,8 @@ import * as matchers from "@testing-library/jest-dom/matchers"; import { BrowserPage } from "../../../dashboard/src/v2/BrowserPage.js"; import { useProjectData } from "../../../dashboard/src/v2/context/project-data.js"; import { usePreviewSessions } from "../../../dashboard/src/v2/hooks/use-preview-sessions.js"; -import { fetchPreviewLogs, fetchPreviewScript, rebuildPreviewSession, savePreviewScript } from "../../../dashboard/src/v2/lib/browser-api.js"; +import { fetchPreviewLogs, fetchPreviewScript, rebuildPreviewSession, savePreviewEnvironmentOverrides, savePreviewScript } from "../../../dashboard/src/v2/lib/browser-api.js"; +import { saveProjectPreviewEnvironmentVariables } from "../../../dashboard/src/v2/lib/settings-api.js"; expect.extend(matchers); @@ -39,6 +40,7 @@ const effectiveSettingsMock = vi.hoisted(() => ({ sprintPreview: { enabled: true, showInAppBrowser: true, + environmentVariables: [{ key: "API_BASE_URL", value: "http://api.local", enabled: true }], }, }, }, @@ -102,17 +104,20 @@ vi.mock("../../../dashboard/src/v2/components/browser/PreviewSessionSlider.js", sessions, onSelectSession, onRemoveSession, + onManageEnvironment, removingSessionIds = [], }: { sessions: Array<{ id: string; sprintName: string; hostPort?: number | null }>; onSelectSession: (id: string) => void; onRemoveSession: (id: string) => void; + onManageEnvironment: (id: string) => void; removingSessionIds?: string[]; }) => (
{sessions.filter((session) => !removingSessionIds.includes(session.id)).map((session) => ( @@ -223,14 +228,50 @@ vi.mock("../../../dashboard/src/v2/lib/browser-api.js", () => ({ removePreviewSession: mockRemovePreviewSession, rebuildPreviewSession: vi.fn().mockResolvedValue(undefined), savePreviewScript: vi.fn().mockResolvedValue({ content: "new mock script", mode: "script", path: "/script.sh" }), + savePreviewEnvironmentOverrides: vi.fn().mockResolvedValue({ + id: "sess-1", + projectId: "p1", + sprintId: "s1", + sprintName: "Sprint 1", + status: "running", + healthStatus: "healthy", + containerAppPort: 3000, + hostPort: 8080, + portMappings: [{ containerPort: 3000, hostPort: 8080, isPrimary: true }], + environmentOverrides: [{ key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }], + }), startPreviewSession: mockStartPreviewSession, stopPreviewSession: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../../../dashboard/src/v2/lib/settings-api.js", () => ({ + saveProjectPreviewEnvironmentVariables: vi.fn().mockResolvedValue({ + settings: { + sprintPreview: { + environmentVariables: [ + { key: "API_BASE_URL", value: "http://api.local", enabled: true }, + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }, + ], + }, + }, + }), +})); + afterEach(() => { cleanup(); vi.mocked(usePreviewSessions).mockReset(); vi.mocked(usePreviewSessions).mockImplementation(() => buildDefaultPreviewSessionsResult()); + vi.mocked(saveProjectPreviewEnvironmentVariables).mockReset(); + vi.mocked(saveProjectPreviewEnvironmentVariables).mockResolvedValue({ + settings: { + sprintPreview: { + environmentVariables: [ + { key: "API_BASE_URL", value: "http://api.local", enabled: true }, + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }, + ], + }, + }, + } as any); }); describe("BrowserPage", () => { @@ -244,6 +285,7 @@ describe("BrowserPage", () => { sprintPreview: { enabled: true, showInAppBrowser: true, + environmentVariables: [{ key: "API_BASE_URL", value: "http://api.local", enabled: true }], }, }, }, @@ -376,6 +418,11 @@ describe("BrowserPage", () => { vi.mocked(fetchPreviewScript).mockResolvedValue({ content: "mock script", mode: "script", path: "/script.sh" }); vi.mocked(savePreviewScript).mockReset(); vi.mocked(savePreviewScript).mockResolvedValue({ content: "new mock script", mode: "script", path: "/script.sh" }); + vi.mocked(savePreviewEnvironmentOverrides).mockReset(); + vi.mocked(savePreviewEnvironmentOverrides).mockResolvedValue({ + ...buildDefaultPreviewSessionsResult().selectedSession, + environmentOverrides: [{ key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }], + } as any); vi.mocked(rebuildPreviewSession).mockReset(); vi.mocked(rebuildPreviewSession).mockResolvedValue(undefined); }); @@ -645,6 +692,51 @@ describe("BrowserPage", () => { expect(screen.getByText("Container rebuilt successfully")).toBeInTheDocument(); }); + it("saves selected container environment overrides", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Env Sprint 1" })); + expect(screen.getByText("API_BASE_URL=http://api.local")).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "Sprint 1" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Add override" })); + const nameInputs = screen.getAllByLabelText("Environment variable name"); + const valueInputs = screen.getAllByLabelText("Preview environment override value"); + await user.type(nameInputs.at(-1) as HTMLElement, "CODE_UX_ALLOW_PUBLIC_DASHBOARD"); + await user.type(valueInputs.at(-1) as HTMLElement, "1"); + await user.click(screen.getByRole("button", { name: "Save overrides" })); + + expect(savePreviewEnvironmentOverrides).toHaveBeenCalledWith( + "p1", + "s1", + "sess-1", + [{ key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }], + ); + expect(screen.getByText("Preview environment saved. Rebuild the container to apply changes.")).toBeInTheDocument(); + }); + + it("saves project-wide preview environment defaults from the right sidebar", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add default" })); + const nameInputs = screen.getAllByLabelText("Environment variable name"); + const valueInputs = screen.getAllByLabelText("Preview environment default value"); + await user.type(nameInputs.at(-1) as HTMLElement, "CODE_UX_ALLOW_PUBLIC_DASHBOARD"); + await user.type(valueInputs.at(-1) as HTMLElement, "1"); + await user.click(screen.getByRole("button", { name: "Save defaults" })); + + expect(saveProjectPreviewEnvironmentVariables).toHaveBeenCalledWith( + "p1", + [ + { key: "API_BASE_URL", value: "http://api.local", enabled: true }, + { key: "CODE_UX_ALLOW_PUBLIC_DASHBOARD", value: "1", enabled: true }, + ], + ); + expect(screen.getByText("Preview environment defaults saved. Rebuild containers to apply changes.")).toBeInTheDocument(); + }); + it("shows script save error feedback and keeps the editor available for recovery", async () => { const user = userEvent.setup(); vi.mocked(savePreviewScript).mockRejectedValueOnce(new Error("disk full")); diff --git a/tests/dashboard/v2/top-nav-selectors.test.tsx b/tests/dashboard/v2/top-nav-selectors.test.tsx index ab864d83a4..d23225deb0 100644 --- a/tests/dashboard/v2/top-nav-selectors.test.tsx +++ b/tests/dashboard/v2/top-nav-selectors.test.tsx @@ -299,12 +299,12 @@ describe("TopNav guidance and sprint selectors", () => { }); const styleguideTrigger = screen.getByRole("button", { name: /Styleguide selector/i }); - expect(styleguideTrigger).toHaveTextContent("Code UX Award-Winning Product UI"); + expect(styleguideTrigger).toHaveTextContent("Code UX"); fireEvent.click(styleguideTrigger); const listbox = await screen.findByRole("listbox", { name: "Styleguide list" }); - expect(screen.queryByRole("option", { name: /Code UX Award-Winning Product UI/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /^Code UX$/i })).not.toBeInTheDocument(); const activeDescendantId = styleguideTrigger.getAttribute("aria-activedescendant"); expect(activeDescendantId).toBe("styleguide-option-none");